Compare commits

...
Author SHA1 Message Date
leeferandmultica-agent d9f3328958 HEL-892: expose source-reviewed workflow documentation in shared drawer
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 08:23:47 +08:00
leeferandmultica-agent d927925000 HEL-892: review remaining named fields and inventory UI conversion prerequisites
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:24:09 +08:00
leeferandmultica-agent 1390489289 HEL-892: review remaining source semantics and prepare frontend export comparisons
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:00:21 +08:00
leeferandmultica-agent 8c666d2220 HEL-892: add source-bound partial reviews and offline conversion differential checks
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 00:21:15 +08:00
leeferandmultica-agent 122c51e47e HEL-892: correct stale output mapping assertions after audio support
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 00:07:29 +08:00
leeferandmultica-agent 3cf6ae4241 HEL-892: add offline workflow catalogue and conservative conversion framework
Co-authored-by: multica-agent <github@multica.ai>
2026-10-27 23:39:54 +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
施工员andmultica-agent 70a860ce2a fix(ui): split running/awaiting-confirm colors and neutral kind tags (HEL-651 A batch)
- project card stage segments: running -> --run, awaiting confirm -> --warn
- running counters/dots and 'testing' states -> --run; warn-named notes -> --warn
- image/video/usage kind tags and plain entry/card icons -> neutral tokens

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 08:38:11 +08:00
施工员andmultica-agent e42212a16f feat(ui): fusion design tokens and shared component reskin (HEL-651 A batch)
- app.css: HEL-649 §一 tokens (bg/line/accent/status/text/type/space/radius/shadow/motion), legacy names aliased, reduced-motion via prefers-reduced-motion and data-reduce
- shared .a-btn/.status-chip/.a-panel/inputs reskinned; new .a-tag/.a-progress/.a-sprocket/.a-stage/.a-float
- tailwind colors/radii reference the same CSS variables
- ui primitives (StatusChip/ProgressBar/Modal/Button/ConfirmDialog/Skeleton/Switch/Card/Toast) on tokens
- running state -> --run, awaiting confirm -> --warn, --cloud purple removed, old palette literals -> tokens

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 08:29:37 +08:00
总工 f5f19f0366 merge: HEL-643 import preflight, real test runs and media groups 2026-09-29 19:12:18 +08:00
总工 bfe7b20b73 merge: HEL-641 image deletion and video history click-to-play 2026-09-29 19:12:08 +08:00
总工andmultica-agent 2ed9d25c24 feat(workflows): import preflight, real test runs and media groups (HEL-643)
- L2 连接核查: /object_info preflight (node existence + combo values) with
  5-min cache + manual refresh; analyze accepts endpoint_id, saved workflows
  get POST /{id}/check; unreachable degrades to 无法核查, never blocks saving
- 测试工作流: jobs.origin='workflow_test' rides the real queue → ComfyUI →
  outputs pipeline; only a downloadable, correctly-typed output counts as a
  pass; dry-run refused; repeat clicks reuse the in-flight job; artifacts
  isolated under assets/_workflow_test/{id}; retention keeps newest 5;
  clear-tests + delete-workflow cascade cleanup; tasks page shows a 测试 badge
- Media groups: Autogrow detection generalized to video/audio lists
  (ref_videos/ref_audios etc.), patcher clones/rewires per media family,
  validation per list kind; entries appear only for actually-wired slots;
  MiniMax H3 Ref2VA official-limit hints keyed off the node class
- 25 new backend tests (preflight / workflow-test / media-groups) against the
  fake ComfyUI; fake server gains /object_info + drop_outputs knobs

Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 19:07:20 +08:00
总工andmultica-agent 48e16ae443 feat(studio): image result deletion and video history click-to-play (HEL-641)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 17:48:00 +08:00
施工员andmultica-agent 319c7b049b fix: nine review follow-ups for connections & workflow foundation (HEL-619)
1. .form-col border-box — 384px is the outer width (a6 spec)
2. import preview counts all analyzed inputs, matching inline/studio counts
3. SSRF guard also runs on endpoint save (create + patch)
4. schemeless/garbled URL is a 400 format error, not a DNS-classified probe failure
5. ComfyUI health probe timeout 120s → 5s (matches the test probe)
6. workflow delete refuses (409) while pending/running jobs use it; delete errors surfaced in the UI
7. agent_llm_assignments: per-role incremental merge server-side + single-role delta payloads
8. test_shell_tool now portable (interpreter on PATH; PYTHON+approval instead of ffprobe)
9. test_video_continue pauses the queue singleton via monkeypatch (no leak into test_workbench)

Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 16:13:00 +08:00
549 changed files with 1178396 additions and 13101 deletions
+5
View File
@@ -46,3 +46,8 @@ prototype/
# --- Reference clones (vendored for porting only) ---
reference/
# HEL-892 offline audit is reproducible; local DBs stay private.
.local-evidence/
!docs/hel-892/
!docs/hel-892/**
+6 -1
View File
@@ -37,7 +37,12 @@
"image",
"img",
"vid",
"video"
"video",
"audio",
"sound",
"file",
"srt",
"subtitle"
],
"agent_event_types": [
"assistant/message",
@@ -0,0 +1,360 @@
(
'Source-only UI conversion'
' inventory. This does not'
' implement export rules.'
)
from __future__ import annotations
import argparse
import hashlib
import json
from collections import Counter
from pathlib import Path
def graph_rows(graph: dict, pointer: str = "", scope: str = ""):
(
'Use the same scoped IDs a'
's library documents, incl'
'uding unused definitions.'
)
yield graph, pointer, scope
for i, sub in enumerate(graph.get("definitions", {}).get("subgraphs", [])):
yield from graph_rows(sub, f"{pointer}/definitions/subgraphs/{i}", f"{scope}{sub['id']}/")
def cycles(nodes: list[dict], links: list) -> list[list[str]]:
(
'Strongly connected compon'
'ents of literal edges, no'
't plugin execution loops.'
)
adjacency = {str(n["id"]): set() for n in nodes}
for link in links:
if isinstance(link, list) and len(link) >= 6:
a, b = str(link[1]), str(link[3])
elif isinstance(link, dict) and {"origin_id", "target_id"} <= link.keys():
a, b = str(link["origin_id"]), str(link["target_id"])
else:
continue
if a in adjacency and b in adjacency:
adjacency[a].add(b)
order, low, stack, active, found = {}, {}, [], set(), []
def visit(n):
order[n] = low[n] = len(order)
stack.append(n)
active.add(n)
for target in sorted(adjacency[n]):
if target not in order:
visit(target)
low[n] = min(low[n], low[target])
elif target in active:
low[n] = min(low[n], order[target])
if low[n] == order[n]:
component = []
while True:
target = stack.pop()
active.remove(target)
component.append(target)
if target == n:
break
if len(component) > 1 or n in adjacency[n]:
found.append(sorted(component))
for n in adjacency:
if n not in order:
visit(n)
return found
# Source-review rules: these describe what must be checked, not what an export
# will do. Candidates are kept even when not referenced in the main graph.
RULES = {
"subgraph": (
'按定义ID及实例输入输出逐槽核对展开、实例ID重写'
'和未使用定义剔除;源UI不能证明展开规则。'
),
"reroute": "沿所列输入输出核对转接节点的剔除和端点重连,不能按相邻布局猜线。",
"virtual_candidate": (
"Set/Get或Primitive等候选虚拟节点需对应前端/"
"插件规则及真实导出;type字符串不能单独证明应剔除。"
),
"note": (
'注释原值保留用于说明;实际导出是否剔除需核对,不把'
'注释当服务端模型节点。'
),
"nonzero_mode": (
'源mode非0(2/4保留为静音/旁路候选);逐输'
'入核对跳过/穿透与实际端点,不能只删除节点。'
),
"loop_candidate": (
'循环命名节点仅是候选;核对循环边界、迭代输入和前端'
'展开规则,不能声称静态DAG即可执行。'
),
"literal_cycle": (
'字面连线存在强连通分量;需真实导出与插件循环语义确'
'认,不能直接交队列。'
),
"position_widgets": (
'位置数组逐节点待具名;object_info的in'
'put_order本身不足以证明第三方控件序列化顺'
'序。'
),
"named_widgets": (
'具名控件仍须核对前端状态、动态槽和服务端字段声明;'
'不保证可直接原样拷贝。'
),
"slots": (
'逐条核对起止节点、非零输出槽、输入槽索引、端点声明'
'及源type;通配/联合/类型不一致仍保留待验证。'
),
"node_definitions": (
'逐节点核对服务端字段/槽/必填与插件版本;源UI的'
'type不是可信object_info,实际未采集'
'。'
),
"nonconcrete_slots": (
'源槽声明为通配/联合/枚举等非单一类型;严格相等校'
'验不能自动放行,需真实定义与导出。'
),
"nonzero_output_links": "源连线使用非零输出槽;保留槽号,不能全部改成0。",
"endpoint_findings": (
'仅检查源声明:本地端点缺失可能为子图边界,索引/端'
'点不一致需原前端导出核对,禁止静默纠正。'
),
}
def inventory(repo: Path) -> dict:
docs = repo / "docs/hel-892"
manifest = json.loads((docs / "offline/manifest.json").read_text(encoding="utf-8"))
cases = json.loads((docs / "golden-cases.json").read_text(encoding="utf-8"))["cases"]
files = []
for entry in manifest:
raw = (repo / entry["path"]).read_bytes()
source = json.loads(raw.decode("utf-8-sig"))
assert hashlib.sha1(raw).hexdigest() == entry["source_sha"]
if "nodes" not in source:
continue
findings = {key: [] for key in RULES}
graphs = []
for graph, pointer, scope in graph_rows(source):
nodes = graph["nodes"]
links = graph.get("links", [])
definitions = graph.get("definitions", {}).get("subgraphs", [])
sub_ids = {s["id"] for s in definitions}
# Also allow a subgraph definition referenced from a nested graph.
all_sub_ids = {
s["id"]
for g, _, _ in graph_rows(source)
for s in g.get("definitions", {}).get("subgraphs", [])
}
graph_ref = {
"scope": scope,
"pointer": pointer or "/",
"node_count": len(nodes),
"links": links,
"inputs": graph.get("inputs", []),
"outputs": graph.get("outputs", []),
"extra_keys": sorted(set(graph) - {"nodes", "links"}),
}
graphs.append(graph_ref)
for i, sub in enumerate(definitions):
findings["subgraph"].append(
{
"kind": "definition",
"id": sub["id"],
"name": sub.get("name"),
"pointer": f"{pointer}/definitions/subgraphs/{i}",
"local_instances": [
scope + str(n["id"]) for n in nodes if n.get("type") == sub["id"]
],
}
)
for i, node in enumerate(nodes):
ct = node.get("type", "")
ref = {
"node_id": scope + str(node["id"]),
"type": ct,
"pointer": f"{pointer}/nodes/{i}",
"mode": node.get("mode", 0),
}
if ct in all_sub_ids or ct in sub_ids:
findings["subgraph"].append({**ref, "kind": "instance"})
if "reroute" in ct.lower():
findings["reroute"].append(ref)
if ct in {"SetNode", "GetNode", "PrimitiveNode"} or ct.startswith("Primitive"):
findings["virtual_candidate"].append(
{**ref, "widgets_values": node.get("widgets_values")}
)
if ct in {"Note", "MarkdownNote"}:
findings["note"].append(ref)
if node.get("mode", 0) != 0:
findings["nonzero_mode"].append(ref)
if any(word in ct.lower() for word in ("loop", "foreach", "forloop", "while")):
findings["loop_candidate"].append(ref)
widgets = node.get("widgets_values")
if ct not in {"Note", "MarkdownNote"} and isinstance(widgets, list) and widgets:
findings["position_widgets"].append({**ref, "values": widgets})
if isinstance(widgets, dict) and widgets:
findings["named_widgets"].append({**ref, "fields": sorted(widgets)})
findings["slots"].append(
{**ref, "inputs": node.get("inputs", []), "outputs": node.get("outputs", [])}
)
for direction in ("inputs", "outputs"):
for slot, socket in enumerate(node.get(direction, [])):
value = socket.get("type")
if not isinstance(value, str) or value in {"*", "ANY"} or "," in value:
findings["nonconcrete_slots"].append(
{
**ref,
"direction": direction,
"slot": slot,
"name": socket.get("name"),
"source_type": value,
}
)
if ct not in {"Note", "MarkdownNote"}:
findings["node_definitions"].append(ref)
by_id = {str(n["id"]): n for n in nodes}
for i, link in enumerate(links):
ref = {"scope": scope, "pointer": f"{pointer}/links/{i}", "link": link}
if isinstance(link, dict):
values = [
link.get(k)
for k in (
"id",
"origin_id",
"origin_slot",
"target_id",
"target_slot",
"type",
)
]
elif isinstance(link, list) and len(link) >= 6:
values = link
else:
findings["endpoint_findings"].append({**ref, "reason": "nonstandard_link"})
continue
lid, origin, output, target, input_slot = values[:5]
if type(output) is int and output != 0:
findings["nonzero_output_links"].append(ref)
for node_id, direction, slot in (
(origin, "outputs", output),
(target, "inputs", input_slot),
):
node = by_id.get(str(node_id))
if node is None:
reason = (
'endpoint_not_local_may_be'
'_subgraph_boundary'
)
elif type(slot) is not int or not 0 <= slot < len(node.get(direction, [])):
reason = (
'slot_index_outside_source'
'_declaration'
)
elif direction == "inputs" and node[direction][slot].get("link") != lid:
reason = "input_link_id_not_reciprocal"
elif direction == "outputs" and lid not in (
node[direction][slot].get("links") or []
):
reason = "output_link_id_not_reciprocal"
else:
continue
findings["endpoint_findings"].append(
{
**ref,
"node_id": scope + str(node_id),
"direction": direction,
"slot": slot,
"reason": reason,
}
)
for component in cycles(nodes, links):
findings["literal_cycle"].append(
{"scope": scope, "node_ids": [scope + n for n in component]}
)
matching = [
c
for c in cases
if c["source_path"] == entry["path"] and c["source_sha"] == entry["source_sha"]
]
files.append(
{
"path": entry["path"],
"source_sha": entry["source_sha"],
"facts_method": (
'mechanical_source_structu'
're_extraction'
),
"source_use_review": "docs/hel-892/逐文件第三批审校.md",
"graphs": graphs,
"findings": findings,
"rule_review": [
{
"category": key,
"evidence_count": len(value),
"rule": RULES[key],
"status": "requires_materials_not_implemented",
}
for key, value in findings.items()
if value
],
"golden_case_ids": [c["id"] for c in matching],
"golden_coverage": "exact_source_candidate_only" if matching else "no_exact_sample",
"materials": {
"original_frontend_api_export": {
"source_path": entry["path"],
"source_sha": entry["source_sha"],
"case_export": matching[0]["export_file"] if matching else None,
"needed_for": [
k for k in findings if findings[k] and k != "node_definitions"
],
},
"trusted_definitions": (
'findings.node_definitions'
'逐节点字段和槽;不是模型包或机器访问请求'
),
},
"converted_reliably": False,
"executable": False,
"execution_verified": False,
"remaining": "源结构清单不能当作可靠转换;即使差分equal也不代表可执行",
}
)
actual_ui = {
p.relative_to(repo).as_posix()
for p in (repo / "example_ComfyUI_workflows").rglob("*")
if p.is_file() and "nodes" in json.loads(p.read_text(encoding="utf-8-sig"))
}
assert len(files) == len(actual_ui) == 146
assert {f["path"] for f in files} == actual_ui
return {
"schema_version": 1,
"files": files,
"total_ui_files": len(files),
"remaining_reliable_conversions": 146,
"not_execution_evidence": True,
"files_by_finding": dict(Counter(k for f in files for k, v in f["findings"].items() if v)),
"exact_golden_candidates": sum(bool(f["golden_case_ids"]) for f in files),
"rule_review_method": (
'人工规则核对与机器证据拼接;所有规则仍待真实导出/'
'定义,不是146次实际转换验证'
),
"negative_finding_limit": "未命中命名候选或静态环不证明没有插件隐藏循环/虚拟语义",
}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
report = inventory(args.repo)
args.output.write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps({k: v for k, v in report.items() if k != "files"}, ensure_ascii=False))
@@ -0,0 +1,313 @@
"Compare exported source-only documentation with repository bytes, without services."
from __future__ import annotations
import argparse
import copy
import hashlib
import json
from collections import Counter
from pathlib import Path
def audit(repo: Path, directory: Path) -> dict:
manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8"))
sources = {
p.relative_to(repo).as_posix()
for p in (repo / "example_ComfyUI_workflows").rglob("*")
if p.is_file()
}
assert {e["path"] for e in manifest} == sources
assert len(manifest) == len(sources)
files = []
for entry in manifest:
raw = (repo / entry["path"]).read_bytes()
source = json.loads(raw.decode("utf-8-sig"))
doc = json.loads((directory / entry["document"]).read_text(encoding="utf-8"))
assert hashlib.sha1(raw).hexdigest() == entry["source_sha"]
assert doc["source_review"]["source_sha"] == entry["source_sha"]
nodes = {}
source_links = []
def visit(graph, scope=""):
ui = "nodes" in graph
rows = graph["nodes"] if ui else [dict(n, id=k) for k, n in graph.items()]
for node in rows:
nid = scope + str(node["id"])
nodes[nid] = (
node["widgets_values"]
if ui and "widgets_values" in node
else ([] if ui else node["inputs"])
)
if not ui:
source_links.extend(
{
"scope": scope,
"node_id": nid,
"field": key,
"upstream_node_id": str(value[0]),
"output_slot": value[1],
}
for key, value in node["inputs"].items()
if isinstance(value, list) and len(value) == 2
)
source_links.extend({"scope": scope, "link": link} for link in graph.get("links", []))
for sub in graph.get("definitions", {}).get("subgraphs", []):
visit(sub, scope + sub["id"] + "/")
visit(source)
assert doc["source_links"] == source_links
assert {n["node_id"]: n["source_values"] for n in doc["source_nodes"]} == nodes
for param in doc["params"]:
assert param["default"] == nodes[param["node_id"]][param["field"]]
assert param["unit"] is None
assert param["min"] is None and param["max"] is None
for note in doc["source_notes"]:
assert note["content"] == nodes[note["node_id"]]
checked_fields = doc["source_review"].get("reviewed_named_fields", [])
assert len({(f["node_id"], f["field"]) for f in checked_fields}) == len(checked_fields)
for field in checked_fields:
assert field["source_value"] == nodes[field["node_id"]][field["field"]]
fourth = doc["source_review"].get("fourth_review")
if fourth:
assert fourth["operation_check"] in doc["steps"]
assert {(f["node_id"], f["field"]) for f in checked_fields} == {
(p["node_id"], p["field"]) for p in doc["params"]
}
semantic = doc["source_review"].get("semantic_review")
if semantic:
assert semantic["batch"] == "HEL-892-third"
assert semantic["operation_check"] in doc["steps"]
assert semantic["conclusions"] and semantic["unknowns"]
assert {(f["node_id"], f["field"]) for f in checked_fields} == {
(p["node_id"], p["field"]) for p in doc["params"]
}
for kind in ("nodes", "notes"):
for ref in semantic["evidence_refs"][kind]:
key, index = ref["pointer"].strip("/").split("/")
assert doc[key][int(index)]["node_id"] == ref["node_id"]
assert semantic["mechanical_inventory"] == {
"named_value_count": len(doc["params"]),
"note_count": len(doc["source_notes"]),
"link_count": len(doc["source_links"]),
"position_widget_node_count": len(
doc["source_review"]["evidence"]["unnamed_widget_node_ids"]
),
}
if not checked_fields:
assert semantic["named_parameter_finding"] == ("未发现可由源文件确认的具名参数")
assert doc["status"] == {"imported": True, "doc_done": False, "exec_verified": False}
assert doc["source_review"]["complete"] is False
files.append(
{
"path": entry["path"],
"source_sha": entry["source_sha"],
"document": entry["document"],
"state": entry["state"],
"manual_scope": doc["source_review"]["scope"],
"purpose": doc["purpose"],
"source_specific_instruction": doc["steps"][0],
"source_evidence": doc["source_review"]["evidence"],
"unconfirmed": doc["source_review"]["unconfirmed"],
"reviewed_named_fields": checked_fields,
"semantic_review": semantic,
}
)
return {
"files": files,
"total": len(files),
"source_structure_purpose_partially_reviewed": len(files),
"full_documents_completed": 0,
"files_with_selected_named_fields_reviewed": sum(
bool(f["reviewed_named_fields"]) for f in files
),
"selected_named_fields_reviewed": sum(len(f["reviewed_named_fields"]) for f in files),
"real_executions": 0,
"states": dict(Counter(f["state"] for f in files)),
"named_values_preserved": sum(f["source_evidence"]["named_value_count"] for f in files),
"unnamed_widget_nodes": sum(
len(f["source_evidence"]["unnamed_widget_node_ids"]) for f in files
),
"semantic_batch_files": sum(bool(f["semantic_review"]) for f in files),
"semantic_batch_without_named_fields": sum(
bool(f["semantic_review"]) and not f["reviewed_named_fields"] for f in files
),
"semantic_batch_named_fields": sum(
len(f["reviewed_named_fields"]) for f in files if f["semantic_review"]
),
"named_values_without_manual_field_review": sum(
f["source_evidence"]["named_value_count"] - len(f["reviewed_named_fields"])
for f in files
),
"named_values_without_verified_units_ranges_effects": sum(
f["source_evidence"]["named_value_count"] for f in files
),
"scope_limit": "人工结论与机械原值清单分开;源语义局部审校非完整说明定稿",
}
def audit_partition(repo: Path, report: dict, partition_path: Path) -> dict:
partition = json.loads(partition_path.read_text(encoding="utf-8"))
prior, added = partition["prior_16"], partition["new_140"]
prior_paths = {e["path"] for e in prior}
added_paths = {e["path"] for e in added}
assert len(prior) == len(prior_paths) == 16
assert len(added) == len(added_paths) == 140
assert prior_paths.isdisjoint(added_paths)
assert prior_paths | added_paths == {f["path"] for f in report["files"]}
reviews = json.loads(
(repo / ("calliope-backend/src/calliope/comfyui/source_reviews.json")).read_text(
encoding="utf-8"
)
)
for entry in prior:
# The third-batch snapshot remains immutable historical evidence.
# Reconstruct its exact payload, excluding only the explicit batch-four delta.
review = copy.deepcopy(reviews[entry["path"]])
fourth = review.pop("fourth_review", None)
if fourth:
fourth_fields = {tuple(k) for k in fourth["fields"]}
review["reviewed_named_fields"] = [
f
for f in review["reviewed_named_fields"]
if (f["node_id"], f["field"]) not in fourth_fields
]
assert review["reviewed_named_fields"] == entry["reviewed_named_fields"]
assert (
hashlib.sha256(
json.dumps(review, ensure_ascii=False, sort_keys=True).encode()
).hexdigest()
== entry["review_sha256"]
)
by_path = {f["path"]: f for f in report["files"]}
for entry in prior + added:
assert entry["source_sha"] == by_path[entry["path"]]["source_sha"]
assert all(by_path[p]["semantic_review"] for p in added_paths)
assert all(not by_path[p]["semantic_review"] for p in prior_paths)
return {
"prior_files": 16,
"prior_named_fields": 42,
"new_files": 140,
"new_named_fields": report["semantic_batch_named_fields"],
"new_without_named_fields": report["semantic_batch_without_named_fields"],
"disjoint_complete_sha_verified": True,
"prior_review_payloads_unchanged": True,
}
def audit_fourth(repo: Path, report: dict, partition_path: Path) -> dict:
partition = json.loads(partition_path.read_text(encoding="utf-8"))
mechanical = {tuple(k) for k in partition["mechanical_1227"]}
prior = {tuple(k) for k in partition["prior_542"]}
added = {tuple(k) for k in partition["added_685"]}
assert len(mechanical) == len(partition["mechanical_1227"]) == 1227
assert len(prior) == len(partition["prior_542"]) == 542
assert len(added) == len(partition["added_685"]) == 685
assert prior.isdisjoint(added) and prior | added == mechanical
assert len({k[0] for k in added}) == 16
reviews = json.loads(
(repo / ("calliope-backend/src/calliope/comfyui/source_reviews.json")).read_text(
encoding="utf-8"
)
)
current_ids, checked_added, categories = set(), set(), Counter()
for path, original in reviews.items():
review = copy.deepcopy(original)
fourth = review.pop("fourth_review", None)
fields = review["reviewed_named_fields"]
current_ids.update((path, f["node_id"], f["field"]) for f in fields)
if fourth:
raw = (repo / path).read_bytes()
source = json.loads(raw.decode("utf-8-sig"))
assert hashlib.sha1(raw).hexdigest() == review["source_sha"]
new_fields = [f for f in fields if (path, f["node_id"], f["field"]) in added]
assert {(f["node_id"], f["field"]) for f in new_fields} == {
tuple(k) for k in fourth["fields"]
}
for f in new_fields:
assert f["source_path"] == path and f["source_sha"] == review["source_sha"]
value = source
for part in f["source_pointer"].strip("/").split("/"):
part = part.replace("~1", "/").replace("~0", "~")
value = value[int(part)] if isinstance(value, list) else value[part]
assert json.dumps(value, sort_keys=True) == json.dumps(
f["source_value"], sort_keys=True
)
nid = f["node_id"]
if "nodes" in source:
node = next(n for n in source["nodes"] if str(n["id"]) == nid)
assert f["class_type"] == node["type"]
for direction, endpoint in (("incoming", 3), ("outgoing", 1)):
actual = [
{"pointer": f"/links/{i}", "link": link}
for i, link in enumerate(source.get("links", []))
if str(link[endpoint]) == nid
]
assert f["connections"][direction] == actual
else:
assert f["class_type"] == source[nid]["class_type"]
assert f["connections"]["incoming"] == [
{"field": k, "link": v} for k, v in source[nid]["inputs"].items()
if isinstance(v, list)
]
assert f["connections"]["outgoing"] == [
{"node_id": target, "field": k, "link": v}
for target, n in source.items() for k, v in n["inputs"].items()
if isinstance(v, list) and len(v) == 2 and v[0] == nid
]
assert f["category"] in {
"runtime_parameter",
"frontend_state",
"current_value_only",
"semantic_unknown",
}
assert f["label"] and f["operation_check"] and f["connections"]
categories[f["category"]] += 1
checked_added.add((path, f["node_id"], f["field"]))
assert fourth["classification_counts"] == dict(
Counter(f["category"] for f in new_fields)
)
review["reviewed_named_fields"] = [
f for f in fields if (path, f["node_id"], f["field"]) not in added
]
assert (
hashlib.sha256(
json.dumps(review, ensure_ascii=False, sort_keys=True).encode()
).hexdigest()
== partition["prior_review_sha256"][path]
)
assert current_ids == mechanical and checked_added == added
assert report["selected_named_fields_reviewed"] == 1227
return {
"mechanical_values_checked": 1227,
"prior_reviewed": 542,
"added_reviewed": 685,
"new_files": 16,
"classification_counts_new": dict(categories),
"duplicates": 0,
"prior_156_payloads_preserved": True,
"added_fields_with_runtime_source_use": categories["runtime_parameter"],
"added_fields_without_confirmed_runtime_meaning": 685 - categories["runtime_parameter"],
"verified_units_ranges_effects": 0,
"full_documents_completed": 0,
"real_executions": 0,
}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", type=Path, required=True)
parser.add_argument("--documents", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--partition", type=Path)
parser.add_argument("--fourth-partition", type=Path)
args = parser.parse_args()
report = audit(args.repo, args.documents)
if args.partition:
report["partition"] = audit_partition(args.repo, report, args.partition)
if args.fourth_partition:
report["fourth_partition"] = audit_fourth(args.repo, report, args.fourth_partition)
args.output.write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps({k: v for k, v in report.items() if k != "files"}, ensure_ascii=False))
@@ -0,0 +1,219 @@
"""Offline, fail-closed comparison against source-bound original frontend exports.
This command never imports a workflow, enables one, calls a service, or executes it.
An equal result only establishes equality to the supplied export, not its provenance.
"""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
from calliope.comfyui.library import load_snapshot
from calliope.comfyui.ui2api import convert
def differences(expected, actual, pointer=""):
"""Keep types, absent keys, list order and nonzero output slots significant."""
if type(expected) is not type(actual):
return [{"path": pointer, "expected": expected, "actual": actual, "kind": "type"}]
if isinstance(expected, dict):
rows = []
for key in sorted(expected.keys() | actual.keys()):
child = pointer + "/" + key.replace("~", "~0").replace("/", "~1")
if key not in expected or key not in actual:
rows.append(
{
"path": child,
"kind": "missing_key",
"expected_present": key in expected,
"actual_present": key in actual,
"expected": expected.get(key),
"actual": actual.get(key),
}
)
else:
rows.extend(differences(expected[key], actual[key], child))
return rows
if isinstance(expected, list):
rows = []
for index in range(max(len(expected), len(actual))):
child = pointer + "/" + str(index)
if index >= len(expected) or index >= len(actual):
rows.append(
{
"path": child,
"kind": "missing_item",
"expected_present": index < len(expected),
"actual_present": index < len(actual),
"expected": expected[index] if index < len(expected) else None,
"actual": actual[index] if index < len(actual) else None,
}
)
else:
rows.extend(differences(expected[index], actual[index], child))
return rows
return (
[]
if expected == actual
else [{"path": pointer, "kind": "value", "expected": expected, "actual": actual}]
)
def preserved_fields(expected, actual, pointer=""):
if type(expected) is not type(actual):
return []
if isinstance(expected, dict):
return [
row
for key in sorted(expected.keys() & actual.keys())
for row in preserved_fields(
expected[key],
actual[key],
pointer + "/" + key.replace("~", "~0").replace("/", "~1"),
)
]
if isinstance(expected, list):
return [
row
for index, (left, right) in enumerate(zip(expected, actual))
for row in preserved_fields(left, right, pointer + "/" + str(index))
]
return [{"path": pointer, "value": expected}] if expected == actual else []
def compare_case(repo, exports, case, snapshot=None):
row = {**case, "executable": False, "execution_verified": False}
try:
raw = (repo / case["source_path"]).read_bytes()
if hashlib.sha1(raw).hexdigest() != case["source_sha"]:
return {**row, "status": "source_sha_mismatch"}
source = json.loads(raw.decode("utf-8-sig"))
missing = [
name
for name in (case["export_file"], case["binding_file"])
if not (exports / name).is_file()
]
if missing:
return {
**row,
"status": "missing_materials",
"missing": missing,
"cannot_check": case["comparison"],
"node_definitions_available": bool(snapshot),
"missing_definitions": [] if snapshot else [
"未提供可信节点定义;UI位置控件和插件行为不能仅凭源文件确认"
],
}
export_raw = (exports / case["export_file"]).read_bytes()
binding = json.loads((exports / case["binding_file"]).read_text(encoding="utf-8"))
# Binding is an operator statement, not automatic authentication of provenance.
required = {
"source_path": case["source_path"],
"source_sha": case["source_sha"],
"export_sha256": hashlib.sha256(export_raw).hexdigest(),
"original_frontend_export": True,
}
if any(
type(binding.get(k)) is not type(v) or binding.get(k) != v for k, v in required.items()
) or not all(
isinstance(binding.get(k), str) and binding[k].strip()
for k in ("exported_at", "frontend_version", "node_versions", "origin")
):
return {**row, "status": "invalid_binding", "required": required}
expected = json.loads(export_raw.decode("utf-8-sig"))
# A UI file is not an original API export, even when convert could accept it.
if not isinstance(expected, dict) or "nodes" in expected:
return {**row, "status": "invalid_export", "issues": ["必须为原始API图"]}
checked = convert(expected)
if checked.prompt is None:
return {**row, "status": "invalid_export", "issues": checked.issues}
result = convert(source, (snapshot or {}).get("object_info"))
row.update(
export_sha256=required["export_sha256"],
binding=binding,
provenance="仅记录提供者声明;真实性仍需审核",
)
if result.prompt is None:
return {
**row,
"status": "conversion_blocked",
"issues": result.issues,
"cannot_check": "转换器未生成完整prompt,不能逐字段/连线对照",
}
def split(graph):
return (
{
nid: {k: v for k, v in node.items() if k != "_meta"}
for nid, node in graph.items()
},
{nid: node["_meta"] for nid, node in graph.items() if "_meta" in node},
)
expected_graph, expected_meta = split(expected)
actual_graph, actual_meta = split(result.prompt)
delta = differences(expected_graph, actual_graph)
metadata = differences(expected_meta, actual_meta)
return {
**row,
"status": "different" if delta or metadata else "equal",
"differences": delta,
"metadata_differences": metadata,
"preserved_fields": preserved_fields(expected_graph, actual_graph),
"preserved_node_ids": sorted(expected.keys() & result.prompt.keys()),
"comparison_scope": "全部字段、类型、值及连线槽;_meta单列也影响equal",
}
except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
return {**row, "status": "invalid_materials", "error": f"{type(exc).__name__}: {exc}"}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", type=Path, required=True)
parser.add_argument("--cases", type=Path, required=True)
parser.add_argument("--exports", type=Path, required=True)
parser.add_argument("--snapshot", type=Path)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
cases = json.loads(args.cases.read_text(encoding="utf-8"))["cases"]
try:
snapshot = load_snapshot(args.snapshot)
except (OSError, ValueError, AttributeError) as exc:
report = {
"status": "invalid_snapshot",
"error": str(exc),
"cases": [],
"execution_verified": False,
}
else:
rows = [compare_case(args.repo, args.exports, case, snapshot) for case in cases]
report = {
"status": "equal"
if rows and all(r["status"] == "equal" for r in rows)
else "not_ready",
"cases": rows,
"execution_verified": False,
"snapshot_source": (snapshot or {}).get("source_url"),
"snapshot_captured_at": (snapshot or {}).get("captured_at"),
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(
json.dumps(
{
"status": report["status"],
"cases": [{"id": r["id"], "status": r["status"]} for r in report["cases"]],
},
ensure_ascii=False,
)
)
return 0 if report["status"] == "equal" else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -2,17 +2,39 @@
from __future__ import annotations
import logging
import time
import uuid
from pathlib import Path
from typing import Any
import httpx
from calliope.comfyui.registry import (
AUDIO_CLASSES,
IMAGE_CLASSES,
VIDEO_CLASSES,
VIDEO_FILE_CLASSES,
)
from calliope.config import settings
from calliope.comfyui.registry import IMAGE_CLASSES, AUDIO_CLASSES, VIDEO_CLASSES, VIDEO_FILE_CLASSES
logger = logging.getLogger("calliope.comfyui")
# Reachability probe budget (HEL-619): matches the connections-page test
# probe (5s). Without it health() inherits the 120s client default, so a
# bad endpoint hangs every queued job for two minutes before failing.
HEALTH_TIMEOUT_SEC = 5.0
# /object_info fetch is a few MB on big instances; the preflight check
# (HEL-642/643) caches it per endpoint for this long. Manual refresh
# (`force_refresh=True`) bypasses the TTL.
OBJECT_INFO_TTL_SEC = 300.0
_object_info_cache: dict[str, tuple[float, dict[str, Any]]] = {}
def clear_object_info_cache() -> None:
"""Drop cached /object_info payloads (tests swap fake endpoints in)."""
_object_info_cache.clear()
def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
"""Raise with ComfyUI's own error body instead of an opaque status line.
@@ -76,11 +98,40 @@ class ComfyUIClient:
async def health(self) -> bool:
try:
resp = await self._http.get(f"{self.base_url}/system_stats")
resp = await self._http.get(
f"{self.base_url}/system_stats", timeout=HEALTH_TIMEOUT_SEC
)
return resp.status_code == 200
except Exception:
return False
def _cache_key(self) -> str:
auth = self._headers.get("Authorization") or next(
(v for k, v in self._headers.items() if k.lower() not in ("authorization",)), ""
)
return f"{self.base_url}|{auth}"
async def object_info(self, force_refresh: bool = False) -> dict[str, Any]:
"""The server's full ``/object_info`` node catalogue, cached per endpoint.
Cached for OBJECT_INFO_TTL_SEC; ``force_refresh`` re-fetches (the
connections page "重新检查" button after the user installs nodes).
Raises on transport/HTTP errors so callers can report honestly.
"""
key = self._cache_key()
now = time.monotonic()
if not force_refresh:
hit = _object_info_cache.get(key)
if hit and now - hit[0] < OBJECT_INFO_TTL_SEC:
return hit[1]
resp = await self._http.get(f"{self.base_url}/object_info", timeout=30.0)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, dict):
raise RuntimeError("ComfyUI 返回的 /object_info 不是对象")
_object_info_cache[key] = (now, data)
return data
async def upload_image(self, path: Path, subfolder: str = "calliope") -> str:
data = path.read_bytes()
files = {"image": (path.name, data, "application/octet-stream")}
@@ -290,6 +341,9 @@ class ComfyUIClient:
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mkv", ".mov", ".gif", ".m4v", ".avi", ".flv"})
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"})
_AUDIO_EXTS = frozenset({".wav", ".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac"})
_FILE_EXTS = frozenset({".srt", ".vtt", ".txt", ".json"})
@classmethod
def _infer_media_kind(cls, filename: str, history_key: str) -> str | None:
ext = Path(str(filename)).suffix.lower()
@@ -297,6 +351,12 @@ class ComfyUIClient:
return "video"
if ext in cls._IMAGE_EXTS:
return "image"
if ext in cls._AUDIO_EXTS:
return "audio"
if ext in cls._FILE_EXTS:
return "file"
if history_key in ("audio", "audios"):
return "audio"
if history_key in ("gifs", "videos"):
return "video"
return None
@@ -315,9 +375,14 @@ class ComfyUIClient:
for node_id, node_out in (history.get("outputs") or {}).items():
if node_kinds is not None and str(node_id) not in node_kinds:
continue
for key in ("images", "gifs", "videos"):
for item in node_out.get(key) or []:
filename = item.get("filename", "")
for key in ("images", "gifs", "videos", "audio", "audios", "files"):
items = node_out.get(key) or []
if not isinstance(items, list):
continue
for item in items:
if not isinstance(item, dict) or not item.get("filename"):
continue
filename = item["filename"]
outputs.append(
{
"filename": filename,
@@ -0,0 +1,504 @@
"""Source-tracked, disabled-by-default example library with reversible batches."""
from __future__ import annotations
import hashlib
import json
import sqlite3
import uuid
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from calliope.comfyui.lint import workflow_warnings
from calliope.comfyui.output_map import auto_output_map, needs_user_output_map
from calliope.comfyui.parser import classify_usage, parse_dynamic_inputs, parse_dynamic_outputs
from calliope.comfyui.preflight import check_workflow_against
from calliope.comfyui.source_reviews import review_for
from calliope.comfyui.ui2api import convert
LIBRARY_COLUMNS = {
"source_repo_path": "TEXT",
"source_sha": "TEXT",
"source_commit": "TEXT",
"batch_id": "TEXT",
"raw_source_json": "TEXT",
"doc_json": "TEXT",
"import_state": "TEXT",
"import_issues": "TEXT",
"imported_at": "TEXT",
"doc_completed_at": "TEXT",
"exec_verified_at": "TEXT",
"library_fingerprint": "TEXT",
}
def migrate_library(conn: sqlite3.Connection) -> None:
"""Rebuild only the legacy kind constraint; preserve columns, indexes and FKs."""
sql = conn.execute("SELECT sql FROM sqlite_master WHERE name='workflows'").fetchone()[0]
old = "CHECK(kind IN ('image', 'video'))"
if old in sql:
conn.commit()
foreign_keys = conn.execute("PRAGMA foreign_keys").fetchone()[0]
conn.execute("PRAGMA foreign_keys=OFF")
try:
conn.execute("BEGIN")
has_sequence = conn.execute(
"SELECT 1 FROM sqlite_master WHERE name='sqlite_sequence'"
).fetchone()
sequence = (
conn.execute("SELECT seq FROM sqlite_sequence WHERE name='workflows'").fetchone()
if has_sequence
else None
)
objects = conn.execute(
"SELECT sql FROM sqlite_master WHERE tbl_name='workflows' "
"AND type IN ('index','trigger') AND sql IS NOT NULL"
).fetchall()
conn.execute(
sql.replace(
"CREATE TABLE workflows", "CREATE TABLE workflows_library_new", 1
).replace(old, "CHECK(kind IN ('image', 'video', 'audio', 'file', 'unknown'))")
)
columns = ",".join(
'"' + r[1] + '"' for r in conn.execute("PRAGMA table_info(workflows)")
)
conn.execute(
f"INSERT INTO workflows_library_new ({columns}) SELECT {columns} FROM workflows"
)
conn.execute("DROP TABLE workflows")
conn.execute("ALTER TABLE workflows_library_new RENAME TO workflows")
if sequence is not None:
conn.execute(
"UPDATE sqlite_sequence SET seq=MAX(seq,?) WHERE name='workflows'",
(sequence[0],),
)
for obj in objects:
conn.execute(obj[0])
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.execute(f"PRAGMA foreign_keys={foreign_keys}")
existing = {r[1] for r in conn.execute("PRAGMA table_info(workflows)")}
for name, kind in LIBRARY_COLUMNS.items():
if name not in existing:
conn.execute(f"ALTER TABLE workflows ADD COLUMN {name} {kind}")
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_source ON workflows(source_repo_path) "
"WHERE source_repo_path IS NOT NULL"
)
conn.execute(
"CREATE TABLE IF NOT EXISTS workflow_revisions ("
"id INTEGER PRIMARY KEY, batch_id TEXT NOT NULL, workflow_id INTEGER NOT NULL, "
"before_json TEXT, after_fingerprint TEXT NOT NULL, rolled_back INTEGER DEFAULT 0)"
)
def _json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True)
def fingerprint(row: dict[str, Any]) -> str:
# Include enable/map/description edits so a later import cannot discard user work.
data = {k: v for k, v in row.items() if k not in ("id", "created_at", "library_fingerprint")}
return hashlib.sha256(_json(data).encode()).hexdigest()
def load_snapshot(path: Path | None) -> dict[str, Any] | None:
if path is None:
return None
value = json.loads(path.read_text())
if (
value.get("status") != "captured"
or not value.get("source_url", "").endswith("/object_info")
or not value.get("captured_at")
or not isinstance(value.get("object_info"), dict)
):
raise ValueError("需要含来源、时间和 object_info 的成功快照;连接失败记录不可用于转换")
return value
def build_document(
path: str,
source: Any,
prompt: dict | None,
snapshot: dict | None,
issues: list[dict],
review: dict | None = None,
) -> dict:
"""Evidence draft: keep UI values unnamed, never invent units or parameter ranges."""
info = (snapshot or {}).get("object_info", {})
params, inventory, notes, links = [], [], [], []
def inspect(graph: dict, scope: str = "") -> None:
is_ui = isinstance(graph.get("nodes"), list)
nodes = (
graph["nodes"]
if is_ui
else [
dict(v, id=k, type=v.get("class_type"))
for k, v in graph.items()
if isinstance(v, dict) and "class_type" in v
]
)
for node in nodes:
nid = scope + str(node.get("id", ""))
ct = node.get("type", "")
values = node.get("widgets_values", []) if is_ui else node.get("inputs", {})
inventory.append(
{
"node_id": nid,
"class_type": ct,
"title": node.get("title") or (node.get("_meta") or {}).get("title"),
"mode": node.get("mode", 0),
"source_values": values,
"inputs": node.get("inputs", []) if is_ui else values,
}
)
if ct in ("Note", "MarkdownNote"):
notes.append({"node_id": nid, "content": values})
# Only API/named fields have a defensible node+field association.
if isinstance(values, dict):
declared = {
**info.get(ct, {}).get("input", {}).get("required", {}),
**info.get(ct, {}).get("input", {}).get("optional", {}),
}
for name, value in values.items():
if is_ui and name == "videopreview":
# Editor preview metadata is preserved in source_nodes only.
continue
if not is_ui and isinstance(value, list) and len(value) == 2:
links.append(
{
"scope": scope,
"node_id": nid,
"field": name,
"upstream_node_id": str(value[0]),
"output_slot": value[1],
}
)
continue
spec = declared.get(name, [])
cfg = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
params.append(
{
"name": name,
"node_id": nid,
"field": name,
"type": spec[0]
if spec and isinstance(spec[0], str)
else type(value).__name__,
"default": value,
"unit": None,
"min": cfg.get("min"),
"max": cfg.get("max"),
"options": spec[0] if spec and isinstance(spec[0], list) else None,
"range_source": "object_info" if spec else "unverified",
"default_source": "source_file",
"value_origin": "ui_named_widget" if is_ui else "api_input",
"effect": cfg.get("tooltip") or "待节点定义及人工核对",
"advanced": False,
}
)
links.extend({"scope": scope, "link": link} for link in graph.get("links", []))
for sub in (graph.get("definitions") or {}).get("subgraphs", []):
inspect(sub, scope + str(sub.get("id", "?")) + "/")
if isinstance(source, dict):
inspect(source)
model_exts = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".onnx")
models = set()
def strings(value: Any) -> None:
if isinstance(value, str) and value.lower().endswith(model_exts):
models.add(value)
elif isinstance(value, (list, dict)):
for v in value.values() if isinstance(value, dict) else value:
strings(v)
# Editor metadata and note download URLs are not active model selections.
for node in inventory:
if node["class_type"] not in ("Note", "MarkdownNote"):
strings(node["source_values"])
inputs = parse_dynamic_inputs(prompt or {})
outputs = parse_dynamic_outputs(prompt or {})
document = {
"title": Path(path).stem,
"purpose": f"源目录:{Path(path).parent};工作流:{Path(path).stem}。用途待逐节点复核。",
"draft": True,
"source_path": path,
"materials": [
{"slot": i.get("nodeId"), "desc": i.get("label"), "required": i.get("required", False)}
for i in inputs
if i.get("kind") in ("image", "video", "audio", "image_list")
],
"dependencies": {
"models": sorted(models),
"node_types": sorted({n["class_type"] for n in inventory}),
"node_packs": [],
"missing": [],
"check_state": "unchecked",
"source": (snapshot or {}).get("source_url"),
"checked_at": None,
"model_locations": "待目标机提供",
},
"params": params,
"source_nodes": inventory,
"source_links": links,
"source_notes": notes,
"steps": [
f"打开源工作流 {path},逐项核对下列源节点与注释。",
"补齐节点定义、模型来源及所需素材;完成输入输出映射核对。",
"明确触发真实测试后核对产物;本批未提交生成任务。",
],
"io": {"inputs": inputs, "outputs": outputs},
"errors": [
{"symptom": i["message"], "fix": "按具体节点补材料或提供原 ComfyUI API 导出。"}
for i in issues
],
"status": {"imported": True, "doc_done": False, "exec_verified": False},
}
if review:
document["purpose"] = review["purpose"]
document["steps"].insert(0, review["instruction"])
if review.get("semantic_review"):
document["steps"].insert(1, review["semantic_review"]["operation_check"])
if review.get("fourth_review"):
document["steps"].insert(1, review["fourth_review"]["operation_check"])
document["source_review"] = {
**review,
"scope": review.get("scope", ["结构及用途局部审校"]),
"complete": False,
"unconfirmed": [
"逐参数语义、单位、范围和调整效果未定稿",
*(
["UI 位置控件未具名,含子图和旁路的实际执行路径待黄金导出"]
if isinstance(source, dict) and "nodes" in source
else []
),
"模型/节点包位置、素材可用性及实际输出未在目标环境核对",
],
"evidence": {
"node_count": len(inventory),
"link_count": len(links),
"named_value_count": len(params),
"note_node_ids": [n["node_id"] for n in notes],
"model_references": sorted(models),
"unnamed_widget_node_ids": [
n["node_id"]
for n in inventory
if n["class_type"] not in ("Note", "MarkdownNote")
and isinstance(n["source_values"], list)
and n["source_values"]
],
},
}
return document
def import_library(
conn: sqlite3.Connection, root: Path, *, source_commit: str, snapshot: dict | None = None
) -> dict:
"""Caller supplies a migrated local DB. Single transaction; no network or jobs."""
batch = str(uuid.uuid4())
report: dict[str, Any] = {"batch_id": batch, "source_commit": source_commit, "files": []}
info = (snapshot or {}).get("object_info")
conn.row_factory = sqlite3.Row
with conn:
conn.execute("BEGIN IMMEDIATE")
for path in sorted(p for p in root.rglob("*") if p.is_file()):
relative = root.name + "/" + path.relative_to(root).as_posix()
raw = path.read_bytes()
sha = hashlib.sha1(raw).hexdigest()
review = review_for(relative, sha)
review_sha = hashlib.sha256(_json(review).encode()).hexdigest()
existing = conn.execute(
"SELECT * FROM workflows WHERE source_repo_path=?", (relative,)
).fetchone()
record = {"path": relative, "source_sha": sha}
if existing and fingerprint(dict(existing)) != existing["library_fingerprint"]:
report["files"].append(
{
**record,
"result": "conflict",
"workflow_id": existing["id"],
"issues": [
{
"code": "user_modified",
"message": "已有条目被用户修改,完整保留;未覆盖",
}
],
}
)
continue
# A fresh snapshot may improve the conversion even when source bytes are unchanged.
snapshot_sha = hashlib.sha256(_json(snapshot).encode()).hexdigest()
if (
existing
and existing["source_sha"] == sha
and existing["import_state"] != "rolled_back"
):
old_doc = json.loads(existing["doc_json"])
if (
old_doc.get("snapshot_sha") == snapshot_sha
and old_doc.get("source_review_sha") == review_sha
):
report["files"].append(
{
**record,
"result": "skipped",
"workflow_id": existing["id"],
"state": existing["import_state"],
"issues": json.loads(existing["import_issues"]),
}
)
continue
source = None
try:
text = raw.decode("utf-8-sig")
source = json.loads(text)
result = convert(source, info)
prompt, issues = result.prompt, result.issues
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
text = raw.hex()
prompt, issues = (
None,
[{"code": "invalid_source", "message": f"源文件无法读取为 JSON:{exc}"}],
)
if prompt:
issues.extend(i for i in workflow_warnings(prompt) if i["level"] == "error")
findings = check_workflow_against(info, prompt) if info else []
issues.extend(findings)
if not info:
issues.append(
{"code": "environment_unchecked", "message": "目标机节点和模型尚未核对"}
)
if needs_user_output_map(prompt) or not auto_output_map(prompt):
issues.append(
{"code": "output_unconfirmed", "message": "输出节点或类型待人工确认"}
)
state = (
"needs_conversion"
if prompt is None
else "pending_check"
if issues
else "pending_test"
)
doc = build_document(relative, source, prompt, snapshot, issues, review)
doc["snapshot_sha"] = snapshot_sha
doc["source_review_sha"] = review_sha
judgment = classify_usage(prompt or {})
outputs = auto_output_map(prompt or {})
kinds = {o["kind"] for o in outputs}
kind = next(iter(kinds)) if len(kinds) == 1 else "unknown"
values = {
"name": path.stem,
"kind": kind,
"workflow_json": _json(prompt or {}),
"input_schema": _json(parse_dynamic_inputs(prompt or {})),
"output_schema": _json(parse_dynamic_outputs(prompt or {})),
"output_map": _json(outputs),
"output_map_source": "auto" if outputs else None,
"usage": judgment["usage"],
"usage_source": "auto",
"is_enabled": 0,
"source_repo_path": relative,
"source_sha": sha,
"source_commit": source_commit,
"batch_id": batch,
"raw_source_json": text,
"doc_json": _json(doc),
"import_state": state,
"import_issues": _json(issues),
"imported_at": datetime.now(timezone.utc).isoformat(),
"doc_completed_at": None,
"exec_verified_at": None,
}
before = dict(existing) if existing else None
if existing:
wid = existing["id"]
conn.execute(
"UPDATE workflows SET " + ",".join(f"{k}=?" for k in values) + " WHERE id=?",
[*values.values(), wid],
)
else:
cur = conn.execute(
"INSERT INTO workflows ("
+ ",".join(values)
+ ") VALUES ("
+ ",".join("?" for _ in values)
+ ")",
list(values.values()),
)
wid = cur.lastrowid
row = dict(conn.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone())
digest = fingerprint(row)
conn.execute("UPDATE workflows SET library_fingerprint=? WHERE id=?", (digest, wid))
conn.execute(
"INSERT INTO workflow_revisions "
"(batch_id,workflow_id,before_json,after_fingerprint) "
"VALUES (?,?,?,?)",
(batch, wid, _json(before) if before else None, digest),
)
report["files"].append(
{
**record,
"result": "updated" if existing else "imported",
"workflow_id": wid,
"state": state,
"issues": issues,
}
)
report["counts"] = dict(Counter(r["result"] for r in report["files"]))
report["states"] = dict(Counter(r.get("state", "conflict") for r in report["files"]))
report["total"] = len(report["files"])
report["doc_completed"] = 0
report["exec_verified"] = 0
return report
def rollback_batch(conn: sqlite3.Connection, batch_id: str) -> dict:
"""Restore previous snapshots. Refuse user edits and referenced new rows."""
conn.row_factory = sqlite3.Row
results = []
with conn:
conn.execute("BEGIN IMMEDIATE")
revisions = conn.execute(
"SELECT * FROM workflow_revisions WHERE batch_id=? AND rolled_back=0 ORDER BY id DESC",
(batch_id,),
).fetchall()
for revision in revisions:
wid = revision["workflow_id"]
row = conn.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone()
if row is None or fingerprint(dict(row)) != revision["after_fingerprint"]:
results.append({"workflow_id": wid, "result": "conflict"})
continue
if revision["before_json"]:
before = json.loads(revision["before_json"])
conn.execute(
"UPDATE workflows SET " + ",".join(f"{k}=?" for k in before) + " WHERE id=?",
[*before.values(), wid],
)
else:
# Deleting a new row could detach jobs/canvas references. Keep it disabled instead.
conn.execute(
"UPDATE workflows SET is_enabled=0, import_state=? WHERE id=?",
("rolled_back", wid),
)
new = dict(conn.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone())
conn.execute(
"UPDATE workflows SET library_fingerprint=? WHERE id=?", (fingerprint(new), wid)
)
conn.execute(
"UPDATE workflow_revisions SET rolled_back=1 WHERE id=?", (revision["id"],)
)
results.append(
{
"workflow_id": wid,
"result": "restored" if revision["before_json"] else "disabled",
}
)
return {"batch_id": batch_id, "files": results}
@@ -0,0 +1,99 @@
"""Explicit local library import/rollback and read-only object_info snapshot CLI."""
from __future__ import annotations
import argparse
import asyncio
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
import httpx
from calliope.comfyui.library import import_library, load_snapshot, rollback_batch
from calliope.db import get_db, migrate_db
def write(path: Path, data: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
snap = commands.add_parser("snapshot")
snap.add_argument("--url", required=True)
snap.add_argument("--output", type=Path, required=True)
for command in ("import", "rollback"):
sub = commands.add_parser(command)
sub.add_argument(
"--db", type=Path, required=True, help="Explicit local database; never a remote service"
)
sub.add_argument("--output-dir", type=Path, required=True)
if command == "import":
sub.add_argument("--root", type=Path, required=True)
sub.add_argument("--source-commit", required=True)
sub.add_argument("--snapshot", type=Path)
else:
sub.add_argument("--batch-id", required=True)
args = parser.parse_args()
if args.command == "snapshot":
data = {
"source_url": args.url.rstrip("/") + "/object_info",
"captured_at": datetime.now(timezone.utc).isoformat(),
}
try:
response = httpx.get(data["source_url"], timeout=10, trust_env=False)
response.raise_for_status()
info = response.json()
if not isinstance(info, dict) or not info:
raise ValueError("object_info must be a nonempty object")
data.update(status="captured", object_info=info)
except (httpx.HTTPError, ValueError) as exc:
data.update(status="unavailable", error=f"{type(exc).__name__}: {exc}")
write(args.output, data)
print(data["status"])
if data["status"] != "captured":
raise SystemExit(1)
return
if args.command == "import" and not args.root.is_dir():
parser.error("--root 必须为存在的目录,避免误报空库成功")
snapshot = load_snapshot(args.snapshot) if args.command == "import" else None
asyncio.run(migrate_db(args.db))
conn = get_db(args.db)
try:
if args.command == "import":
report = import_library(
conn, args.root, source_commit=args.source_commit, snapshot=snapshot
)
documents = []
for row in conn.execute(
"SELECT source_repo_path, source_sha, import_state, doc_json "
"FROM workflows WHERE source_repo_path IS NOT NULL ORDER BY source_repo_path"
):
doc = json.loads(row["doc_json"])
key = hashlib.sha256(row["source_repo_path"].encode()).hexdigest()[:16]
write(args.output_dir / "documents" / (key + ".json"), doc)
documents.append(
{
"path": row["source_repo_path"],
"source_sha": row["source_sha"],
"state": row["import_state"],
"doc_done": bool(row["doc_json"] and doc["status"]["doc_done"]),
"source_review_scope": (doc.get("source_review") or {}).get("scope", []),
"document": "documents/" + key + ".json",
}
)
write(args.output_dir / "manifest.json", documents)
else:
report = rollback_batch(conn, args.batch_id)
write(args.output_dir / (args.command + "_report.json"), report)
print(json.dumps({k: v for k, v in report.items() if k != "files"}, ensure_ascii=False))
finally:
conn.close()
if __name__ == "__main__":
main()
@@ -91,7 +91,7 @@ def workflow_warnings(workflow: dict[str, Any]) -> list[dict[str, str]]:
from calliope.comfyui.parser import detect_output_nodes
outputs = detect_output_nodes(workflow)
if not any(o.get("kind") in ("image", "video") for o in outputs):
if not any(o.get("kind") in ("image", "video", "audio", "file") for o in outputs):
unknown = [o for o in outputs if o.get("evidence") == "unknown_node"]
if not unknown:
found.append(
@@ -19,13 +19,12 @@ from typing import Any
from calliope.comfyui.parser import parse_dynamic_outputs
from calliope.comfyui.registry import (
IMAGE_OUTPUT_CLASSES,
NUMBER_CLASSES,
OUTPUT_CLASSES,
TEXT_AREA_CLASSES,
VIDEO_OUTPUT_CLASSES,
)
VALID_OUTPUT_KINDS = ("image", "video")
VALID_OUTPUT_KINDS = ("image", "video", "audio", "file")
def _wiring_class(class_type: str) -> bool:
@@ -91,10 +90,10 @@ def validate_output_map(
ct = str(node.get("class_type") or "").strip()
if not ct:
problems.append(f"节点 {nid} 缺少节点类型,不能作为输出节点")
elif _wiring_class(ct) and ct not in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
elif _wiring_class(ct) and ct not in OUTPUT_CLASSES:
problems.append(f"节点 {nid}({ct})是参数/连线节点,不能作为输出节点")
if entry.get("kind") not in VALID_OUTPUT_KINDS:
problems.append(f"输出节点 {nid} 的媒体类型必须是图片或视频")
problems.append(f"输出节点 {nid} 的媒体类型必须是图片、视频、音频或文件")
if nid in seen:
problems.append(f"输出节点 {nid} 重复指定")
seen.add(nid)
+150 -44
View File
@@ -10,15 +10,16 @@ from calliope.comfyui.patcher import FIELD_KEY_SEP, resolve_patch_field
from calliope.comfyui.registry import (
AUDIO_CLASSES,
IMAGE_CLASSES,
IMAGE_OUTPUT_CLASSES,
IMAGE_URL_CLASSES,
NUMBER_CLASSES,
OPTIONAL_FRAME_INPUTS,
OUTPUT_CLASSES,
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,
@@ -281,17 +282,45 @@ def resolution_selector_size(aspect_ratio: str, megapixels: float, multiple: int
)
# ComfyUI Autogrow image inputs a studio form fills as one 1…max list.
# The workflow wires ``<group>.<prefix>N``; extra images are cloned LoadImage
# nodes added at patch time (patcher.py). This registry only beautifies known
# classes (label + real max from /object_info); detection itself is generic —
# any ``<group>.<prefix>N`` run of image-loader links folds into a group.
IMAGE_GROUPS: dict[str, dict[str, dict[str, Any]]] = {
# ComfyUI Autogrow media inputs a studio form fills as one 1…max list
# (HEL-643 generalizes the original image-only registry to three media
# kinds). The workflow wires ``<group>.<prefix>N``; extra items are cloned
# loader nodes added at patch time (patcher.py). This registry only
# beautifies known classes (label + real max + hint key); detection itself
# is generic — any ``<group>.<prefix>N`` run of same-family media-loader
# links folds into a group, image / video / audio alike.
MEDIA_GROUPS: dict[str, dict[str, dict[str, Any]]] = {
"MiniMaxH3ReferenceToVideo": {
"ref_images": {"prefix": "ref_image_", "max": 9, "label": "参考图"},
"ref_images": {
"prefix": "ref_image_",
"max": 9,
"label": "参考图",
"kind": "image",
},
"ref_videos": {
"prefix": "ref_video_",
"max": 3,
"label": "参考视频",
"kind": "video",
},
"ref_audios": {
"prefix": "ref_audio_",
"max": 3,
"label": "参考音频",
"kind": "audio",
},
},
}
# Backwards-compatible alias for the original image-only view.
IMAGE_GROUPS = MEDIA_GROUPS
# Consuming-node classes whose groups carry official usage limits worth a
# hint in the UI. Purely cosmetic — detection never depends on it.
_MEDIA_HINTS: dict[str, str] = {
"MiniMaxH3ReferenceToVideo": "minimax_h3_ref2va",
}
# ``ref_images.ref_image_0`` / ``images.image_1`` style Autogrow input names.
_GROUP_INPUT_RE = re.compile(
r"^(?P<group>[A-Za-z0-9_+-]+)\.(?P<rest>[A-Za-z0-9_+-]+?)(?P<n>\d+)$"
@@ -305,9 +334,27 @@ _FRAME_SLOT_ROLES: dict[str, tuple[str, str]] = {
"end_image": ("last_frame", "尾帧"),
}
# Group media kind → (schema kind, role, default label, default max).
_GROUP_KIND_META: dict[str, tuple[str, str, str, int]] = {
"image": ("image_list", "reference_image", "参考图", 9),
"video": ("video_list", "reference_video", "参考视频", 9),
"audio": ("audio_list", "reference_audio", "参考音频", 9),
}
# Loader class family for each media kind (registry.py sets).
_GROUP_SOURCE_CLASSES: dict[str, frozenset[str]] = {
"image": IMAGE_CLASSES,
"video": VIDEO_CLASSES,
"audio": AUDIO_CLASSES,
}
def media_group_spec(class_type: str, group: str) -> dict[str, Any] | None:
return MEDIA_GROUPS.get(class_type, {}).get(group)
def image_group_spec(class_type: str, group: str) -> dict[str, Any] | None:
return IMAGE_GROUPS.get(class_type, {}).get(group)
return media_group_spec(class_type, group)
def image_group_prefix(node: dict[str, Any], group: str) -> str | None:
@@ -346,14 +393,14 @@ def image_group_slots(
def _detected_group_runs(
workflow: dict[str, Any], node: dict[str, Any]
) -> list[tuple[str, str, list[str]]]:
"""``[(group, prefix, source LoadImage ids)]`` for generic Autogrow inputs.
) -> list[tuple[str, str, list[str], str]]:
"""``[(group, prefix, source loader ids, media kind)]`` for Autogrow inputs.
A run qualifies when the numbers are consecutive, every slot links to an
image-loader class, and the run either starts at 0 (single-slot Autogrow
exports like H3's ``ref_image_0``) or has ≥2 slots. Single runs numbered
from 1 (e.g. TextEncodeQwenImage21's ``images.image_1``) stay ordinary
image slots.
A run qualifies when the numbers are consecutive, every slot links to a
loader of the SAME media family (image / video / audio), and the run
either starts at 0 (single-slot Autogrow exports like H3's
``ref_image_0``) or has ≥2 slots. Single runs numbered from 1 (e.g.
TextEncodeQwenImage21's ``images.image_1``) stay ordinary image slots.
"""
runs: dict[tuple[str, str], dict[int, Any]] = {}
for name, val in (node.get("inputs") or {}).items():
@@ -361,7 +408,7 @@ def _detected_group_runs(
if not m or not (isinstance(val, list) and len(val) == 2):
continue
runs.setdefault((m.group("group"), m.group("rest")), {})[int(m.group("n"))] = val
out: list[tuple[str, str, list[str]]] = []
out: list[tuple[str, str, list[str], str]] = []
for (group, prefix), slots in sorted(runs.items()):
ns = sorted(slots)
if any(b - a != 1 for a, b in zip(ns, ns[1:])):
@@ -369,41 +416,57 @@ def _detected_group_runs(
if not (ns[0] == 0 or len(ns) >= 2):
continue
sources: list[str] = []
family: str | None = None
for n in ns:
link = slots[n]
src = workflow.get(str(link[0]))
if not (isinstance(src, dict) and src.get("class_type") in IMAGE_CLASSES):
sources = []
if not isinstance(src, dict):
family = None
break
ct = str(src.get("class_type") or "")
kinds = [
kind
for kind, classes in _GROUP_SOURCE_CLASSES.items()
if ct in classes
]
if len(kinds) != 1 or (family is not None and kinds[0] != family):
family = None
break
family = kinds[0]
sources.append(str(link[0]))
if sources:
out.append((group, prefix, sources))
if family and sources:
out.append((group, prefix, sources, family))
return out
def _image_groups(
def _media_groups(
workflow: dict[str, Any],
) -> tuple[list[dict[str, Any]], set[str]]:
"""Group entries for Autogrow reference inputs + the LoadImage ids they absorb."""
"""Group entries for Autogrow reference inputs (image/video/audio) plus
the loader node ids they absorb."""
entries: list[dict[str, Any]] = []
absorbed: set[str] = set()
for nid, node in sorted(
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
key=lambda kv: _node_sort_key(kv[0]),
):
for group, _prefix, sources in _detected_group_runs(workflow, node):
spec = image_group_spec(str(node.get("class_type") or ""), group) or {}
class_type = str(node.get("class_type") or "")
for group, _prefix, sources, media_kind in _detected_group_runs(workflow, node):
spec = media_group_spec(class_type, group) or {}
kind, role, default_label, default_max = _GROUP_KIND_META[media_kind]
absorbed.update(sources)
hint = _MEDIA_HINTS.get(class_type)
entries.append(
_entry(
f"{nid}{FIELD_KEY_SEP}{group}",
str(spec.get("label") or "参考图"),
"reference_image",
"image_list",
str(spec.get("label") or default_label),
role,
kind,
None,
True,
min=1,
max=spec.get("max", 9),
max=spec.get("max", default_max),
media_hint=hint,
)
)
return entries, absorbed
@@ -449,7 +512,7 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
)
texts: list[tuple[str, dict[str, Any], str, bool]] = [] # (id, node, field, negative)
groups, grouped_images = _image_groups(workflow)
groups, grouped_loaders = _media_groups(workflow)
media: list[dict[str, Any]] = []
numbers: list[dict[str, Any]] = []
taken_roles: set[str] = set()
@@ -475,7 +538,7 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
lowered = title.lower()
if ct in IMAGE_CLASSES or ct in _MASK_IMAGE_CLASSES:
if nid in grouped_images:
if nid in grouped_loaders:
continue
is_mask = ct in _MASK_IMAGE_CLASSES or "mask" in lowered or "遮罩" in lowered
slot = _image_slot(nid, workflow, consumers)
@@ -504,6 +567,10 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
)
continue
if ct in VIDEO_CLASSES or ct in AUDIO_CLASSES:
if nid in grouped_loaders:
# Absorbed into an Autogrow media group (HEL-643) — the
# group entry owns its upload slots.
continue
kind = "video" if ct in VIDEO_CLASSES else "audio"
field = resolve_patch_field(node)
has_default = isinstance(inputs.get(field), str) and bool(inputs.get(field))
@@ -623,6 +690,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]]
@@ -666,6 +735,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:
@@ -685,10 +790,8 @@ def parse_dynamic_outputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
continue
out_kind: ComfyOutputKind = class_to_output_kind(node.get("class_type", ""))
canon = normalize_output_role(role)
if canon == "video":
out_kind = "video"
elif canon == "image":
out_kind = "image"
if canon in ("image", "video", "audio", "file"):
out_kind = canon
results.append(
{
"nodeId": str(node_id),
@@ -838,13 +941,13 @@ def detect_output_nodes(workflow: dict[str, Any]) -> list[dict[str, Any]]:
# 1) Trusted registry outputs at sink positions.
for nid, node in _sinks(workflow, consumers):
ct = str(node.get("class_type") or "")
if ct in IMAGE_OUTPUT_CLASSES or ct in VIDEO_OUTPUT_CLASSES:
if ct in OUTPUT_CLASSES:
registry_outputs.append(
{
"nodeId": nid,
"label": _title_of(node) or ct,
"role": None,
"kind": "video" if ct in VIDEO_OUTPUT_CLASSES else "image",
"kind": class_to_output_kind(ct),
"class_type": ct,
"evidence": "registry",
}
@@ -857,14 +960,14 @@ def detect_output_nodes(workflow: dict[str, Any]) -> list[dict[str, Any]]:
key=lambda kv: _node_sort_key(kv[0]),
):
ct = str(node.get("class_type") or "")
if nid in seen or ct not in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
if nid in seen or ct not in OUTPUT_CLASSES:
continue
registry_outputs.append(
{
"nodeId": nid,
"label": _title_of(node) or ct,
"role": None,
"kind": "video" if ct in VIDEO_OUTPUT_CLASSES else "image",
"kind": class_to_output_kind(ct),
"class_type": ct,
"evidence": "registry",
}
@@ -877,7 +980,7 @@ def detect_output_nodes(workflow: dict[str, Any]) -> list[dict[str, Any]]:
has_trusted = bool(registry_outputs)
for nid, node in _sinks(workflow, consumers):
ct = str(node.get("class_type") or "")
if nid in seen or _is_wiring(ct) or ct in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
if nid in seen or _is_wiring(ct) or ct in OUTPUT_CLASSES:
continue
if _unknown_sink_looks_like_save(node, workflow) or not has_trusted:
candidates.append(_unknown_output_entry(nid, node, workflow, videoish))
@@ -892,7 +995,7 @@ def detect_output_nodes(workflow: dict[str, Any]) -> list[dict[str, Any]]:
key=lambda kv: _node_sort_key(kv[0]),
):
ct = str(node.get("class_type") or "")
if nid in seen or _is_wiring(ct) or ct in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
if nid in seen or _is_wiring(ct) or ct in OUTPUT_CLASSES:
continue
if _node_has_save_widget(node):
candidates.append(_unknown_output_entry(nid, node, workflow, videoish))
@@ -939,7 +1042,7 @@ def classify_usage(workflow: dict[str, Any]) -> dict[str, Any]:
"""
inputs = parse_dynamic_inputs(workflow) if isinstance(workflow, dict) else []
outputs = parse_dynamic_outputs(workflow)
media_outputs = [o for o in outputs if o.get("kind") in ("image", "video")]
media_outputs = [o for o in outputs if o.get("kind") in ("image", "video", "audio", "file")]
unknown_outputs = [o for o in outputs if o.get("evidence") == "unknown_node"]
out_kinds = sorted({o["kind"] for o in media_outputs})
@@ -973,7 +1076,7 @@ def classify_usage(workflow: dict[str, Any]) -> dict[str, Any]:
summary: list[str] = []
for o in media_outputs:
zh = "图片" if o["kind"] == "image" else "视频"
zh = {"image": "图片", "video": "视频", "audio": "音频", "file": "文件"}[o["kind"]]
summary.append(f"输出:节点 {o['nodeId']}({o.get('class_type') or o['label']})→ {zh}")
if unknown_outputs:
names = "、".join(
@@ -1012,6 +1115,9 @@ def classify_usage(workflow: dict[str, Any]) -> dict[str, Any]:
elif len(out_kinds) == 1:
if out_kinds[0] == "image":
usage = "image2image" if bulk else "text2image"
elif out_kinds[0] in ("audio", "file"):
ambiguous = True
problem = "音频/文件输出已识别,请人工确认语音、音乐、转写或工具用途"
elif has_ref_group or len(bulk) >= 2:
usage = "ref2video"
elif has_frame and not bulk:
@@ -5,12 +5,45 @@ from __future__ import annotations
import copy
from typing import Any
from calliope.comfyui.registry import IMAGE_CLASSES, class_to_patch_field
from calliope.comfyui.registry import (
AUDIO_CLASSES,
IMAGE_CLASSES,
VIDEO_CLASSES,
VIDEO_FILE_CLASSES,
class_to_patch_field,
optional_frame_sibling,
)
# Input keys of the form "<nodeId>@<widget>" target one named widget on a
# node (auto-detected inputs: KSampler seed/steps/cfg, latent width/height…).
FIELD_KEY_SEP = "@"
# Media-loader families and the Chinese slot label used on cloned nodes.
_GROUP_LABELS: tuple[tuple[frozenset[str], str], ...] = (
(IMAGE_CLASSES, "参考图"),
(VIDEO_CLASSES, "参考视频"),
(AUDIO_CLASSES, "参考音频"),
)
def _loader_label(class_type: str) -> str:
for classes, label in _GROUP_LABELS:
if class_type in classes:
return label
return "参考素材"
def _loader_field(class_type: str, inputs: dict[str, Any]) -> str:
"""The widget a loader node of this class stores its file value in."""
if class_type in IMAGE_CLASSES:
return "image"
if class_type in VIDEO_CLASSES:
return "file" if class_type in VIDEO_FILE_CLASSES else "video"
if class_type in AUDIO_CLASSES:
# VHS_LoadAudio names its widget "audio:" (with the colon).
return "audio" if "audio" in inputs else "audio:"
return "image"
def _resolve_field(field: str, inputs: dict[str, Any]) -> str:
"""Map the computed patch field onto a key that exists on this node.
@@ -43,15 +76,16 @@ def _next_node_id(workflow: dict[str, Any]) -> str:
return str(max(numeric, default=0) + 1)
def apply_image_group(
def apply_media_group(
workflow: dict[str, Any], node_id: str, group: str, paths: list[str]
) -> None:
"""Rewire an Autogrow image group to exactly ``paths``, in order.
"""Rewire an Autogrow media group (image/video/audio) to exactly ``paths``.
``<group>.<prefix>0 … N-1`` each get their own LoadImage: the workflow's
existing ones are reused in slot order, extra ones are clones of the
first, and slots/loaders left over from the export are removed so no
stale image reaches ComfyUI.
``<group>.<prefix>0 … N-1`` each get their own loader node: the
workflow's existing ones are reused in slot order, extra ones are clones
of the first, and slots/loaders left over from the export are removed so
no stale media reaches ComfyUI. File values are local paths —
``prepare_media_inputs`` uploads them right before queueing.
"""
from calliope.comfyui.parser import image_group_prefix, image_group_slots
@@ -61,7 +95,6 @@ def apply_image_group(
prefix = image_group_prefix(node, group)
if prefix is None:
return
label = "参考图"
inputs = dict(node.get("inputs") or {})
slots = image_group_slots(node, group, prefix)
loaders: list[str] = []
@@ -69,11 +102,14 @@ def apply_image_group(
inputs.pop(name, None)
if isinstance(link, list) and len(link) == 2:
src = workflow.get(str(link[0]))
if isinstance(src, dict) and src.get("class_type") in IMAGE_CLASSES:
if isinstance(src, dict) and str(src.get("class_type") or "") in (
IMAGE_CLASSES | VIDEO_CLASSES | AUDIO_CLASSES
):
loaders.append(str(link[0]))
if not loaders:
return
template = copy.deepcopy(workflow[loaders[0]])
label = _loader_label(str(template.get("class_type") or ""))
still_used: set[str] = set()
for nid, other in workflow.items():
@@ -94,7 +130,8 @@ def apply_image_group(
nid = _next_node_id(workflow)
loader = copy.deepcopy(template)
loader_inputs = dict(loader.get("inputs") or {})
loader_inputs["image"] = path
field = _loader_field(str(loader.get("class_type") or ""), loader_inputs)
loader_inputs[field] = path
loader["inputs"] = loader_inputs
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{label} {i + 1}"}
workflow[nid] = loader
@@ -102,6 +139,30 @@ def apply_image_group(
node["inputs"] = inputs
# Original name kept: image groups were the first (and most common) family.
apply_image_group = apply_media_group
def 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],
@@ -125,7 +186,11 @@ def patch_workflow(
if isinstance(paths, list):
clean = [p for p in paths if isinstance(p, str) and p.strip()]
if clean:
apply_image_group(patched, key, field, clean)
apply_media_group(patched, key, field, clean)
continue
if field is 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)
@@ -0,0 +1,137 @@
"""L2 connection preflight: check a workflow against a live /object_info (HEL-642/643).
Layer 1 (lint.py) judges the JSON on its own; this module judges the JSON
against what the target ComfyUI actually has installed. Two generic rules,
zero hard-coding for any specific workflow or model name:
1. Node existence — every ``class_type`` in the JSON must exist as a key of
the server's /object_info catalogue; missing ones are custom nodes the
user has not installed.
2. Combo value legality — for every combo input the server declares (an
input whose spec carries a candidate list — unet_name/clip_name/
sampler/scheduler/…), the JSON's current widget value must be one of the
candidates; a miss usually means a model file or component is missing.
File-upload widgets (LoadImage.image and friends) are skipped: their combo
lists are the server's input directory at export time and the value is
replaced by an upload before every submit, so a miss there says nothing.
"""
from __future__ import annotations
from typing import Any
from calliope.comfyui.registry import AUDIO_CLASSES, IMAGE_CLASSES, VIDEO_CLASSES
# Media-loader classes and the widget names that hold their file values —
# upload-backed combos that cannot be judged before an upload happens.
_MEDIA_LOADER_FIELDS: tuple[frozenset[str], frozenset[str]] = (
(IMAGE_CLASSES, frozenset({"image"})),
(AUDIO_CLASSES, frozenset({"audio", "audio:"})),
(VIDEO_CLASSES, frozenset({"file", "video"})),
)
# object_info widget-config keys that mark an input as file-upload backed.
_UPLOAD_CONFIG_KEYS = ("image_upload", "audio_upload", "video_upload", "upload")
_MAX_CANDIDATES_SHOWN = 6
def _node_title(node: dict[str, Any]) -> str:
title = (node.get("_meta") or {}).get("title")
return str(title).strip() if title else ""
def _is_upload_backed(class_type: str, input_name: str, config: Any) -> bool:
if isinstance(config, dict) and any(config.get(k) for k in _UPLOAD_CONFIG_KEYS):
return True
for classes, fields in _MEDIA_LOADER_FIELDS:
if class_type in classes and input_name in fields:
return True
return False
def _combo_candidates(spec: Any) -> list[str] | None:
"""The candidate list when ``spec`` is a combo input, else None.
A combo spec is ``[["a", "b"], {config}]`` — first element a list of
plain strings. Typed inputs (``["FLOAT", {…}]``) return None.
"""
if not (isinstance(spec, list) and len(spec) >= 1):
return None
values = spec[0]
if not isinstance(values, list) or not values:
return None
if not all(isinstance(v, str) for v in values):
return None
return values
def check_workflow_against(
object_info: dict[str, Any], workflow: dict[str, Any]
) -> list[dict[str, Any]]:
"""Findings for every problem the target server's catalogue reveals.
Each finding is ``{level, code, node_id, node_title, message, hint}``
(``level`` is always ``"error"`` for now — both rules are hard misses).
"""
findings: list[dict[str, Any]] = []
if not isinstance(object_info, dict) or not isinstance(workflow, dict):
return findings
for node_id, node in workflow.items():
if not isinstance(node, dict):
continue
class_type = str(node.get("class_type") or "")
title = _node_title(node)
label = f"节点 {node_id}「{title}」" if title else f"节点 {node_id}"
spec = object_info.get(class_type)
if not isinstance(spec, dict):
findings.append(
{
"level": "error",
"code": "missing_node",
"node_id": str(node_id),
"node_title": title,
"message": (
f"缺少自定义节点 {class_type or '(未知类名)'}({label})——"
"该 ComfyUI 未安装此节点,需先安装对应的节点包"
),
"hint": "安装节点包后回到本页点「重新检查」即可复查,无需重新导入。",
}
)
continue
declared = (spec.get("input") or {}) if isinstance(spec.get("input"), dict) else {}
inputs = node.get("inputs") or {}
for section in ("required", "optional"):
entries = declared.get(section)
if not isinstance(entries, dict):
continue
for input_name, in_spec in entries.items():
value = inputs.get(input_name)
# Wired inputs (links) and absent widgets are not combos to judge.
if isinstance(value, (list, dict)) or value is None:
continue
config = in_spec[1] if isinstance(in_spec, list) and len(in_spec) > 1 else None
if _is_upload_backed(class_type, str(input_name), config):
continue
candidates = _combo_candidates(in_spec)
if candidates is None:
continue
if value in candidates:
continue
shown = "、".join(candidates[:_MAX_CANDIDATES_SHOWN])
if len(candidates) > _MAX_CANDIDATES_SHOWN:
shown += f" 等 {len(candidates)} 项"
findings.append(
{
"level": "error",
"code": "value_not_in_list",
"node_id": str(node_id),
"node_title": title,
"message": (
f"{label}:值「{value}」不在 ComfyUI 的可选列表"
f"(参数 {input_name})——多为对应的模型文件或组件未安装"
),
"hint": f"该 ComfyUI 上可选:{shown}" if shown else None,
}
)
return findings
@@ -1,10 +1,10 @@
"""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"]
ComfyOutputKind = Literal["image", "video", "audio", "file", "other"]
PatchField = Literal["image", "url", "audio", "video", "file", "text", "value", "int", "float"]
TEXT_AREA_CLASSES = frozenset(
@@ -33,6 +33,27 @@ IMAGE_OUTPUT_CLASSES = frozenset(
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket",
"SaveImageAdvanced"}
)
AUDIO_OUTPUT_CLASSES = frozenset({"SaveAudio", "SaveAudioMP3", "SaveAudioOpus", "PreviewAudio"})
OUTPUT_CLASSES = IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES | AUDIO_OUTPUT_CLASSES
# 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:
@@ -92,6 +113,8 @@ def class_to_patch_field(class_type: str) -> PatchField:
def class_to_output_kind(class_type: str) -> ComfyOutputKind:
if class_type in AUDIO_OUTPUT_CLASSES:
return "audio"
if class_type in VIDEO_OUTPUT_CLASSES or "video" in class_type.lower():
return "video"
if class_type in IMAGE_OUTPUT_CLASSES or "image" in class_type.lower():
@@ -28,6 +28,8 @@ INPUT_ROLE_ALIASES: dict[str, frozenset[str]] = {
OUTPUT_ROLE_ALIASES: dict[str, frozenset[str]] = {
"image": frozenset({"image", "img"}),
"video": frozenset({"video", "vid"}),
"audio": frozenset({"audio", "sound"}),
"file": frozenset({"file", "subtitle", "srt"}),
}
# The five workflow usages the studio distinguishes (HEL-636). Derived from
@@ -40,6 +42,7 @@ USAGE_VALUES: tuple[str, ...] = (
"text2video",
"image2video",
"ref2video",
"text2speech", "speech2text", "text2music", "utility", "audio2video", "pose2video",
)
@@ -47,7 +50,11 @@ def usage_to_kind(usage: str | None) -> str | None:
"""Output media kind of a usage (image / video); None when unknown."""
if usage in ("text2image", "image2image"):
return "image"
if usage in ("text2video", "image2video", "ref2video"):
if usage in ("text2speech", "text2music"):
return "audio"
if usage == "speech2text":
return "file"
if usage in ("text2video", "image2video", "ref2video", "audio2video", "pose2video"):
return "video"
return None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
"""Curated source-only notes, bound to exact source bytes; never execution evidence."""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
@lru_cache(maxsize=1)
def _reviews() -> dict:
return json.loads(Path(__file__).with_name("source_reviews.json").read_text(encoding="utf-8"))
def review_for(path: str, source_sha: str) -> dict | None:
review = _reviews().get(path)
return review if review and review["source_sha"] == source_sha else None
@@ -0,0 +1,139 @@
"""Conservative offline conversion; partial or ambiguous prompts are never returned.
object_info does not define custom frontend widget serialization. Only named
widgets pass. Even input_order cannot prove arbitrary frontend serialization.
Subgraphs, virtual nodes, seed extra controls, mute and bypass need API exports.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class Conversion:
prompt: dict[str, Any] | None
issues: list[dict[str, Any]] = field(default_factory=list)
def convert(source: Any, object_info: dict[str, Any] | None = None) -> Conversion:
issues: list[dict[str, Any]] = []
def fail(code: str, message: str, node_id: Any = "") -> None:
issues.append({"code": code, "message": message, "node_id": str(node_id)})
if not isinstance(source, dict):
return Conversion(None, [{"code": "invalid_format", "message": "源文件不是 JSON 对象"}])
if "nodes" not in source:
if source and all(
isinstance(n, dict)
and isinstance(n.get("class_type"), str)
and isinstance(n.get("inputs"), dict)
for n in source.values()
):
for nid, node in source.items():
for name, value in node["inputs"].items():
if isinstance(value, list):
if (
len(value) != 2
or not isinstance(value[0], str)
or value[0] not in source
or type(value[1]) is not int
or value[1] < 0
):
fail("invalid_api_link", f"输入 {name} 的 API 连线无法核对", nid)
if issues:
return Conversion(None, issues)
return Conversion(source)
return Conversion(
None, [{"code": "invalid_format", "message": "无法识别 API 或 UI 图格式"}]
)
if not isinstance(source["nodes"], list):
return Conversion(None, [{"code": "invalid_format", "message": "nodes 必须为数组"}])
if (source.get("definitions") or {}).get("subgraphs"):
fail("subgraph_requires_export", "包含子图:需原 ComfyUI API 导出及差分核对")
info = object_info or {}
nodes = {str(n["id"]): n for n in source["nodes"] if isinstance(n, dict) and "id" in n}
if len(nodes) != len(source["nodes"]):
fail("invalid_nodes", "节点 ID 缺失或重复")
links = {}
for link in source.get("links") or []:
if not isinstance(link, list) or len(link) < 6:
fail("unsupported_links", "连线格式需原 ComfyUI API 导出核对")
else:
if str(link[0]) in links:
fail("duplicate_link", "连线 ID 重复")
links[str(link[0])] = link
prompt = {}
for nid, node in nodes.items():
ct = node.get("type", "")
if ct in ("Note", "MarkdownNote"):
continue
if node.get("mode", 0) != 0:
fail("mode_requires_export", f"{ct} 包含静音/旁路状态,需 API 导出核对", nid)
continue
spec = info.get(ct)
if not isinstance(spec, dict):
fail("node_definition_unavailable", f"待提供目标机节点定义:{ct}", nid)
continue
declared = {
**spec.get("input", {}).get("required", {}),
**spec.get("input", {}).get("optional", {}),
}
sockets = {s["name"]: s for s in node.get("inputs", []) if "name" in s}
widgets = node.get("widgets_values") or []
inputs = {}
if isinstance(widgets, dict):
if set(widgets) - set(declared):
fail("unknown_widget", f"{ct} 存在节点定义以外的具名控件", nid)
inputs.update({k: v for k, v in widgets.items() if k in declared})
elif widgets:
fail(
"widget_layout_unverified",
f"{ct} 控件为位置数组;input_order 不证明前端序列化顺序,需 API 导出核对",
nid,
)
continue
for name, socket in sockets.items():
lid = socket.get("link")
if lid is None:
continue
link = links.get(str(lid))
if (
not link
or str(link[3]) != nid
or not isinstance(link[4], int)
or not 0 <= link[4] < len(node.get("inputs", []))
):
fail("invalid_link", f"输入 {name} 的连线无效", nid)
continue
if node["inputs"][link[4]].get("name") != name or name not in declared:
fail("invalid_socket", f"输入 {name} 的目标槽不匹配", nid)
continue
upstream = nodes.get(str(link[1]))
outputs = info.get((upstream or {}).get("type"), {}).get("output", [])
if not upstream or not isinstance(link[2], int) or not 0 <= link[2] < len(outputs):
fail("invalid_output_slot", f"输入 {name} 的来源槽无法核对", nid)
continue
inputs[name] = [str(link[1]), link[2]]
expected = declared[name][0] if declared[name] else None
actual = outputs[link[2]]
# Custom unions/wildcards need frontend evidence; do not guess coercions.
if expected != actual:
fail(
"link_type_unverified",
f"输入 {name} 的槽类型不一致:{actual} → {expected}",
nid,
)
for name in spec.get("input", {}).get("required", {}):
if name not in inputs:
fail("missing_required_input", f"{ct}.{name} 缺少源文件值或连线", nid)
prompt[nid] = {
"class_type": ct,
"inputs": inputs,
"_meta": {"title": node.get("title") or ct},
}
if not prompt:
fail("empty_prompt", "未得到可执行节点")
return Conversion(None if issues else prompt, issues)
@@ -63,20 +63,24 @@ def validate_input_values(
errors.append(f"「{label}」不能大于 {_fmt(hi)}")
continue
clean[key] = value
elif kind == "image_list":
elif kind in ("image_list", "video_list", "audio_list"):
paths = [raw] if isinstance(raw, str) else raw
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
errors.append(f"「{label}」的取值格式不对,请重新上传")
continue
paths = [p for p in paths if p.strip()]
unit = _LIST_UNITS[kind]
noun = _LIST_NOUNS[kind]
hi = entry.get("max")
if hi is not None and len(paths) > hi:
errors.append(f"{label}最多 {hi} 张(当前 {len(paths)} 张),请删除多余的图片")
errors.append(
f"{label}最多 {hi} {unit}(当前 {len(paths)} {unit}),请删除多余的{noun}"
)
continue
bad = [
f"{label} {i}:{msg}"
for i, p in enumerate(paths, 1)
if (msg := _image_file_problem(p))
if (msg := _media_file_problem(p, kind))
]
if bad:
errors.extend(bad)
@@ -98,10 +102,11 @@ def validate_input_values(
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
if key not in clean and not has_fallback:
errors.append(f"请填写「{label}」")
elif required and entry.get("kind") == "image_list":
elif required and entry.get("kind") in ("image_list", "video_list", "audio_list"):
lo = entry.get("min") or 1
unit = _LIST_UNITS[entry.get("kind")]
if len(clean.get(key) or []) < lo and not any(label in e for e in errors):
errors.append(f"需要至少 {lo} 张{label}")
errors.append(f"需要至少 {lo} {unit}{label}")
elif required and entry.get("kind") == "image" and entry.get("role") != "mask":
if key not in clean:
errors.append(f"请上传{label}")
@@ -109,19 +114,33 @@ def validate_input_values(
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp"})
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mov"})
_AUDIO_EXTS = frozenset({".mp3", ".wav", ".m4a"})
_LIST_UNITS = {"image_list": "张", "video_list": "段", "audio_list": "段"}
_LIST_NOUNS = {"image_list": "图片", "video_list": "视频", "audio_list": "音频"}
_LIST_EXTS = {"image_list": _IMAGE_EXTS, "video_list": _VIDEO_EXTS, "audio_list": _AUDIO_EXTS}
_LIST_FORMAT_HINT = {
"image_list": "格式不支持,请使用 PNG / JPG / WebP",
"video_list": "格式不支持,请使用 MP4 / WebM / MOV",
"audio_list": "格式不支持,请使用 MP3 / WAV / M4A",
}
def _image_file_problem(path: str) -> str | None:
"""Why ``path`` cannot be a studio reference image (None = fine)."""
def _media_file_problem(path: str, kind: str) -> str | None:
"""Why ``path`` cannot fill a media-list slot of this kind (None = fine)."""
from calliope import config
target = Path(path)
if target.suffix.lower() not in _IMAGE_EXTS:
return "格式不支持,请使用 PNG / JPG / WebP"
if target.suffix.lower() not in _LIST_EXTS[kind]:
return _LIST_FORMAT_HINT[kind]
try:
target.resolve().relative_to(config.settings.assets_dir.resolve())
except (ValueError, OSError):
return "只能使用在本页上传或从生成结果选入的图片"
return f"只能使用在本页上传或从生成结果选入的{_LIST_NOUNS[kind]}"
if not target.is_file():
return "文件已不存在,请重新上传"
return None
def _image_file_problem(path: str) -> str | None:
return _media_file_problem(path, "image_list")
+52 -3
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import re
import sqlite3
from pathlib import Path
from typing import Any
@@ -112,7 +113,7 @@ CREATE INDEX IF NOT EXISTS idx_clips_project ON clips(project_id);
CREATE TABLE IF NOT EXISTS workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('image', 'video')),
kind TEXT NOT NULL CHECK(kind IN ('image', 'video', 'audio', 'file', 'unknown')),
workflow_json TEXT NOT NULL,
input_node_map TEXT,
output_node_name TEXT,
@@ -354,7 +355,8 @@ def _backfill_workflow_usage(conn: sqlite3.Connection) -> None:
from calliope.comfyui.parser import classify_usage
rows = conn.execute(
"SELECT id, workflow_json FROM workflows WHERE usage IS NULL OR usage = ''"
"SELECT id, workflow_json FROM workflows WHERE source_repo_path IS NULL"
" AND (usage IS NULL OR usage = '')"
).fetchall()
for row in rows: # plain connect — positional access
try:
@@ -387,7 +389,8 @@ def _backfill_workflow_output_map(conn: sqlite3.Connection) -> None:
rows = conn.execute(
"SELECT id, workflow_json FROM workflows"
" WHERE output_map_source IS NULL OR output_map_source != 'user'"
" WHERE source_repo_path IS NULL"
" AND (output_map_source IS NULL OR output_map_source != 'user')"
).fetchall()
for row in rows:
try:
@@ -410,10 +413,50 @@ 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 WHERE source_repo_path IS NULL"
).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)
conn.executescript(SCHEMA)
from calliope.comfyui.library import migrate_library
migrate_library(conn)
# Additive migrations for existing DBs
# Dedupe guard: ARTIFACT nodes keyed by (canvas_id, job_id). Without the
# unique index, the agent's post_artifact_to_canvas and the frontend
@@ -472,6 +515,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")
@@ -518,6 +562,11 @@ async def migrate_db(db_path: Path) -> None:
conn.execute("ALTER TABLE jobs ADD COLUMN comfy_prompt_id TEXT")
if "phase" not in job_cols:
conn.execute("ALTER TABLE jobs ADD COLUMN phase TEXT")
# HEL-643: where a job came from — 'user' (every normal generation) or
# 'workflow_test' (L3 试运行). Existing rows default to 'user', semantics
# unchanged; test jobs get isolated artifacts and no entity write-back.
if "origin" not in job_cols:
conn.execute("ALTER TABLE jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'user'")
# Build Scene's per-scene sessions: origin stamps WHERE a blind session was
# born ('chat' = user-created in AI Canvas, 'scene' = auto-created by the
# Build Scene rail). The session list excludes origin='scene' so scene
@@ -204,12 +204,13 @@ WorkflowUsageKind = Literal[
"text2video",
"image2video",
"ref2video",
"text2speech", "speech2text", "text2music", "utility", "audio2video", "pose2video",
]
class WorkflowCreate(BaseModel):
name: str = Field(..., min_length=1)
kind: Literal["image", "video"] | None = None
kind: Literal["image", "video", "audio", "file"] | None = None
workflow_json: dict[str, Any]
description: str | None = None
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
@@ -231,7 +232,7 @@ class WorkflowCreate(BaseModel):
class WorkflowUpdate(BaseModel):
name: str | None = None
kind: Literal["image", "video"] | None = None
kind: Literal["image", "video", "audio", "file"] | None = None
description: str | None = None
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
is_enabled: bool | None = None
@@ -247,6 +248,23 @@ class WorkflowUpdate(BaseModel):
class WorkflowAnalyze(BaseModel):
workflow_json: dict[str, Any]
# HEL-643: optional target connection — given, the analyze response also
# carries an L2 connection check (node/model availability via /object_info).
endpoint_id: str | None = None
# Bypass the /object_info cache (user re-checks after installing nodes).
refresh: bool = False
class WorkflowCheckRequest(BaseModel):
"""L2 re-check of a saved workflow against a live ComfyUI (HEL-643)."""
endpoint_id: str | None = None
refresh: bool = False
class WorkflowTestRequest(BaseModel):
"""Launch one real test run of a workflow (HEL-643 L3)."""
endpoint_id: str | None = None
input_values: dict[str, Any] = Field(default_factory=dict)
class JobCreate(BaseModel):
@@ -36,16 +36,18 @@ class QueueManager:
scene_id: int | None = None,
clip_id: int | None = None,
payload: dict[str, Any] | None = None,
origin: str = "user",
) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
payload = self._stamp_endpoint(conn, workflow_id, dict(payload or {}))
cur = conn.execute(
"""
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id, status, payload_json)
VALUES (?, ?, ?, ?, ?, 'pending', ?)
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id,
status, payload_json, origin)
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
""",
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload)),
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload), origin),
)
conn.commit()
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
+39 -4
View File
@@ -21,6 +21,11 @@ from calliope.db import get_db
from calliope.events.bus import event_bus
from calliope.export.runner import run_export
from calliope.queue.manager import queue_manager
from calliope.queue.workflow_test import (
TEST_ORIGIN,
prune_workflow_tests,
workflow_test_dir,
)
logger = logging.getLogger("calliope.worker")
@@ -164,6 +169,16 @@ class QueueWorker:
"message": f"{self._job_label(job)} failed",
},
)
if str(job.get("origin") or "") == TEST_ORIGIN and job.get("workflow_id"):
# Retention: keep only the newest records (and their
# artifacts) per workflow (HEL-643).
try:
prune_workflow_tests(int(job["workflow_id"]))
except Exception:
logger.exception(
"Pruning workflow-test records for workflow %s failed",
job["workflow_id"],
)
except Exception:
logger.exception("Worker loop error")
await asyncio.sleep(config.settings.queue_poll_interval_sec)
@@ -173,6 +188,7 @@ class QueueWorker:
payload = json.loads(job["payload_json"] or "{}")
project_id = job["project_id"]
kind = job["kind"]
is_test = str(job.get("origin") or "") == TEST_ORIGIN
use_dry = bool(config.settings.dry_run)
if kind == "export":
@@ -183,6 +199,13 @@ class QueueWorker:
base_url, headers, endpoint_label = self._resolve_endpoint(payload)
client = ComfyUIClient(base_url, headers=headers)
try:
# A workflow test must be a REAL run — a dry-run placeholder that
# passes as "测试通过" is exactly the false positive HEL-643 bans.
if use_dry and is_test:
raise RuntimeError(
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
"请到「设置」关闭 Dry-run 后重新测试"
)
if use_dry:
queue_manager.set_phase(job["id"], "dry_run")
return await self._dry_run(job, payload)
@@ -201,6 +224,10 @@ class QueueWorker:
raise RuntimeError(
"找不到该任务使用的工作流(可能已被删除),请重新选择工作流后再生成"
)
if workflow_row.get("import_state") in (
"needs_conversion", "pending_check", "rolled_back"
):
raise RuntimeError("工作流库条目尚未完成转换或依赖核对,禁止提交生成任务")
workflow = workflow_row["workflow_json"]
broken = blocking_problem(workflow)
if broken:
@@ -256,7 +283,8 @@ class QueueWorker:
if not outputs_meta:
checked = "、".join(sorted(node_kinds))
raise RuntimeError(
f"ComfyUI 显示任务已结束(任务 ID {prompt_id}),但没有返回任何图片或视频,"
f"ComfyUI 显示任务已结束(任务 ID {prompt_id}),"
"但没有返回任何图片、视频、音频或文件,"
"不算生成完成。已按输出映射检查节点:" f"{checked};"
"请到 ComfyUI 历史中核对该任务,并确认所指定的输出节点正确。"
)
@@ -277,7 +305,12 @@ class QueueWorker:
"请到「ComfyUI 连接」页修正该节点的媒体类型。"
)
queue_manager.set_phase(job["id"], "saving")
dest_dir = config.settings.assets_dir / str(project_id) / kind
if is_test:
# Test artifacts live in a scratch dir, isolated from project
# media and never written back to entities (HEL-643).
dest_dir = workflow_test_dir(int(workflow_id))
else:
dest_dir = config.settings.assets_dir / str(project_id) / kind
dest_dir.mkdir(parents=True, exist_ok=True)
paths: list[str] = []
for meta in outputs_meta:
@@ -291,7 +324,8 @@ class QueueWorker:
)
paths.append(str(dest))
self._apply_outputs_to_entities(job, payload, paths)
if not is_test:
self._apply_outputs_to_entities(job, payload, paths)
queue_manager.set_phase(job["id"], "finished")
return paths
finally:
@@ -510,7 +544,7 @@ class QueueWorker:
conn = get_db(config.settings.db_path)
try:
row = conn.execute(
"SELECT workflow_json, output_map, output_map_source FROM workflows"
"SELECT workflow_json, output_map, output_map_source, import_state FROM workflows"
" WHERE id = ?",
(workflow_id,),
).fetchone()
@@ -520,6 +554,7 @@ class QueueWorker:
"workflow_json": json.loads(row["workflow_json"]),
"output_map": row["output_map"],
"output_map_source": row["output_map_source"],
"import_state": row["import_state"],
}
finally:
conn.close()
@@ -0,0 +1,180 @@
"""Workflow-test job helpers (HEL-643).
A test job (jobs.origin = 'workflow_test') runs the FULL real pipeline —
queue → ComfyUI → outputs — but its artifacts are isolated from project
entities: files land under ``assets/_workflow_test/{workflow_id}/`` and are
never written back to characters/locations/scenes/clips.
This module owns the artifact directory, the retention policy (keep the
newest N records per workflow) and the purge paths used by "清除测试记录"
and workflow deletion.
"""
from __future__ import annotations
import json
import logging
import shutil
from pathlib import Path
from typing import Any
from calliope import config
from calliope.db import get_db
logger = logging.getLogger("calliope.queue")
TEST_ORIGIN = "workflow_test"
TEST_SOURCE = "workflow_test"
# Records (and artifacts) kept per workflow before older ones are pruned.
TEST_KEEP = 5
def workflow_test_dir(workflow_id: int) -> Path:
return config.settings.assets_dir / "_workflow_test" / str(workflow_id)
def _safe_unlink(path_str: str) -> bool:
"""Delete a file if it resolves under assets_dir (missing = already gone)."""
try:
target = Path(path_str).resolve()
target.relative_to(config.settings.assets_dir.resolve())
except (ValueError, OSError):
return False
if not target.is_file():
return False
try:
target.unlink()
return True
except OSError:
return False
def _unlink_job_outputs(job: dict[str, Any]) -> None:
try:
paths = json.loads(job.get("output_paths_json") or "[]")
except (json.JSONDecodeError, TypeError):
return
if not isinstance(paths, list):
return
for p in paths:
if isinstance(p, str) and p.strip():
_safe_unlink(p)
def _remove_test_dir_if_empty(workflow_id: int) -> None:
d = workflow_test_dir(workflow_id)
try:
if d.is_dir() and not any(d.iterdir()):
d.rmdir()
except OSError:
pass
def _test_job_public(job: dict[str, Any]) -> dict[str, Any]:
out = dict(job)
out["payload"] = json.loads(job.get("payload_json") or "{}")
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
out.pop("payload_json", None)
out.pop("output_paths_json", None)
return out
def list_test_jobs(workflow_id: int, limit: int = 20) -> list[dict[str, Any]]:
conn = get_db(config.settings.db_path)
try:
rows = conn.execute(
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
" ORDER BY id DESC LIMIT ?",
(workflow_id, TEST_ORIGIN, limit),
).fetchall()
return [_test_job_public(dict(r)) for r in rows]
finally:
conn.close()
def find_active_test_job(workflow_id: int, endpoint_id: str | None) -> dict[str, Any] | None:
"""The unfinished test job for (workflow, endpoint), if one exists.
Idempotency guard for the "测试工作流" button: a second click re-uses the
in-flight job instead of enqueuing a duplicate.
"""
conn = get_db(config.settings.db_path)
try:
rows = conn.execute(
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
" AND status IN ('pending', 'running') ORDER BY id DESC",
(workflow_id, TEST_ORIGIN),
).fetchall()
finally:
conn.close()
for r in rows:
job = dict(r)
try:
payload = json.loads(job.get("payload_json") or "{}")
except (json.JSONDecodeError, TypeError):
payload = {}
snap = payload.get("endpoint") or {}
if not isinstance(snap, dict):
snap = {}
if (snap.get("id") or None) == (endpoint_id or None):
return _test_job_public(job)
return None
def prune_workflow_tests(workflow_id: int, keep: int = TEST_KEEP) -> int:
"""Keep only the newest ``keep`` test records (older rows + files go)."""
conn = get_db(config.settings.db_path)
try:
rows = conn.execute(
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
" ORDER BY id DESC LIMIT -1 OFFSET ?",
(workflow_id, TEST_ORIGIN, max(keep, 0)),
).fetchall()
removed = 0
for r in rows:
job = dict(r)
if job.get("status") in ("pending", "running"):
continue
_unlink_job_outputs(job)
conn.execute("DELETE FROM jobs WHERE id = ?", (job["id"],))
removed += 1
conn.commit()
finally:
conn.close()
_remove_test_dir_if_empty(workflow_id)
if removed:
logger.info("Pruned %s old workflow-test records for workflow %s", removed, workflow_id)
return removed
def purge_workflow_tests(workflow_id: int, *, allow_active: bool = False) -> int:
"""Delete EVERY test record and artifact for a workflow.
Returns the removed row count; raises RuntimeError when an unfinished
test job is still in flight (unless ``allow_active``).
"""
conn = get_db(config.settings.db_path)
try:
if not allow_active:
active = conn.execute(
"SELECT COUNT(*) AS n FROM jobs WHERE workflow_id = ? AND origin = ?"
" AND status IN ('pending', 'running')",
(workflow_id, TEST_ORIGIN),
).fetchone()["n"]
if active:
raise RuntimeError(
f"该工作流还有 {active} 个进行中的测试任务,请先等待完成或取消,再清除测试记录"
)
rows = conn.execute(
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?",
(workflow_id, TEST_ORIGIN),
).fetchall()
removed = 0
for r in rows:
_unlink_job_outputs(dict(r))
conn.execute("DELETE FROM jobs WHERE id = ?", (r["id"],))
removed += 1
conn.commit()
finally:
conn.close()
shutil.rmtree(workflow_test_dir(workflow_id), ignore_errors=True)
return removed
@@ -77,8 +77,11 @@ def _parsed_url(base_url: str) -> tuple[str, str]:
url = (base_url or "").strip()
if not url:
raise HTTPException(status_code=400, detail="API 地址不能为空")
if "://" not in url:
url = f"http://{url}"
# HEL-619: the server validates format as strictly as the UI already
# does. A schemeless garbled string used to be silently prefixed with
# http:// and then reported as a DNS failure; it is a 400 format error.
if "://" not in url or any(ch.isspace() for ch in url):
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
@@ -135,7 +138,12 @@ async def list_endpoints() -> dict[str, Any]:
@router.post("/endpoints")
async def create_endpoint(payload: EndpointIn) -> dict[str, Any]:
base_url, _host = _parsed_url(payload.base_url)
base_url, host = _parsed_url(payload.base_url)
# HEL-619: the same SSRF guard as the connectivity test applies on
# save — otherwise a blocked address can be stored and later probed by
# the worker path. Unresolvable hosts still pass (resolution happens
# again at probe time).
_assert_not_ssrf(host)
incoming = [ep for ep in settings.comfyui_endpoints or []]
incoming.append(payload.model_dump() | {"base_url": base_url})
try:
@@ -151,7 +159,8 @@ async def update_endpoint(endpoint_id: str, payload: EndpointPatch) -> dict[str,
_stored_endpoint(endpoint_id)
base_url = payload.base_url
if base_url is not None:
base_url, _host = _parsed_url(base_url)
base_url, host = _parsed_url(base_url)
_assert_not_ssrf(host) # same save-time guard as create (HEL-619)
patch = payload.model_dump(exclude_unset=True)
if base_url is not None:
patch["base_url"] = base_url
@@ -197,7 +197,14 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
pid = ensure_playground_project(conn)
finally:
conn.close()
return [_job_public(j) for j in queue_manager.list_jobs(pid, None, limit)]
# Workflow-test jobs (HEL-643) ride the same scratch project but must not
# surface in the studio windows' history — they carry their own dialog.
jobs = [
j
for j in queue_manager.list_jobs(pid, None, limit)
if str(j.get("origin") or "user") != "workflow_test"
]
return [_job_public(j) for j in jobs]
@router.delete("/jobs/{job_id}")
@@ -107,7 +107,11 @@ async def update_settings(payload: SettingsUpdate) -> dict[str, Any]:
if assignments_in is not None:
settings.ensure_llm_profiles()
ids = {p["id"] for p in settings.llm_profiles}
cleaned: dict[str, str | None] = {}
# HEL-619: per-role INCREMENTAL update. Rapid dropdown switches used
# to send whole cached snapshots that fully overwrote the map, so a
# stale snapshot could wipe a newer choice. Roles absent from the
# payload keep their stored value; a role sent as null is cleared.
cleaned: dict[str, str | None] = dict(settings.agent_llm_assignments or {})
for role, pid in assignments_in.items():
if role not in AGENT_LLM_ROLES:
continue
@@ -5,7 +5,8 @@ from typing import Any
from fastapi import APIRouter, HTTPException
from calliope.comfyui.lint import workflow_warnings
from calliope.comfyui.endpoints import client_for, endpoint_snapshot
from calliope.comfyui.lint import blocking_problem, workflow_warnings
from calliope.comfyui.output_map import (
auto_output_map,
load_output_map,
@@ -19,18 +20,40 @@ from calliope.comfyui.parser import (
parse_dynamic_inputs,
parse_dynamic_outputs,
)
from calliope.comfyui.preflight import check_workflow_against
from calliope.comfyui.profiles import detect_prompt_profile
from calliope.comfyui.roles import usage_to_kind
from calliope.comfyui.validate import validate_input_values
from calliope.config import settings
from calliope.db import get_db, row_to_dict
from calliope.models.schemas import WorkflowAnalyze, WorkflowCreate, WorkflowUpdate
from calliope.events.bus import event_bus
from calliope.models.schemas import (
WorkflowAnalyze,
WorkflowCheckRequest,
WorkflowCreate,
WorkflowTestRequest,
WorkflowUpdate,
)
from calliope.queue.manager import queue_manager
from calliope.queue.workflow_test import (
TEST_SOURCE,
find_active_test_job,
list_test_jobs,
purge_workflow_tests,
)
router = APIRouter()
def _serialize_workflow(row: Any) -> dict[str, Any]:
def _serialize_workflow(row: Any, *, include_document: bool = True) -> dict[str, Any]:
data = row_to_dict(row)
data["workflow_json"] = json.loads(data["workflow_json"])
if not include_document:
data.pop("doc_json", None)
for field in (("doc_json", "import_issues") if include_document else ("import_issues",)):
data[field] = json.loads(data[field]) if data.get(field) else None
data.pop("raw_source_json", None)
data.pop("library_fingerprint", None)
stored = json.loads(data["input_schema"]) if data.get("input_schema") else []
data["input_schema"] = effective_input_schema(stored, data["workflow_json"])
# Outputs are pure graph derivation (no manual overrides) — always fresh,
@@ -47,12 +70,53 @@ def _serialize_workflow(row: Any) -> dict[str, Any]:
return data
async def _connection_check(
endpoint_id: str | None, workflow_json: dict[str, Any], refresh: bool = False
) -> dict[str, Any]:
"""L2 preflight (HEL-643): workflow vs the target ComfyUI's /object_info.
An unreachable connection degrades to ``reachable: False`` with an honest
「无法核查」 message — it never blocks and never pretends to pass.
"""
endpoint = settings.resolve_comfyui_endpoint(endpoint_id)
if endpoint is None:
return {
"ok": False,
"reachable": False,
"endpoint_id": endpoint_id,
"endpoint_name": None,
"findings": [],
"message": "没有可用的 ComfyUI 连接,无法核查节点与模型",
}
base = {
"endpoint_id": endpoint.get("id"),
"endpoint_name": endpoint.get("name"),
"base_url": endpoint.get("base_url"),
}
client = client_for(endpoint)
try:
object_info = await client.object_info(force_refresh=refresh)
except Exception as exc:
return {
**base,
"ok": False,
"reachable": False,
"findings": [],
"message": f"无法核查(连不上该 ComfyUI):{type(exc).__name__}",
}
finally:
await client.close()
findings = check_workflow_against(object_info, workflow_json)
ok = not any(f.get("level") == "error" for f in findings)
return {**base, "ok": ok, "reachable": True, "findings": findings}
@router.post("/analyze")
async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
inputs = parse_dynamic_inputs(payload.workflow_json)
outputs = parse_dynamic_outputs(payload.workflow_json)
judgment = classify_usage(payload.workflow_json)
return {
result: dict[str, Any] = {
"inputs": inputs,
"outputs": outputs,
"suggested_profile": detect_prompt_profile(payload.workflow_json),
@@ -64,6 +128,13 @@ async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
"usage_evidence": judgment["evidence"],
"usable_usage": judgment["usable_usage"],
}
# L2 (optional): with a target connection the analyze response also
# reports which nodes/models that ComfyUI actually has (HEL-643).
if payload.endpoint_id:
result["connection_check"] = await _connection_check(
payload.endpoint_id, payload.workflow_json, payload.refresh
)
return result
def _stored_schema(payload_schema: list[dict[str, Any]] | None, workflow: dict[str, Any]) -> str:
@@ -107,6 +178,8 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
else:
output_map, output_map_source = json.dumps(auto_output_map(workflow)), "auto"
is_enabled = data["is_enabled"]
if data.get("import_state") in ("needs_conversion", "pending_check", "rolled_back"):
is_enabled = 0
if output_map_source is None or usage is None:
is_enabled = 0
conn.execute(
@@ -133,16 +206,67 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
conn.close()
def _last_test_summary(job: dict[str, Any]) -> dict[str, Any]:
"""Slim "上次测试" record for a workflow row (queried live, never stored)."""
try:
outputs = json.loads(job.get("output_paths_json") or "[]")
except (json.JSONDecodeError, TypeError):
outputs = []
return {
"id": job["id"],
"status": job.get("status"),
"phase": job.get("phase"),
"error": job.get("error"),
"created_at": job.get("created_at"),
"completed_at": job.get("completed_at"),
"output_count": len(outputs) if isinstance(outputs, list) else 0,
}
@router.get("")
async def list_workflows() -> list[dict[str, Any]]:
conn = get_db(settings.db_path)
try:
rows = conn.execute("SELECT * FROM workflows ORDER BY id DESC").fetchall()
return [_serialize_workflow(r) for r in rows]
out = [_serialize_workflow(r, include_document=False) for r in rows]
# 上次测试 state is read live from the newest test job per workflow —
# no cached column that could go stale (HEL-643).
latest: dict[int, dict[str, Any]] = {}
for jr in conn.execute(
"SELECT * FROM jobs WHERE origin = 'workflow_test' AND id IN"
" (SELECT MAX(id) FROM jobs WHERE origin = 'workflow_test' GROUP BY workflow_id)"
).fetchall():
latest[int(jr["workflow_id"])] = _last_test_summary(dict(jr))
for data in out:
data["last_test"] = latest.get(int(data["id"]))
return out
finally:
conn.close()
@router.post("/{workflow_id}/check")
async def check_workflow(workflow_id: int, payload: WorkflowCheckRequest) -> dict[str, Any]:
"""L2 re-check of a saved workflow against its (or a chosen) connection.
The user installs missing nodes/models in ComfyUI, comes back here and
re-checks — no re-import needed.
"""
conn = get_db(settings.db_path)
try:
row = conn.execute(
"SELECT workflow_json, endpoint_id, import_state FROM workflows WHERE id = ?",
(workflow_id,)
).fetchone()
finally:
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
if row["import_state"] in ("needs_conversion", "rolled_back"):
raise HTTPException(status_code=400, detail="请先完成转换或恢复库条目,不能核查空图")
endpoint_id = payload.endpoint_id or row["endpoint_id"]
return await _connection_check(endpoint_id, json.loads(row["workflow_json"]), payload.refresh)
@router.post("/dedupe")
async def dedupe_workflows() -> dict[str, Any]:
"""Delete byte-identical duplicate workflows, keeping the lowest id.
@@ -153,7 +277,8 @@ async def dedupe_workflows() -> dict[str, Any]:
conn = get_db(settings.db_path)
try:
rows = conn.execute(
"SELECT id, name, kind, workflow_json FROM workflows ORDER BY id"
"SELECT id, name, kind, workflow_json FROM workflows"
" WHERE source_repo_path IS NULL ORDER BY id"
).fetchall()
seen: dict[tuple[str, str, str], int] = {}
drop_ids: list[int] = []
@@ -190,9 +315,9 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
out_kinds = {
o.get("kind")
for o in parse_dynamic_outputs(payload.workflow_json)
if o.get("kind") in ("image", "video")
if o.get("kind") in ("image", "video", "audio", "file")
}
kind = "video" if out_kinds == {"video"} else "image"
kind = next(iter(out_kinds)) if len(out_kinds) == 1 else "image"
profile = payload.prompt_profile or detect_prompt_profile(payload.workflow_json)
# HEL-638: an explicit user output map wins; otherwise the trusted auto
# map. Either way a graph with unknown output candidates is unusable
@@ -280,6 +405,12 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
if not existing:
raise HTTPException(status_code=404, detail="Workflow not found")
data = payload.model_dump(exclude_unset=True)
if existing["import_state"] in ("needs_conversion", "pending_check", "rolled_back"):
if data.get("is_enabled"):
raise HTTPException(
status_code=400, detail="该库条目尚未完成转换或依赖核对,不能启用"
)
data["is_enabled"] = 0
if "is_enabled" in data and data["is_enabled"] is not None:
data["is_enabled"] = 1 if data["is_enabled"] else 0
workflow_json = json.loads(existing["workflow_json"])
@@ -341,14 +472,151 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
conn.close()
def _job_public(job: dict[str, Any]) -> dict[str, Any]:
out = dict(job)
out["payload"] = json.loads(job.get("payload_json") or "{}")
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
out.pop("payload_json", None)
out.pop("output_paths_json", None)
return out
@router.post("/{workflow_id}/test")
async def test_workflow(workflow_id: int, payload: WorkflowTestRequest) -> dict[str, Any]:
"""Launch (or re-attach to) one REAL test run of a workflow (HEL-643 L3).
The job goes through the ordinary queue → ComfyUI → outputs pipeline;
only its artifacts and bookkeeping are isolated (origin='workflow_test').
A dry-run setting is refused outright — placeholder output must never
count as a test pass. Repeat clicks reuse the in-flight job.
"""
from calliope import config
if config.settings.dry_run:
raise HTTPException(
status_code=400,
detail=(
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
"请到「设置」关闭 Dry-run 后再测试"
),
)
conn = get_db(settings.db_path)
try:
wf = conn.execute(
"SELECT id, kind, workflow_json, input_schema, output_map, output_map_source,"
" endpoint_id, import_state FROM workflows WHERE id = ?",
(workflow_id,),
).fetchone()
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
if wf["import_state"] in ("needs_conversion", "pending_check", "rolled_back"):
raise HTTPException(status_code=400, detail="该库条目尚未完成转换或依赖核对,不能测试")
workflow_json = json.loads(wf["workflow_json"])
broken = blocking_problem(workflow_json)
if broken:
raise HTTPException(status_code=400, detail=broken)
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
if map_problem:
raise HTTPException(status_code=400, detail=map_problem)
schema = effective_input_schema(
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
)
input_values, problems = validate_input_values(schema, payload.input_values)
if problems:
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
endpoint_id = payload.endpoint_id or wf["endpoint_id"] or None
snapshot = endpoint_snapshot(endpoint_id)
active = find_active_test_job(workflow_id, (snapshot or {}).get("id"))
if active is not None:
return {"ok": True, "job": _job_public(active), "reused": True}
from calliope.routers.playground import ensure_playground_project
pid = ensure_playground_project(conn)
finally:
conn.close()
job = queue_manager.enqueue(
project_id=pid,
kind=wf["kind"],
workflow_id=workflow_id,
scene_id=None,
payload={
"input_values": input_values,
"source": TEST_SOURCE,
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
},
origin="workflow_test",
)
await event_bus.publish(
"job.created",
{"job_id": job["id"], "kind": job["kind"], "source": TEST_SOURCE,
"project_id": job["project_id"], "workflow_test": True},
)
return {"ok": True, "job": _job_public(job), "reused": False}
@router.get("/{workflow_id}/tests")
async def list_workflow_tests(workflow_id: int) -> list[dict[str, Any]]:
conn = get_db(settings.db_path)
try:
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
finally:
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
return list_test_jobs(workflow_id)
@router.post("/{workflow_id}/clear-tests")
async def clear_workflow_tests(workflow_id: int) -> dict[str, Any]:
"""Delete every test record and artifact for this workflow."""
conn = get_db(settings.db_path)
try:
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
finally:
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
try:
removed = purge_workflow_tests(workflow_id)
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
await event_bus.publish("job.deleted", {"workflow_id": workflow_id, "source": TEST_SOURCE})
return {"ok": True, "removed": removed}
@router.delete("/{workflow_id}")
async def delete_workflow(workflow_id: int) -> dict[str, bool]:
conn = get_db(settings.db_path)
try:
cur = conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
conn.commit()
if cur.rowcount == 0:
# HEL-619: refuse to delete while queued/running jobs still use the
# workflow — deleting sets their workflow_id NULL and the job dies
# with "工作流已被删除" only after the user waits for it.
row = conn.execute(
"SELECT id FROM workflows WHERE id = ?", (workflow_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
pending = conn.execute(
"SELECT COUNT(*) AS n FROM jobs"
" WHERE workflow_id = ? AND status IN ('pending', 'running')",
(workflow_id,),
).fetchone()["n"]
if pending:
raise HTTPException(
status_code=409,
detail=(
f"该工作流还有 {pending} 个排队或执行中的任务,"
"请先等任务完成或取消任务,再删除该工作流"
),
)
# Test records/artifacts belong to the workflow — cascade (HEL-643).
# The 409 above guarantees none are in flight here.
purge_workflow_tests(workflow_id, allow_active=True)
conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
conn.commit()
return {"ok": True}
finally:
conn.close()
@@ -374,6 +374,61 @@ def test_comfy_test_requires_url(client):
assert r.status_code == 400
def test_schemeless_url_is_format_error_everywhere(client):
"""HEL-619: a schemeless/garbled address is a 400 format error on every
entry point (probe + save), not a silent http:// prefix that later
surfaces as a DNS-classified probe failure."""
prev = _snapshot_settings()
try:
for url in ("192.168.0.1:8188", "http:192.168.0.1", "ht!tp://x", "http://a b.test/"):
r = client.post(
"/api/comfyui/endpoints/test", json={"base_url": url, "timeout_sec": 2}
)
assert r.status_code == 400, url
assert "http://" in r.json()["detail"] or "https://" in r.json()["detail"]
r = client.post(
"/api/comfyui/endpoints", json={"name": "无协议", "base_url": url}
)
assert r.status_code == 400, url
assert "无协议" not in [ep.get("name") for ep in settings.comfyui_endpoints or []]
finally:
for key, value in prev.items():
setattr(settings, key, value)
def test_save_endpoint_runs_ssrf_guard(client):
"""HEL-619: the SSRF guard applies on save (create + edit), not only on
the connectivity probe; loopback stays allowed."""
prev = _snapshot_settings()
try:
_clear_endpoints()
r = client.post(
"/api/comfyui/endpoints",
json={"name": "元数据", "base_url": "http://169.254.169.254/latest"},
)
assert r.status_code == 400
assert "禁止" in r.json()["detail"]
r = client.post(
"/api/comfyui/endpoints",
json={"name": "本机", "base_url": "http://127.0.0.1:8188"},
)
assert r.status_code == 200
eid = r.json()["endpoints"][-1]["id"]
r = client.patch(
f"/api/comfyui/endpoints/{eid}",
json={"base_url": "http://169.254.169.254/latest"},
)
assert r.status_code == 400
# the stored endpoint is untouched
stored = next(ep for ep in settings.comfyui_endpoints if ep["id"] == eid)
assert stored["base_url"] == "http://127.0.0.1:8188"
finally:
for key, value in prev.items():
setattr(settings, key, value)
# ----------------------------------------------------------------------
# workflow binding + job snapshots
# ----------------------------------------------------------------------
@@ -542,6 +597,41 @@ def test_queue_enqueue_stamps_all_paths(client):
setattr(settings, key, value)
def test_workflow_delete_blocked_while_jobs_pending(client):
"""HEL-619: deleting a workflow with pending/running jobs is refused
with 409 instead of silently nulling the jobs' workflow_id."""
from calliope.db import get_db
from calliope.queue.manager import queue_manager
wf = _import_workflow(client).json()
project = client.post(
"/api/projects", json={"title": "删除保护测试", "idea": "x"}
).json()
# Pause the lifespan-started worker so the pending job STAYS pending.
queue_manager.paused = True
try:
queue_manager.enqueue(
project_id=project["id"], kind="video", workflow_id=wf["id"], payload={}
)
r = client.delete(f"/api/workflows/{wf['id']}")
assert r.status_code == 409
assert "排队" in r.json()["detail"]
finally:
queue_manager.paused = False
# job finished → deletion allowed again
conn = get_db(settings.db_path)
try:
conn.execute("UPDATE jobs SET status = 'done' WHERE project_id = ?", (project["id"],))
conn.commit()
finally:
conn.close()
r = client.delete(f"/api/workflows/{wf['id']}")
assert r.status_code == 200
assert r.json() == {"ok": True}
def test_settings_public_dict_masks_endpoint_tokens(client):
prev = _snapshot_settings()
try:
@@ -0,0 +1,128 @@
"Fourth-batch partition, literal source structure and isolated DB refresh."
import copy
import hashlib
import json
from scripts.audit_conversion_prerequisites import cycles, graph_rows, inventory
from scripts.audit_library_documents import audit, audit_fourth
from calliope.comfyui import source_reviews
from calliope.comfyui.library import import_library
from calliope.db import get_db, migrate_db
from tests.test_workflow_library import ROOT
def test_actual_field_partition_preserves_every_prior_review():
repo = ROOT.parent
report = audit(repo, repo / "docs/hel-892/offline")
result = audit_fourth(repo, report, repo / "docs/hel-892/fourth-evidence/field-partition.json")
assert result["classification_counts_new"] == {
"runtime_parameter": 590,
"frontend_state": 6,
"current_value_only": 41,
"semantic_unknown": 48,
}
assert report["unnamed_widget_nodes"] == 3701
assert result["verified_units_ranges_effects"] == 0
def test_conversion_inventory_is_complete_scoped_and_never_readiness_evidence():
repo = ROOT.parent
report = inventory(repo)
assert report["total_ui_files"] == report["remaining_reliable_conversions"] == 146
assert report["exact_golden_candidates"] == 7
expected = json.loads(
(repo / ("docs/hel-892/fourth-evidence/conversion-prerequisites.json")).read_text(
encoding="utf-8"
)
)
assert report == expected
for row in report["files"]:
raw = (repo / row["path"]).read_bytes()
assert row["source_sha"] == hashlib.sha1(raw).hexdigest()
source = json.loads(raw)
graphs = list(graph_rows(source))
assert sum(len(g["nodes"]) for g, _, _ in graphs) == len(row["findings"]["slots"])
for (g, pointer, scope), recorded in zip(graphs, row["graphs"], strict=True):
assert recorded["links"] == g.get("links", [])
assert recorded["scope"] == scope
assert recorded["pointer"] == (pointer or "/")
for records in row["findings"].values():
for ref in records:
if "pointer" not in ref:
continue
value = source
for part in ref["pointer"].strip("/").split("/"):
value = value[int(part)] if isinstance(value, list) else value[part]
if "node_id" in ref and "/nodes/" in ref["pointer"]:
assert ref["node_id"].split("/")[-1] == str(value["id"])
if "link" in ref:
assert ref["link"] == value
assert row["converted_reliably"] is row["executable"] is row["execution_verified"] is False
assert all(r["status"] == "requires_materials_not_implemented" for r in row["rule_review"])
def test_literal_cycles_are_not_assumed_from_node_names():
nodes = [{"id": 1}, {"id": 2}, {"id": 3}]
assert cycles(nodes, [[1, 1, 0, 2, 0, "*"], [2, 2, 0, 3, 0, "*"]]) == []
assert cycles(nodes, [[1, 1, 0, 2, 0, "*"], [2, 2, 0, 1, 0, "*"]]) == [["1", "2"]]
assert cycles(nodes, [{"origin_id": 3, "target_id": 3}]) == [["3"]]
async def test_all_16_changed_reviews_refresh_and_user_row_stays_exact(tmp_path, monkeypatch):
current = source_reviews._reviews()
old = copy.deepcopy(current)
revised = {path for path, r in current.items() if r.get("fourth_review")}
for path in revised:
review = old[path]
keys = {tuple(k) for k in review.pop("fourth_review")["fields"]}
review["reviewed_named_fields"] = [
f for f in review["reviewed_named_fields"] if (f["node_id"], f["field"]) not in keys
]
monkeypatch.setattr(source_reviews, "_reviews", lambda: old)
db = tmp_path / "fourth.db"
await migrate_db(db)
conn = get_db(db)
try:
assert import_library(conn, ROOT, source_commit="fixture")["counts"] == {"imported": 156}
prior = {r["source_repo_path"]: dict(r) for r in conn.execute("SELECT * FROM workflows")}
protected_path = sorted(revised)[0]
conn.execute(
("UPDATE workflows SET description=? WHERE source_repo_path=?"),
("用户修改保留", protected_path),
)
conn.commit()
protected = dict(
conn.execute(
"SELECT * FROM workflows WHERE source_repo_path=?", (protected_path,)
).fetchone()
)
monkeypatch.setattr(source_reviews, "_reviews", lambda: current)
assert import_library(conn, ROOT, source_commit="fixture")["counts"] == {
"updated": 15,
"skipped": 140,
"conflict": 1,
}
assert import_library(conn, ROOT, source_commit="fixture")["counts"] == {
"skipped": 155,
"conflict": 1,
}
for row in conn.execute("SELECT * FROM workflows"):
path = row["source_repo_path"]
if path == protected_path:
assert dict(row) == protected
elif path not in revised:
assert dict(row) == prior[path]
else:
doc = json.loads(row["doc_json"])
assert doc["source_review"]["fourth_review"] == current[path]["fourth_review"]
assert current[path]["fourth_review"]["operation_check"] in doc["steps"]
assert (
doc["source_review"]["reviewed_named_fields"]
== current[path]["reviewed_named_fields"]
)
assert row["is_enabled"] == 0
assert row["doc_completed_at"] is row["exec_verified_at"] is None
finally:
conn.close()
@@ -0,0 +1,139 @@
"""Synthetic tooling tests; none of these objects is a real frontend golden export."""
import copy
import hashlib
import json
import pytest
from scripts.compare_frontend_exports import compare_case, differences
@pytest.fixture
def pair(tmp_path):
source = {
"nodes": [
{"id": "1:2", "type": "Source", "widgets_values": {"text": "中文\n原文"}},
{"id": 3, "type": "Sink", "inputs": [{"name": "value", "link": 9}]},
],
"links": [[9, "1:2", 1, 3, 0, "STRING"]],
}
expected = {
"1:2": {
"class_type": "Source",
"inputs": {"text": "中文\n原文"},
"_meta": {"title": "Source"},
},
"3": {"class_type": "Sink", "inputs": {"value": ["1:2", 1]}, "_meta": {"title": "Sink"}},
}
info = {
"Source": {"input": {"required": {"text": ["STRING", {}]}}, "output": ["IMAGE", "STRING"]},
"Sink": {"input": {"required": {"value": ["STRING", {}]}}, "output": []},
}
raw = json.dumps(source).encode()
(tmp_path / "source.json").write_bytes(raw)
case = {
"id": "fixture",
"source_path": "source.json",
"source_sha": hashlib.sha1(raw).hexdigest(),
"export_file": "01.api.json",
"binding_file": "01.binding.json",
"comparison": "全部字段与连线",
}
def write_export(value):
payload = json.dumps(value).encode()
(tmp_path / case["export_file"]).write_bytes(payload)
binding = {
"source_path": case["source_path"],
"source_sha": case["source_sha"],
"export_sha256": hashlib.sha256(payload).hexdigest(),
"original_frontend_export": True,
"exported_at": "fixture-time",
"frontend_version": "fixture",
"node_versions": "fixture",
"origin": "synthetic tool test; NOT original export",
}
(tmp_path / case["binding_file"]).write_text(json.dumps(binding))
write_export(expected)
return tmp_path, case, expected, {"object_info": info}, write_export
def test_equal_pair_does_not_enable_or_claim_execution(pair):
root, case, expected, snapshot, _ = pair
before = (root / "source.json").read_bytes()
result = compare_case(root, root, case, snapshot)
assert result["status"] == "equal"
assert result["differences"] == [] and result["metadata_differences"] == []
assert {"path": "/3/inputs/value/1", "value": 1} in result["preserved_fields"]
assert not result["executable"] and not result["execution_verified"]
assert (root / "source.json").read_bytes() == before
assert not list(root.glob("*.db"))
def test_missing_exports_and_definitions_fail_closed(pair):
root, case, _, _, _ = pair
(root / case["export_file"]).unlink()
result = compare_case(root, root, case)
assert result["status"] == "missing_materials"
assert result["missing"] == [case["export_file"]]
assert result["node_definitions_available"] is False
def test_export_present_but_conversion_incomplete_is_blocked(pair):
root, case, _, _, _ = pair
result = compare_case(root, root, case)
assert result["status"] == "conversion_blocked"
assert "differences" not in result
assert {i["code"] for i in result["issues"]} == {"node_definition_unavailable", "empty_prompt"}
@pytest.mark.parametrize(
"mutation, expected_path",
[
(lambda g: g["3"]["inputs"].update(value=["1:2", 0]), "/3/inputs/value/1"),
(lambda g: g["1:2"]["inputs"].update(text="改字"), "/1:2/inputs/text"),
(lambda g: g["3"].update(class_type="Other"), "/3/class_type"),
],
)
def test_field_and_nonzero_link_slot_differences_are_exact(pair, mutation, expected_path):
root, case, golden, snapshot, write = pair
mutation(golden)
write(golden)
result = compare_case(root, root, case, snapshot)
assert result["status"] == "different"
assert expected_path in {d["path"] for d in result["differences"]}
def test_meta_is_separate_and_not_silently_ignored(pair):
root, case, golden, snapshot, write = pair
golden["3"]["_meta"]["title"] = "不同标题"
write(golden)
result = compare_case(root, root, case, snapshot)
assert result["status"] == "different"
assert result["differences"] == []
assert result["metadata_differences"][0]["path"] == "/3/title"
def test_changed_source_and_changed_export_cannot_borrow_old_binding(pair):
root, case, _, snapshot, _ = pair
wrong = copy.deepcopy(case)
wrong["source_sha"] = "bad"
assert compare_case(root, root, wrong, snapshot)["status"] == "source_sha_mismatch"
(root / case["export_file"]).write_text("{}")
assert compare_case(root, root, case, snapshot)["status"] == "invalid_binding"
@pytest.mark.parametrize(
"payload", [[], {"nodes": []}, {}, {"1": {"class_type": "Sink", "inputs": {"x": ["2", 0]}}}]
)
def test_wrong_or_broken_export_is_not_compared(pair, payload):
root, case, _, snapshot, write = pair
write(payload)
assert compare_case(root, root, case, snapshot)["status"] == "invalid_export"
def test_comparison_keeps_missing_null_type_list_order_and_json_pointer_escaping():
result = differences({"a/b~c": [1, None], "empty": None}, {"a/b~c": [True]})
assert {r["path"] for r in result} == {"/a~1b~0c/0", "/a~1b~0c/1", "/empty"}
assert result[0]["kind"] == "type"
@@ -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)})
@@ -216,6 +216,18 @@ def test_settings_accepts_agent_llm_assignments(client):
# Unknown role keys are dropped, known roles stored (incl. explicit None)
assert body["agent_llm_assignments"] == {"video": a_id, "story": None}
# HEL-619: updates are per-role incremental — a partial payload must
# not wipe roles it does not mention (rapid-switch race).
r = client.post(
"/api/settings",
json={"agent_llm_assignments": {"main": a_id}},
)
assert r.status_code == 200
body = r.json()
assert body["agent_llm_assignments"]["main"] == a_id
assert body["agent_llm_assignments"]["video"] == a_id
assert body["agent_llm_assignments"]["story"] is None
r = client.post(
"/api/settings",
json={"agent_llm_assignments": {"video": "no-such-profile"}},
+266
View File
@@ -0,0 +1,266 @@
"""媒体组泛化(HEL-643 批次 3):参考视频/参考音频按 JSON 实际连线进入
input_schema 与提交流程;无连线则无入口;05_ref2va 只保留参考图。
视频/音频参考的真实 H3 JSON 暂无可执行样本(.15 无 ComfyUI),本套用
自造 fixture + fake ComfyUI 验证解析/表单/回填/上传全链路(模拟链路)。
"""
from __future__ import annotations
import json
import time
from pathlib import Path
import pytest
from calliope.comfyui.parser import parse_dynamic_inputs
from calliope.comfyui.patcher import patch_workflow
from calliope.comfyui.validate import validate_input_values
from calliope.config import settings
from tests.test_studio_generation import PNG_1PX, _real_run
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
@pytest.fixture
def uploads_dir(client):
d = settings.assets_dir / "uploads"
d.mkdir(parents=True, exist_ok=True)
return d
def _load_05() -> dict:
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
def _ref2va_workflow(*, images=1, videos=1, audios=1) -> dict:
"""H3 Ref2VA 形状的自造 JSON:图/视频/音频各一组 Autogrow 连线。"""
wf: dict = {
"1": {
"class_type": "PrimitiveStringMultiline",
"inputs": {"value": "提示词"},
},
"92": {
"class_type": "SaveVideo",
"inputs": {"video": ["131", 0]},
},
"131": {
"class_type": "MiniMaxH3ReferenceToVideo",
"inputs": {"prompt": ["1", 0]},
},
"901": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
"902": {"class_type": "LoadVideo", "inputs": {"file": "ref.mp4", "force_rate": 0}},
"903": {"class_type": "LoadAudio", "inputs": {"audio": "ref.wav"}},
}
ins = wf["131"]["inputs"]
for i in range(images):
ins[f"ref_images.ref_image_{i}"] = ["901", 0]
for i in range(videos):
ins[f"ref_videos.ref_video_{i}"] = ["902", 0]
for i in range(audios):
ins[f"ref_audios.ref_audio_{i}"] = ["903", 0]
return wf
def _entry(schema: list[dict], key_suffix: str) -> dict | None:
for e in schema:
if e["nodeId"].endswith("@" + key_suffix):
return e
return None
# ---------------------------------------------------------------------------
# Schema: 入口只由实际连线决定
# ---------------------------------------------------------------------------
def test_ref2va_schema_exposes_three_media_groups():
schema = parse_dynamic_inputs(_ref2va_workflow())
img = _entry(schema, "ref_images")
vid = _entry(schema, "ref_videos")
aud = _entry(schema, "ref_audios")
assert img and img["kind"] == "image_list" and img["role"] == "reference_image"
assert img["max"] == 9 and img["label"] == "参考图"
assert vid and vid["kind"] == "video_list" and vid["role"] == "reference_video"
assert vid["max"] == 3 and vid["label"] == "参考视频"
assert aud and aud["kind"] == "audio_list" and aud["role"] == "reference_audio"
assert aud["max"] == 3 and aud["label"] == "参考音频"
# 官方限额提示按节点类名驱动(通用检测不携带 hint)
assert img["media_hint"] == vid["media_hint"] == aud["media_hint"] == "minimax_h3_ref2va"
# 被组吸收的 loader 不再单独出现
assert not any(e["nodeId"] == "902" for e in schema)
assert not any(e["nodeId"] == "903" for e in schema)
def test_unwired_media_kinds_produce_no_entry():
schema = parse_dynamic_inputs(_ref2va_workflow(videos=0, audios=0))
assert _entry(schema, "ref_images") is not None
assert _entry(schema, "ref_videos") is None
assert _entry(schema, "ref_audios") is None
def test_generic_class_gets_group_without_hint():
wf = _ref2va_workflow(videos=0, audios=0)
wf["20"] = {"class_type": "GenericRefNode", "inputs": {}}
wf["701"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "a.mp4"}}
wf["702"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "b.mp4"}}
wf["20"]["inputs"]["media.clip_0"] = ["701", 0]
wf["20"]["inputs"]["media.clip_1"] = ["702", 0]
schema = parse_dynamic_inputs(wf)
g = _entry(schema, "media")
assert g and g["kind"] == "video_list" and g["max"] == 9
assert "media_hint" not in g
def test_05_ref2va_fixture_stays_image_only():
schema = parse_dynamic_inputs(_load_05())
lists = [e for e in schema if e["kind"].endswith("_list")]
assert len(lists) == 1
assert lists[0]["kind"] == "image_list" and lists[0]["max"] == 9
assert lists[0]["nodeId"] == "131@ref_images"
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def _media_file(path: str, data: bytes) -> str:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(data)
return str(p)
def test_validate_video_and_audio_lists(uploads_dir):
schema = parse_dynamic_inputs(_ref2va_workflow())
img_key = _entry(schema, "ref_images")["nodeId"]
vid_key = _entry(schema, "ref_videos")["nodeId"]
aud_key = _entry(schema, "ref_audios")["nodeId"]
png = _media_file(str(uploads_dir / "ok.png"), PNG_1PX)
mp4 = _media_file(str(uploads_dir / "ok.mp4"), b"00")
mp3 = _media_file(str(uploads_dir / "ok.mp3"), b"00")
clean, errs = validate_input_values(
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp3]}
)
assert errs == [] and clean[vid_key] == [mp4] and clean[aud_key] == [mp3]
# 错误格式(图片文件填视频槽 / 视频文件填音频槽)
_, errs = validate_input_values(
schema, {"1": "p", img_key: [png], vid_key: [png], aud_key: [mp3]}
)
assert errs == ["参考视频 1:格式不支持,请使用 MP4 / WebM / MOV"]
_, errs = validate_input_values(
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp4]}
)
assert errs == ["参考音频 1:格式不支持,请使用 MP3 / WAV / M4A"]
# 上限(H3 官方 3 段)与最少 1 段
four = [_media_file(str(uploads_dir / f"v{i}.mp4"), b"00") for i in range(4)]
_, errs = validate_input_values(
schema, {"1": "p", img_key: [png], vid_key: four, aud_key: [mp3]}
)
assert errs == ["参考视频最多 3 段(当前 4 段),请删除多余的视频"]
_, errs = validate_input_values(
schema, {"1": "p", img_key: [png], aud_key: [mp3]}
)
assert errs == ["需要至少 1 段参考视频"]
# ---------------------------------------------------------------------------
# Patcher + full chain against fake ComfyUI
# ---------------------------------------------------------------------------
def test_patcher_clones_and_backfills_each_media_kind(uploads_dir):
wf = _ref2va_workflow()
schema = parse_dynamic_inputs(wf)
pngs = [_media_file(str(uploads_dir / "r1.png"), PNG_1PX),
_media_file(str(uploads_dir / "r2.png"), PNG_1PX)]
mp4 = _media_file(str(uploads_dir / "v1.mp4"), b"00")
mp3 = _media_file(str(uploads_dir / "a1.mp3"), b"00")
patched = patch_workflow(
wf,
{
_entry(schema, "ref_images")["nodeId"]: pngs,
_entry(schema, "ref_videos")["nodeId"]: [mp4],
_entry(schema, "ref_audios")["nodeId"]: [mp3],
},
)
ins = patched["131"]["inputs"]
image_links = [v for k, v in ins.items() if k.startswith("ref_images.")]
video_links = [v for k, v in ins.items() if k.startswith("ref_videos.")]
audio_links = [v for k, v in ins.items() if k.startswith("ref_audios.")]
assert len(image_links) == 2 and len(video_links) == 1 and len(audio_links) == 1
loaders = {nid: n for nid, n in patched.items()
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")}
imgs = [n for n in loaders.values() if n["class_type"] == "LoadImage"]
vids = [n for n in loaders.values() if n["class_type"] == "LoadVideo"]
auds = [n for n in loaders.values() if n["class_type"] == "LoadAudio"]
assert len(imgs) == 2 and all(n["inputs"]["image"] in pngs for n in imgs)
assert vids and vids[0]["inputs"]["file"] == mp4
assert auds and auds[0]["inputs"]["audio"] == mp3
# 克隆出的节点带中文槽位标题
assert {n["_meta"]["title"] for n in imgs} == {"参考图 1", "参考图 2"}
assert vids[0]["_meta"]["title"] == "参考视频 1"
assert auds[0]["_meta"]["title"] == "参考音频 1"
def test_full_chain_uploads_and_backfills_media_groups(client, fake_comfy, uploads_dir):
"""提交 → 上传 → 回填:fake ComfyUI 收到的 prompt 里每类 loader 都
指向已上传文件(模拟链路:fake 端点,非真机 H3)。"""
wf = _ref2va_workflow()
png = _media_file(str(uploads_dir / "ref.png"), PNG_1PX)
mp4 = _media_file(str(uploads_dir / "ref.mp4"), b"00")
mp3 = _media_file(str(uploads_dir / "ref.mp3"), b"00")
r = client.post(
"/api/workflows",
json={"name": "媒体组链路", "workflow_json": wf, "usage": "ref2video"},
)
assert r.status_code == 200, r.text
wf_id = r.json()["id"]
schema = r.json()["input_schema"]
with _real_run(client, fake_comfy):
fake_comfy.out_name = "media_out.mp4"
r = client.post(
f"/api/workflows/{wf_id}/test",
json={
"input_values": {
_entry(schema, "ref_images")["nodeId"]: [png],
_entry(schema, "ref_videos")["nodeId"]: [mp4],
_entry(schema, "ref_audios")["nodeId"]: [mp3],
}
},
)
assert r.status_code == 200, r.text
job_id = r.json()["job"]["id"]
deadline = time.time() + 25
job = {}
while time.time() < deadline:
job = client.get(f"/api/jobs/{job_id}").json()
if job["status"] not in ("pending", "running"):
break
time.sleep(0.4)
assert job["status"] == "done", job.get("error")
assert len(fake_comfy.uploads) >= 3 # 图、视频、音频各一次
queued = fake_comfy.prompts[-1]["prompt"]
loaders = [n for n in queued.values()
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")]
by_field = {
(n["class_type"], k): v
for n in loaders
for k, v in n["inputs"].items()
if isinstance(v, str) and "uploaded-input" in v
}
assert ("LoadImage", "image") in by_field
assert ("LoadVideo", "file") in by_field
assert ("LoadAudio", "audio") in by_field
# 组连线数与提交数一致
ins = queued["131"]["inputs"]
assert len([k for k in ins if k.startswith("ref_images.")]) == 1
assert len([k for k in ins if k.startswith("ref_videos.")]) == 1
assert len([k for k in ins if k.startswith("ref_audios.")]) == 1
@@ -314,7 +314,7 @@ def test_empty_outputs_never_count_as_done(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(空结果)", "image", _load(T2I))
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
assert job["status"] == "failed"
assert "没有返回任何图片或视频" in job["error"]
assert "没有返回任何图片、视频、音频或文件" in job["error"]
assert job["comfy_prompt_id"] and job["comfy_prompt_id"] in job["error"]
assert job["output_paths"] == []
+54 -15
View File
@@ -13,6 +13,7 @@ The shell is default-OFF; every guard branch here is a security contract:
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
@@ -25,6 +26,23 @@ from calliope.agent.harness.registry import ToolContext
PYTHON = Path(sys.executable).stem # bare name ("python") — the tool refuses paths
@pytest.fixture(autouse=True, scope="module")
def _interpreter_on_path():
"""Make the bare interpreter name resolvable via PATH (HEL-619).
The guard resolves the command with ``shutil.which`` against PATH —
running pytest as ``path/to/.venv/bin/python -m pytest`` (or on a box
with no ``python`` alias) leaves the stem unresolvable and every
exec-path test fails for environmental reasons only. Prepending the
interpreter's own bin dir (restored afterwards) pins the lookup to the
interpreter actually running the suite, on every OS.
"""
prev = os.environ.get("PATH", "")
os.environ["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{prev}"
yield
os.environ["PATH"] = prev
@pytest.fixture(autouse=True, scope="module")
def _never_touch_real_db():
"""Redirect data_dir before ANY write in this module (repo test rule)."""
@@ -210,12 +228,19 @@ def test_structured_shell_approval_allows(enabled):
def test_cwd_outside_workspace_denied(enabled):
"""Guard denial tests use PYTHON + a structured approval card (not
ffprobe): the interpreter resolves on every machine, and the card
clears the approval gate so the LATER containment check is what fires
(HEL-619 — ffprobe-absent boxes used to fail these for environmental
reasons)."""
registry, _ = build_harness()
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "cwd": str(Path(settings_data_dir_home()))},
{"command": PYTHON, "cwd": str(settings_data_dir_home())},
)
)
assert out["reason_code"] == "guard_shell_outside_workspace"
@@ -230,11 +255,13 @@ def test_sensitive_arg_denied(enabled, tmp_path):
registry, _ = build_harness()
secret = tmp_path / "calliope_config.json"
secret.write_text("{}", encoding="utf-8")
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": [str(secret)]},
{"command": PYTHON, "args": ["-c", "print('x')", str(secret)]},
)
)
assert out["reason_code"] == "guard_shell_denied_path"
@@ -244,11 +271,13 @@ def test_db_arg_denied(enabled, tmp_path):
registry, _ = build_harness()
db = tmp_path / "calliope.db"
db.write_bytes(b"")
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": [str(db)]},
{"command": PYTHON, "args": ["-c", "print('x')", str(db)]},
)
)
assert out["reason_code"] == "guard_shell_denied_path"
@@ -256,11 +285,13 @@ def test_db_arg_denied(enabled, tmp_path):
def test_env_file_arg_denied(enabled):
registry, _ = build_harness()
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": ["--", ".env"]},
{"command": PYTHON, "args": ["-c", "print('x')", "--", ".env"]},
)
)
assert out["reason_code"] == "guard_shell_denied_path"
@@ -270,11 +301,13 @@ def test_outside_absolute_arg_denied(enabled, tmp_path):
registry, _ = build_harness()
outside = tmp_path / "movie.mp4"
outside.write_bytes(b"00")
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": [str(outside)]},
{"command": PYTHON, "args": ["-c", "print('x')", str(outside)]},
)
)
assert out["reason_code"] == "guard_shell_outside_workspace"
@@ -287,16 +320,22 @@ def test_inside_asset_arg_allowed(enabled):
registry, _ = build_harness()
ref = settings.assets_dir / "ref.mp4"
ref.write_bytes(b"00")
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": ["-v", "error", str(ref)]},
{
# The asset path is a real argv entry the guard must allow
# (and the interpreter actually reads it).
"command": PYTHON,
"args": ["-c", "import sys; print(open(sys.argv[1]).read())", str(ref)],
},
)
)
# Not a real mp4 — ffprobe fails, but the GUARD must not have denied it.
assert out.get("reason_code") is None
assert out.get("exit_code") != 0 or out.get("ok") is False or out["ok"] is True
assert out["ok"] is True, out
assert "00" in out["stdout"]
def test_workspace_relative_cwd_allowed(enabled, tmp_path):
@@ -0,0 +1,77 @@
"""Actual catalogue review refresh, user protection and source-evidence audit."""
import copy
import json
from pathlib import Path
from scripts.audit_library_documents import audit, audit_partition
from calliope.comfyui import source_reviews
from calliope.comfyui.library import import_library
from calliope.db import get_db, migrate_db
from tests.test_workflow_library import ROOT
def test_actual_review_partition_and_evidence_match_original_sources():
repo = ROOT.parent
report = audit(repo, repo / "docs/hel-892/offline")
partition = audit_partition(
repo, report, repo / "docs/hel-892/third-evidence/review-partition.json"
)
assert partition["new_named_fields"] == 500
assert partition["new_without_named_fields"] == 114
assert report["named_values_preserved"] == 1227
assert report["selected_named_fields_reviewed"] == 1227
assert report["full_documents_completed"] == report["real_executions"] == 0
async def test_all_140_reviews_refresh_but_user_modified_row_is_preserved(tmp_path, monkeypatch):
current = json.loads(Path(source_reviews.__file__).with_suffix(".json").read_text())
previous = copy.deepcopy(current)
revised = {path for path, review in current.items() if review.get("semantic_review")}
for path in revised:
previous[path] = {
key: previous[path][key] for key in ("source_sha", "purpose", "instruction")
}
monkeypatch.setattr(source_reviews, "_reviews", lambda: previous)
await migrate_db(tmp_path / "catalogue.db")
conn = get_db(tmp_path / "catalogue.db")
try:
assert import_library(conn, ROOT, source_commit="fixture")["counts"] == {"imported": 156}
prior = {
row["source_repo_path"]: dict(row)
for row in conn.execute("SELECT * FROM workflows")
if row["source_repo_path"] not in revised
}
protected_path = sorted(revised)[0]
conn.execute(
"UPDATE workflows SET description=? WHERE source_repo_path=?",
("用户整行版本", protected_path),
)
conn.commit()
protected = dict(
conn.execute(
"SELECT * FROM workflows WHERE source_repo_path=?", (protected_path,)
).fetchone()
)
monkeypatch.setattr(source_reviews, "_reviews", lambda: current)
result = import_library(conn, ROOT, source_commit="fixture")
assert result["counts"] == {"updated": 139, "skipped": 16, "conflict": 1}
assert import_library(conn, ROOT, source_commit="fixture")["counts"] == {
"skipped": 155,
"conflict": 1,
}
for row in conn.execute("SELECT * FROM workflows"):
path = row["source_repo_path"]
if path == protected_path:
assert dict(row) == protected
elif path in prior:
assert dict(row) == prior[path]
else:
doc = json.loads(row["doc_json"])
assert doc["source_review"]["semantic_review"] == current[path]["semantic_review"]
assert current[path]["semantic_review"]["operation_check"] in doc["steps"]
assert row["is_enabled"] == 0
assert row["doc_completed_at"] is None and row["exec_verified_at"] is None
finally:
conn.close()
@@ -50,6 +50,9 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
},
)
return
if self.path == "/object_info":
self._json(200, dict(getattr(self.server, "object_info", None) or {}))
return
if self.path.startswith("/history/"):
pid = self.path[len("/history/") :]
entry = self.server.history.get(pid)
@@ -78,9 +81,13 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
{"filename": self.server.out_name, "subfolder": "", "type": "output"}
]
}
outputs = (
{} if getattr(self.server, "drop_outputs", False) else
{self._output_node(body): out_entry}
)
self.server.history[prompt_id] = {
"status": {"status_str": "success", "completed": True},
"outputs": {self._output_node(body): out_entry},
"outputs": outputs,
"prompt": [],
}
self.server.prompts.append(json.loads(body.decode("utf-8") or "{}"))
@@ -135,6 +142,8 @@ def fake_comfy():
server.out_name = "out-1.png"
server.outputs_key = "images"
server.forced_output_node = None
server.object_info = {}
server.drop_outputs = False
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
@@ -0,0 +1,164 @@
"""Source API preservation and synthetic direct-link differential evidence.
The synthetic pair is not a substitute for the seven real frontend exports.
"""
from __future__ import annotations
import copy
import hashlib
import json
import pytest
from calliope.comfyui import source_reviews
from calliope.comfyui.library import import_library
from calliope.comfyui.ui2api import convert
from calliope.db import get_db, migrate_db
from tests.test_workflow_library import ROOT
API_FILES = sorted(ROOT.glob("*_API.json"))
@pytest.mark.parametrize("path", API_FILES, ids=lambda path: path.name)
def test_ten_existing_api_exports_preserve_every_field_and_link(path):
source = json.loads(path.read_text())
before = copy.deepcopy(source)
result = convert(source)
assert result.issues == []
assert result.prompt == before
assert source == before
# Verify references independently of the converter, including colon/dotted IDs.
for node in result.prompt.values():
for value in node["inputs"].values():
if isinstance(value, list):
assert len(value) == 2 and value[0] in result.prompt
assert type(value[1]) is int and value[1] >= 0
def test_named_direct_link_pair_preserves_nonzero_slot_and_named_values():
info = {
"Source": {"input": {"required": {"text": ["STRING", {}]}}, "output": ["IMAGE", "STRING"]},
"Sink": {"input": {"required": {"value": ["STRING", {}]}}, "output": []},
}
ui = {
"nodes": [
{"id": "1:7", "type": "Source", "widgets_values": {"text": "逐字保留\n中文"}},
{"id": 2, "type": "Sink", "inputs": [{"name": "value", "link": 9}]},
{"id": 3, "type": "Note", "widgets_values": ["not executable"]},
],
"links": [[9, "1:7", 1, 2, 0, "STRING"]],
}
before = copy.deepcopy(ui)
expected = {
"1:7": {
"class_type": "Source",
"inputs": {"text": "逐字保留\n中文"},
"_meta": {"title": "Source"},
},
"2": {"class_type": "Sink", "inputs": {"value": ["1:7", 1]}, "_meta": {"title": "Sink"}},
}
assert convert(ui, info).prompt == expected
assert ui == before
ui["links"][0][2] = 0 # valid slot, incompatible IMAGE -> STRING
result = convert(ui, info)
assert result.prompt is None
assert "link_type_unverified" in {i["code"] for i in result.issues}
@pytest.mark.parametrize("value", [["missing", 0], ["1", -1], ["1", True], ["1"], []])
def test_invalid_api_links_fail_closed(value):
source = {"1": {"class_type": "Sink", "inputs": {"value": value}}}
result = convert(source)
assert result.prompt is None
assert result.issues[0]["code"] == "invalid_api_link"
def test_real_ui_sources_stay_closed_without_frontend_evidence():
paths = [p for p in ROOT.rglob("*") if p.is_file() and p not in API_FILES]
assert len(API_FILES) == 10 and len(paths) == 146
for path in paths:
source = json.loads(path.read_text())
result = convert(source)
assert result.prompt is None, path
assert result.issues, path
async def test_review_revision_refreshes_docs_preserving_user_edits_and_sha_binding(
tmp_path,
monkeypatch,
):
root = tmp_path / "examples"
root.mkdir()
source = root / "a.json"
source.write_text(json.dumps({"1": {"class_type": "SaveImage", "inputs": {}}}))
sha = hashlib.sha1(source.read_bytes()).hexdigest()
notes = {}
monkeypatch.setattr(source_reviews, "_reviews", lambda: notes)
await migrate_db(tmp_path / "local.db")
conn = get_db(tmp_path / "local.db")
try:
assert import_library(conn, root, source_commit="fixture")["counts"] == {"imported": 1}
notes["examples/a.json"] = {
"source_sha": sha,
"purpose": "图像保存结构",
"instruction": "核对 SaveImage",
}
assert import_library(conn, root, source_commit="fixture")["counts"] == {"updated": 1}
doc = json.loads(conn.execute("SELECT doc_json FROM workflows").fetchone()[0])
assert doc["purpose"] == "图像保存结构"
assert not doc["source_review"]["complete"]
assert doc["status"] == {"imported": True, "doc_done": False, "exec_verified": False}
assert import_library(conn, root, source_commit="fixture")["counts"] == {"skipped": 1}
# A changed source must not inherit notes for different source bytes.
source.write_text(json.dumps({"2": {"class_type": "SaveAudio", "inputs": {}}}))
assert import_library(conn, root, source_commit="fixture")["counts"] == {"updated": 1}
doc = json.loads(conn.execute("SELECT doc_json FROM workflows").fetchone()[0])
assert "source_review" not in doc
conn.execute("UPDATE workflows SET description='用户版'")
conn.commit()
notes["examples/a.json"]["instruction"] = "changed review"
assert import_library(conn, root, source_commit="fixture")["counts"] == {"conflict": 1}
assert conn.execute("SELECT description FROM workflows").fetchone()[0] == "用户版"
finally:
conn.close()
async def test_reviewed_catalogue_has_real_named_values_and_api_edges(tmp_path):
await migrate_db(tmp_path / "local.db")
conn = get_db(tmp_path / "local.db")
try:
report = import_library(conn, ROOT, source_commit="fixture")
assert report["states"] == {"needs_conversion": 146, "pending_check": 10}
for row in conn.execute("SELECT * FROM workflows"):
doc = json.loads(row["doc_json"])
assert doc["source_review"]["source_sha"] == row["source_sha"]
review = doc["source_review"]
assert review["scope"][0] == "结构及用途局部审校"
if review.get("semantic_review"):
assert review["semantic_review"]["operation_check"] in doc["steps"]
assert len(review["scope"]) == 2
assert not doc["source_review"]["complete"]
assert doc["status"]["doc_done"] is False
source = json.loads(row["raw_source_json"])
if "nodes" not in source:
links = [
(nid, name, value)
for nid, node in source.items()
for name, value in node["inputs"].items()
if isinstance(value, list)
]
assert len(doc["source_links"]) == len(links)
for nid, name, value in links:
assert {
"scope": "",
"node_id": nid,
"field": name,
"upstream_node_id": value[0],
"output_slot": value[1],
} in doc["source_links"]
for param in doc["params"]:
assert param["unit"] is None and param["range_source"] == "unverified"
assert param["field"] != "videopreview"
finally:
conn.close()
@@ -184,7 +184,7 @@ def test_mapped_node_returning_nothing_fails_for_real(client, fake_comfy):
assert r.status_code == 200, r.text
job = _wait_done(client, r.json()["job"]["id"])
assert job["status"] == "failed"
assert "没有返回任何图片或视频" in job["error"]
assert "没有返回任何图片、视频、音频或文件" in job["error"]
assert "15" in job["error"] # names the node it waited on
assert job["output_paths"] == []
@@ -234,7 +234,7 @@ def test_invalid_mappings_are_rejected_in_chinese(client):
"/api/workflows",
json={"name": "坏映射2", "workflow_json": _vault_workflow(),
"usage": "text2video",
"output_map": [{"nodeId": "12", "kind": "audio"}]},
"output_map": [{"nodeId": "12", "kind": "unsupported_media"}]},
)
assert r.status_code == 422 and "媒体类型" in r.json()["detail"]
@@ -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
@@ -289,7 +289,11 @@ def _stub_comfy(monkeypatch, captured: dict) -> list[str]:
prepare_media_inputs handed it; download_image writes out.mp4. Returns
the list that collects uploaded local paths.
"""
queue_manager.paused = True
# monkeypatch (not bare assignment): queue_manager is a process-level
# singleton — a bare `paused = True` leaks into every later test and
# stalls e.g. test_workbench's worker (HEL-619). monkeypatch restores
# the previous value at teardown.
monkeypatch.setattr(queue_manager, "paused", True)
uploads: list[str] = []
async def fake_upload_video(self, path, subfolder="calliope"):
@@ -38,6 +38,7 @@ def test_analyze_returns_usage_evidence_and_problem(client):
assert any(o["kind"] == "image" for o in body["usage_evidence"]["outputs"])
assert body["usable_usage"] == [
"text2image", "image2image", "text2video", "image2video", "ref2video",
"text2speech", "speech2text", "text2music", "utility", "audio2video", "pose2video",
]
r = client.post("/api/workflows/analyze", json={"workflow_json": AMBIGUOUS})
@@ -0,0 +1,285 @@
"""HEL-892: offline catalogue completeness, preservation and conservative conversion."""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
import pytest
from calliope.comfyui.client import ComfyUIClient
from calliope.comfyui.library import import_library, load_snapshot, migrate_library, rollback_batch
from calliope.comfyui.output_map import auto_output_map
from calliope.comfyui.ui2api import convert
from calliope.config import settings
from calliope.db import get_db, migrate_db
ROOT = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
API = {"1": {"class_type": "SaveImage", "inputs": {"filename_prefix": "test"}}}
async def test_all_sources_and_repeat_after_restart(tmp_path):
db = tmp_path / "library.db"
await migrate_db(db)
conn = get_db(db)
conn.execute(
"INSERT INTO workflows(name,kind,workflow_json) VALUES ('user','image',?)",
(json.dumps(API),),
)
conn.commit()
before = dict(conn.execute("SELECT * FROM workflows WHERE id=1").fetchone())
report = import_library(conn, ROOT, source_commit="fixture")
assert report["total"] == 156
assert report["states"] == {"needs_conversion": 146, "pending_check": 10}
assert conn.execute("SELECT count(*) FROM workflows WHERE is_enabled=0").fetchone()[0] == 156
assert dict(conn.execute("SELECT * FROM workflows WHERE id=1").fetchone()) == before
for row in conn.execute("SELECT * FROM workflows WHERE source_repo_path IS NOT NULL"):
doc = json.loads(row["doc_json"])
assert doc["source_nodes"]
assert doc["status"] == {"imported": True, "doc_done": False, "exec_verified": False}
assert json.loads(row["raw_source_json"]) == json.loads(
(ROOT.parent / row["source_repo_path"]).read_text()
)
conn.close()
await migrate_db(db)
conn = get_db(db)
second = import_library(conn, ROOT, source_commit="fixture")
assert second["counts"] == {"skipped": 156}
assert conn.execute("SELECT count(*) FROM workflows").fetchone()[0] == 157
conn.close()
async def test_update_user_conflict_and_rollback(tmp_path):
root = tmp_path / "sources"
root.mkdir()
source = root / "a.json"
source.write_text(json.dumps(API))
db = tmp_path / "local.db"
await migrate_db(db)
conn = get_db(db)
first = import_library(conn, root, source_commit="first")
old = dict(conn.execute("SELECT * FROM workflows").fetchone())
source.write_text(json.dumps({"2": {"class_type": "SaveAudio", "inputs": {}}}))
second = import_library(conn, root, source_commit="second")
assert second["counts"] == {"updated": 1}
assert conn.execute("SELECT kind FROM workflows").fetchone()[0] == "audio"
assert rollback_batch(conn, second["batch_id"])["files"][0]["result"] == "restored"
assert dict(conn.execute("SELECT * FROM workflows").fetchone()) == old
conn.execute("UPDATE workflows SET description='用户版' WHERE id=?", (old["id"],))
conn.commit()
assert import_library(conn, root, source_commit="third")["counts"] == {"conflict": 1}
assert rollback_batch(conn, first["batch_id"])["files"][0]["result"] == "conflict"
assert conn.execute("SELECT description FROM workflows").fetchone()[0] == "用户版"
conn.close()
async def test_rollback_new_rows_preserves_references(tmp_path):
await migrate_db(tmp_path / "db")
conn = get_db(tmp_path / "db")
root = tmp_path / "sources"
root.mkdir()
(root / "a.json").write_text(json.dumps(API))
report = import_library(conn, root, source_commit="first")
assert rollback_batch(conn, report["batch_id"])["files"][0]["result"] == "disabled"
assert conn.execute("SELECT import_state FROM workflows").fetchone()[0] == "rolled_back"
assert rollback_batch(conn, report["batch_id"])["files"] == []
assert import_library(conn, root, source_commit="first")["counts"] == {"updated": 1}
conn.close()
def test_legacy_constraint_migration_preserves_fk_and_indexes():
conn = sqlite3.connect(":memory:")
conn.execute("PRAGMA foreign_keys=ON")
conn.executescript("""
CREATE TABLE workflows(id INTEGER PRIMARY KEY, name TEXT,
kind TEXT CHECK(kind IN ('image', 'video')), workflow_json TEXT);
CREATE INDEX wf_name ON workflows(name);
CREATE TABLE jobs(id INTEGER PRIMARY KEY, workflow_id INTEGER REFERENCES workflows(id));
INSERT INTO workflows VALUES (9,'existing','image','{}');
INSERT INTO jobs VALUES(1,9);
""")
migrate_library(conn)
migrate_library(conn)
assert conn.execute("SELECT name FROM workflows WHERE id=9").fetchone()[0] == "existing"
assert conn.execute("SELECT workflow_id FROM jobs").fetchone()[0] == 9
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1
assert conn.execute("SELECT name FROM sqlite_master WHERE name='wf_name'").fetchone()
conn.execute("INSERT INTO workflows(id,kind) VALUES(10,'audio')")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("DELETE FROM workflows WHERE id=9")
def test_ui_conversion_golden_and_ambiguous_controls():
info = {
"Scalar": {
"input": {"required": {"text": ["STRING", {}]}},
"input_order": {"required": ["text"]},
"output": ["STRING"],
},
"Sink": {
"input": {"required": {"value": ["STRING", {"forceInput": True}]}},
"input_order": {"required": ["value"]},
"output": [],
},
}
ui = {
"nodes": [
{"id": 1, "type": "Scalar", "widgets_values": {"text": "hello"}},
{"id": 2, "type": "Sink", "inputs": [{"name": "value", "link": 7}]},
],
"links": [[7, 1, 0, 2, 0, "STRING"]],
}
result = convert(ui, info)
assert result.issues == []
assert result.prompt == {
"1": {"class_type": "Scalar", "inputs": {"text": "hello"}, "_meta": {"title": "Scalar"}},
"2": {"class_type": "Sink", "inputs": {"value": ["1", 0]}, "_meta": {"title": "Sink"}},
}
ui["nodes"][0]["widgets_values"] = ["hello", "randomize"]
assert convert(ui, info).prompt is None
assert any(i["code"] == "widget_layout_unverified" for i in convert(ui, info).issues)
ui["nodes"][0]["mode"] = 4
assert any(i["code"] == "mode_requires_export" for i in convert(ui, info).issues)
assert convert(ui).prompt is None
def test_snapshot_failure_is_not_conversion_evidence(tmp_path):
path = tmp_path / "snapshot.json"
path.write_text(
json.dumps(
{
"source_url": "http://localhost/object_info",
"captured_at": "now",
"status": "unavailable",
}
)
)
with pytest.raises(ValueError):
load_snapshot(path)
def test_audio_and_subtitle_harvest_only_mapped_nodes():
client = ComfyUIClient("http://unused")
history = {
"outputs": {
"1": {"audio": [{"filename": "speech.flac"}]},
"2": {"files": [{"filename": "字幕.srt"}], "text": ["not a file"]},
"3": {"audio": [{"filename": "hidden.wav"}]},
}
}
outputs = client.extract_outputs(history, {"1": "audio", "2": "file"})
assert [o["media_kind"] for o in outputs] == ["audio", "file"]
assert auto_output_map({"1": {"class_type": "SaveAudio", "inputs": {}}})[0]["kind"] == "audio"
assert client.extract_outputs({"outputs": {"1": {"audio": ["inline text", {}]}}}) == []
def test_catalogue_routes_cannot_enable_or_execute_placeholder(client, tmp_path):
root = tmp_path / "sources"
root.mkdir()
(root / "ui.json").write_text(json.dumps({"nodes": [{"id": 1, "type": "Missing"}]}))
conn = get_db(settings.db_path)
report = import_library(conn, root, source_commit="fixture")
wid = report["files"][0]["workflow_id"]
conn.close()
body = client.get(f"/api/workflows/{wid}").json()
assert body["doc_json"]["draft"] is True
assert body["import_issues"]
assert "raw_source_json" not in body
assert (
client.patch(
f"/api/workflows/{wid}", json={"usage": "utility", "is_enabled": True}
).status_code
== 400
)
assert (
client.patch(f"/api/workflows/{wid}", json={"usage": "utility"}).json()["is_enabled"]
is False
)
assert client.post(f"/api/workflows/{wid}/reanalyze").json()["is_enabled"] is False
assert client.post(f"/api/workflows/{wid}/check", json={}).status_code == 400
settings.dry_run = False
try:
assert (
client.post(f"/api/workflows/{wid}/test", json={"input_values": {}}).status_code == 400
)
finally:
settings.dry_run = True
@pytest.mark.parametrize(
"kind,key,filename,usage,ct",
[
("audio", "audio", "speech.flac", "text2speech", "SaveAudio"),
("file", "files", "subtitle.srt", "speech2text", "CustomSubtitleSaver"),
],
)
def test_audio_file_queue_download(client, fake_comfy, kind, key, filename, usage, ct):
from tests.test_studio_generation import PNG_1PX, _real_run
from tests.test_workflow_test import _wait_terminal
with _real_run(client, fake_comfy):
fake_comfy.forced_output_node = "9"
fake_comfy.outputs_key = key
fake_comfy.out_name = filename
workflow = {"9": {"class_type": ct, "inputs": {"filename_prefix": "test"}}}
response = client.post(
"/api/workflows",
json={
"name": filename,
"kind": kind,
"usage": usage,
"workflow_json": workflow,
"output_map": [{"nodeId": "9", "kind": kind}],
},
)
assert response.status_code == 200, response.text
wid = response.json()["id"]
response = client.post(f"/api/workflows/{wid}/test", json={"input_values": {}})
assert response.status_code == 200, response.text
job = _wait_terminal(client, response.json()["job"]["id"])
assert job["status"] == "done", job.get("error")
output = Path(job["output_paths"][0])
assert output.name == filename and output.read_bytes() == PNG_1PX
# This fixture verifies transport and persistence, not a real audio codec/subtitle.
assert any(path.startswith("/view?") for path in fake_comfy.requests)
def test_dedupe_preserves_separate_source_paths(client, tmp_path):
root = tmp_path / "sources"
for name in ("a", "b"):
(root / name).mkdir(parents=True)
(root / name / "same.json").write_text(json.dumps(API))
conn = get_db(settings.db_path)
import_library(conn, root, source_commit="fixture")
conn.close()
assert client.post("/api/workflows/dedupe").json()["removed"] == 0
assert len(client.get("/api/workflows").json()) == 2
def test_rebuild_preserves_autoincrement_watermark():
conn = sqlite3.connect(":memory:")
conn.executescript("""
CREATE TABLE workflows(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT,
kind TEXT CHECK(kind IN ('image', 'video')), workflow_json TEXT);
INSERT INTO workflows(id,kind) VALUES(100,'image');
DELETE FROM workflows;
""")
migrate_library(conn)
assert conn.execute("INSERT INTO workflows(kind) VALUES('audio')").lastrowid == 101
def test_document_dependencies_do_not_treat_notes_or_editor_urls_as_models():
from calliope.comfyui.library import build_document
source = {
"nodes": [
{"id": 1, "type": "Loader", "widgets_values": ["actual.safetensors"]},
{"id": 2, "type": "Note", "widgets_values": ["example.safetensors"]},
],
"properties": {"url": "https://example.com/download.safetensors"},
}
doc = build_document("example/test.json", source, None, None, [])
assert doc["dependencies"]["models"] == ["actual.safetensors"]
assert doc["source_notes"][0]["content"] == ["example.safetensors"]
@@ -0,0 +1,272 @@
"""L2 连接核查(HEL-643 批次 1):导入/复查时用目标 ComfyUI 的 /object_info
预检节点与模型可用性。全部基于 fake ComfyUI 的脚本化 /object_info,
不针对任何固定 JSON 或模型名写死判定。"""
from __future__ import annotations
import json
import socket
from pathlib import Path
import pytest
from calliope.comfyui.client import clear_object_info_cache
from calliope.comfyui.preflight import check_workflow_against
from calliope.config import settings
from tests.test_studio_generation import _url
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
def _load_05() -> dict:
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
def build_object_info(workflow: dict) -> dict:
"""A catalogue where every widget value the JSON carries is legal.
String widgets become combos whose candidates include the workflow's own
value (merged across nodes of the same class); numbers/booleans are typed
inputs; wired inputs are link types — none of those can mismatch.
"""
info: dict = {}
for node in workflow.values():
if not isinstance(node, dict):
continue
ct = str(node.get("class_type") or "")
if not ct:
continue
spec = info.setdefault(ct, {"input": {"required": {}}, "output": ["*"]})
required = spec["input"]["required"]
for name, val in (node.get("inputs") or {}).items():
if isinstance(val, str):
# Merge: two nodes of one class with different values → both legal.
if name in required and isinstance(required[name][0], list):
if val not in required[name][0]:
required[name][0] = [*required[name][0], val]
continue
required[name] = [[val], {}]
elif name in required:
continue
elif isinstance(val, list):
required[name] = ["*", {}]
elif isinstance(val, bool):
required[name] = ["BOOLEAN", {}]
elif isinstance(val, (int, float)):
required[name] = ["NUMBER", {}]
return info
@pytest.fixture(autouse=True)
def _fresh_cache():
clear_object_info_cache()
yield
clear_object_info_cache()
@pytest.fixture
def endpoint_at(client, fake_comfy): # noqa: F811
"""Register a connection pointing at the fake server; yields its id."""
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
prev_active = settings.comfyui_active_id
r = client.post(
"/api/comfyui/endpoints",
json={"name": "预检桩", "base_url": _url(fake_comfy), "auth_type": "none"},
)
assert r.status_code == 200, r.text
endpoints = r.json()["endpoints"]
ep = next(e for e in endpoints if e["base_url"] == _url(fake_comfy))
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["base_url"] == _url(fake_comfy)]
settings.comfyui_active_id = ep["id"]
settings.apply_active_comfyui()
try:
yield ep["id"]
finally:
settings.comfyui_endpoints = prev_eps
settings.comfyui_active_id = prev_active
settings.apply_active_comfyui()
# ---------------------------------------------------------------------------
# Unit rules
# ---------------------------------------------------------------------------
def test_unit_missing_node_reported_with_node_context():
wf = {"900": {"class_type": "H3SLAAttention", "inputs": {"enabled": True},
"_meta": {"title": "H3 SLA Attention"}}}
findings = check_workflow_against({"OtherNode": {}}, wf)
assert len(findings) == 1
f = findings[0]
assert f["code"] == "missing_node" and f["level"] == "error"
msg = f["message"]
assert "H3SLAAttention" in msg and "900" in msg and "H3 SLA Attention" in msg
assert "重新检查" in f["hint"]
def test_unit_combo_value_not_in_list():
wf = {"127": {"class_type": "UNETLoader",
"inputs": {"unet_name": "missing_model.safetensors", "weight_dtype": "default"}}}
info = {"UNETLoader": {"input": {"required": {
"unet_name": [["installed_model.safetensors"], {}],
"weight_dtype": [["default", "fp8"], {"upload": False}],
}}, "output": ["MODEL"]}}
findings = check_workflow_against(info, wf)
assert len(findings) == 1
f = findings[0]
assert f["code"] == "value_not_in_list" and f["node_id"] == "127"
assert "missing_model.safetensors" in f["message"] and "unet_name" in f["message"]
assert "模型文件" in f["message"]
assert "installed_model.safetensors" in f["hint"]
def test_unit_upload_backed_and_wired_inputs_skipped():
wf = {
"1": {"class_type": "LoadImage", "inputs": {"image": "placeholder.png"}},
"2": {"class_type": "LoadVideo", "inputs": {"file": "clip.mp4"}},
"3": {"class_type": "LoadAudio", "inputs": {"audio": "voice.wav"}},
"4": {"class_type": "KSampler", "inputs": {"seed": 7, "model": ["9", 0]}},
}
info = {
"LoadImage": {"input": {"required": {"image": [["other.png"], {"image_upload": True}]}},
"output": ["IMAGE"]},
"LoadVideo": {"input": {"required": {"file": [[], {}]}}, "output": ["IMAGE"]},
"LoadAudio": {"input": {"required": {"audio": [[], {}]}}, "output": ["AUDIO"]},
"KSampler": {"input": {"required": {"seed": ["INT", {}], "model": ["MODEL", {}]}},
"output": ["IMAGE"]},
}
assert check_workflow_against(info, wf) == []
# ---------------------------------------------------------------------------
# Analyze with a target connection
# ---------------------------------------------------------------------------
def test_analyze_all_green_against_fake_object_info(client, endpoint_at, fake_comfy):
fake_comfy.object_info = build_object_info(_load_05())
r = client.post(
"/api/workflows/analyze",
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
)
assert r.status_code == 200, r.text
check = r.json()["connection_check"]
assert check["reachable"] is True and check["ok"] is True
assert check["findings"] == []
def test_analyze_reports_missing_custom_node(client, endpoint_at, fake_comfy):
info = build_object_info(_load_05())
info.pop("H3SLAAttention")
fake_comfy.object_info = info
r = client.post(
"/api/workflows/analyze",
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
)
check = r.json()["connection_check"]
assert check["ok"] is False
missing = [f for f in check["findings"] if f["code"] == "missing_node"]
assert missing and "H3SLAAttention" in missing[0]["message"]
assert "900" in missing[0]["message"]
def test_analyze_reports_missing_model_value(client, endpoint_at, fake_comfy):
info = build_object_info(_load_05())
# The server has a DIFFERENT unet installed than the one the JSON names.
info["UNETLoader"]["input"]["required"]["unet_name"] = [["another_unet.safetensors"], {}]
fake_comfy.object_info = info
r = client.post(
"/api/workflows/analyze",
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
)
check = r.json()["connection_check"]
assert check["ok"] is False
bad = [f for f in check["findings"] if f["code"] == "value_not_in_list"]
assert bad and bad[0]["node_id"] == "127"
assert "minimax_h3_fused_refdelta" in bad[0]["message"]
def test_unreachable_connection_degrades_to_unchecked(client):
# A port that nothing listens on: the endpoint resolves, the server doesn't.
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
prev_active = settings.comfyui_active_id
r = client.post(
"/api/comfyui/endpoints",
json={"name": "断连桩", "base_url": f"http://127.0.0.1:{port}", "auth_type": "none"},
)
endpoints = r.json()["endpoints"]
ep = next(e for e in endpoints if e["base_url"] == f"http://127.0.0.1:{port}")
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["id"] == ep["id"]]
settings.comfyui_active_id = ep["id"]
settings.apply_active_comfyui()
try:
r = client.post(
"/api/workflows/analyze",
json={"workflow_json": _load_05(), "endpoint_id": ep["id"]},
)
check = r.json()["connection_check"]
assert check["reachable"] is False and check["ok"] is False
assert "无法核查" in check["message"]
assert check["findings"] == []
finally:
settings.comfyui_endpoints = prev_eps
settings.comfyui_active_id = prev_active
settings.apply_active_comfyui()
# ---------------------------------------------------------------------------
# Saved-workflow re-check + cache
# ---------------------------------------------------------------------------
def _create_workflow(client, workflow: dict, endpoint_id: str | None = None) -> int:
r = client.post(
"/api/workflows",
json={
"name": "预检工作流",
"workflow_json": workflow,
"usage": "ref2video",
**({"endpoint_id": endpoint_id} if endpoint_id else {}),
},
)
assert r.status_code == 200, r.text
return r.json()["id"]
def test_saved_workflow_recheck_and_refresh(client, endpoint_at, fake_comfy):
wf_id = _create_workflow(client, _load_05(), endpoint_at)
info = build_object_info(_load_05())
info.pop("UNETLoader")
fake_comfy.object_info = info
r = client.post(f"/api/workflows/{wf_id}/check", json={})
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is False and body["endpoint_id"] == endpoint_at
assert any(f["code"] == "missing_node" for f in body["findings"])
# User installs the node package, comes back and re-checks with refresh.
fake_comfy.object_info = build_object_info(_load_05())
r = client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
body = r.json()
assert body["ok"] is True and body["findings"] == []
def test_object_info_cached_within_ttl(client, endpoint_at, fake_comfy):
fake_comfy.object_info = build_object_info(_load_05())
for _ in range(2):
r = client.post(
"/api/workflows/analyze",
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
)
assert r.json()["connection_check"]["ok"] is True
hits = [p for p in fake_comfy.requests if p == "/object_info"]
assert len(hits) == 1 # second call served from the 5-minute cache
wf_id = _create_workflow(client, _load_05())
client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
hits = [p for p in fake_comfy.requests if p == "/object_info"]
assert len(hits) == 2 # manual refresh bypasses the TTL
@@ -0,0 +1,197 @@
"""「测试工作流」(HEL-643 批次 2)端到端:真实队列 → fake ComfyUI → 产物隔离。
测试任务与普通任务区分(jobs.origin)、dry-run 拒绝、幂等复用、
保留策略与级联清理,全部对着 fake ComfyUI 跑真实链路。
"""
from __future__ import annotations
import time
from pathlib import Path
from calliope.config import settings
from calliope.queue.workflow_test import workflow_test_dir
from tests.test_studio_generation import (
IMAGE_WORKFLOW,
PNG_1PX,
_real_run,
)
def _wait_terminal(client, job_id: int, timeout: float = 25.0) -> dict:
deadline = time.time() + timeout
while time.time() < deadline:
r = client.get(f"/api/jobs/{job_id}")
assert r.status_code == 200, r.text
job = r.json()
if job["status"] not in ("pending", "running"):
return job
time.sleep(0.4)
raise AssertionError(f"test job {job_id} did not finish in {timeout}s")
def _create_workflow(client, workflow: dict, name: str = "可测工作流") -> int:
r = client.post(
"/api/workflows",
json={"name": name, "workflow_json": workflow, "usage": "text2image"},
)
assert r.status_code == 200, r.text
return r.json()["id"]
def test_passing_test_downloads_real_output_to_scratch_dir(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="通过用例")
r = client.post(
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "测试提示词"}}
)
assert r.status_code == 200, r.text
body = r.json()
assert body["reused"] is False
job = _wait_terminal(client, body["job"]["id"])
assert job["status"] == "done", job.get("error")
assert job["origin"] == "workflow_test"
assert job["payload"]["source"] == "workflow_test"
assert job["phase"] == "finished"
# 真实拿到可下载输出才算通过:产物落在独立 scratch 目录
assert len(job["output_paths"]) >= 1
out = Path(job["output_paths"][0])
assert out.is_file() and out.read_bytes() == PNG_1PX
assert workflow_test_dir(wf_id) in out.parents
# 提交给 fake ComfyUI 的是补丁后的真实提示词
queued = fake_comfy.prompts[-1]["prompt"]
assert queued["1"]["inputs"]["text"] == "测试提示词"
def test_tasks_list_shows_origin_but_playground_history_hides_it(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="可见性用例")
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
job_id = r.json()["job"]["id"]
_wait_terminal(client, job_id)
listed = client.get("/api/jobs?limit=50").json()
mine = [j for j in listed if j["id"] == job_id]
assert mine and mine[0]["origin"] == "workflow_test"
pg = client.get("/api/playground/jobs").json()
assert all(j["id"] != job_id for j in pg), "test jobs must stay out of studio history"
def test_failed_prompt_reports_chinese_reason(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="排队被拒用例")
fake_comfy.fail_prompt = True
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
job = _wait_terminal(client, r.json()["job"]["id"])
assert job["status"] == "failed"
assert "拒绝执行" in (job["error"] or "")
def test_no_output_does_not_pass(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="无输出用例")
fake_comfy.drop_outputs = True
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
job = _wait_terminal(client, r.json()["job"]["id"])
assert job["status"] == "failed"
assert "没有返回任何图片、视频、音频或文件" in (job["error"] or "")
def test_dry_run_setting_refuses_real_test(client, fake_comfy):
with _real_run(client, fake_comfy):
settings.dry_run = True # conftest default; _real_run clears it — set again
try:
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="dry 拒绝用例")
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
assert r.status_code == 400
assert "Dry-run" in r.json()["detail"]
finally:
settings.dry_run = False
def test_repeat_click_reuses_inflight_job(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="幂等用例")
assert client.post("/api/jobs/pause").status_code == 200
try:
r1 = client.post(
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "一次就好"}}
)
assert r1.status_code == 200 and r1.json()["reused"] is False
r2 = client.post(
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "重复点击"}}
)
assert r2.status_code == 200
assert r2.json()["reused"] is True
assert r2.json()["job"]["id"] == r1.json()["job"]["id"]
finally:
client.post("/api/jobs/resume")
client.post(f"/api/jobs/{r1.json()['job']['id']}/cancel")
_wait_terminal(client, r1.json()["job"]["id"])
def test_retention_keeps_newest_records_and_prunes_artifacts(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="保留策略用例")
for i in range(7):
fake_comfy.out_name = f"t{i}.png"
r = client.post(
f"/api/workflows/{wf_id}/test",
json={"input_values": {"1": f"第{i}次"}},
)
_wait_terminal(client, r.json()["job"]["id"])
rows = client.get(f"/api/workflows/{wf_id}/tests").json()
assert len(rows) <= 5
kept_ids = {row["id"] for row in rows}
# 被清掉的旧记录其产物文件也一并删除
leftovers = [p for p in workflow_test_dir(wf_id).iterdir() if p.suffix == ".png"]
assert all(p.stem.lstrip("t").isdigit() and int(p.stem[1:]) >= 2 for p in leftovers)
assert len(kept_ids) == 5
def test_clear_tests_and_workflow_delete_cascade(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="清理用例")
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
_wait_terminal(client, r.json()["job"]["id"])
assert workflow_test_dir(wf_id).is_dir()
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
assert r.status_code == 200 and r.json()["removed"] >= 1
assert not workflow_test_dir(wf_id).exists()
assert client.get(f"/api/workflows/{wf_id}/tests").json() == []
# 进行中的测试:先拒绝清除,取消后删除工作流级联清理
assert client.post("/api/jobs/pause").status_code == 200
try:
job_id = client.post(
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}}
).json()["job"]["id"]
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
assert r.status_code == 409
finally:
client.post("/api/jobs/resume")
client.post(f"/api/jobs/{job_id}/cancel")
_wait_terminal(client, job_id)
# 工作流列表带出「上次测试」实时状态
listed = client.get("/api/workflows").json()
mine = [w for w in listed if w["id"] == wf_id][0]
assert mine["last_test"]["id"] == job_id
assert client.delete(f"/api/workflows/{wf_id}").status_code == 200
assert not workflow_test_dir(wf_id).exists()
assert client.get(f"/api/workflows/{wf_id}/tests").status_code == 404
def test_last_test_summary_live_on_list(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_a = _create_workflow(client, IMAGE_WORKFLOW, name="A")
wf_b = _create_workflow(client, IMAGE_WORKFLOW, name="B")
r = client.post(f"/api/workflows/{wf_a}/test", json={"input_values": {"1": "p"}})
done = _wait_terminal(client, r.json()["job"]["id"])
listed = {w["id"]: w for w in client.get("/api/workflows").json()}
assert listed[wf_a]["last_test"]["status"] == "done"
assert listed[wf_a]["last_test"]["output_count"] == len(done["output_paths"])
assert listed[wf_b]["last_test"] is None
+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.");
+43 -2
View File
@@ -138,6 +138,8 @@ import type {
ComfyDynamicInput,
ComfyDynamicOutput,
WorkflowOutputMapEntry,
WorkflowConnectionCheck,
WorkflowLastTest,
} from "./comfy/types";
export type {
Job,
@@ -150,6 +152,8 @@ export type {
ComfyDynamicInput,
ComfyDynamicOutput,
WorkflowOutputMapEntry,
WorkflowConnectionCheck,
WorkflowLastTest,
};
async function api<T>(path: string, init?: RequestInit): Promise<T> {
@@ -458,13 +462,50 @@ export const comfy = {
export const workflows = {
list: () => api<Workflow[]>("/api/workflows"),
analyze: (workflow_json: Record<string, unknown>) =>
analyze: (
workflow_json: Record<string, unknown>,
endpoint_id?: string | null,
refresh = false,
) =>
api<WorkflowAnalyzeResult>("/api/workflows/analyze", {
method: "POST",
body: JSON.stringify({ workflow_json }),
body: JSON.stringify({
workflow_json,
...(endpoint_id ? { endpoint_id } : {}),
...(refresh ? { refresh } : {}),
}),
}),
reanalyze: (id: number) =>
api<Workflow>(`/api/workflows/${id}/reanalyze`, { method: "POST" }),
/** L2 re-check against a live ComfyUI (HEL-643): node/model availability. */
check: (
id: number,
payload?: { endpoint_id?: string | null; refresh?: boolean },
) =>
api<WorkflowConnectionCheck>(`/api/workflows/${id}/check`, {
method: "POST",
body: JSON.stringify(payload ?? {}),
}),
/** Launch (or re-attach to) one REAL test run (HEL-643 L3). */
test: (
id: number,
payload: {
endpoint_id?: string | null;
input_values?: Record<string, unknown>;
},
) =>
api<{ ok: boolean; job: Job; reused: boolean }>(
`/api/workflows/${id}/test`,
{
method: "POST",
body: JSON.stringify(payload),
},
),
tests: (id: number) => api<Job[]>(`/api/workflows/${id}/tests`),
clearTests: (id: number) =>
api<{ ok: boolean; removed: number }>(`/api/workflows/${id}/clear-tests`, {
method: "POST",
}),
create: (payload: {
name: string;
workflow_json: Record<string, unknown>;
+59 -13
View File
@@ -1,26 +1,72 @@
import { t } from '$lib/i18n.svelte';
import { t } from "$lib/i18n.svelte";
/** Studio image slots (ref-upload-spec §3): PNG / JPG / WebP, ≤ 20MB each. */
export const IMAGE_SLOT_ACCEPT = '.png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp';
export const IMAGE_SLOT_ACCEPT =
".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp";
export const IMAGE_SLOT_MAX_BYTES = 20 * 1024 * 1024;
const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
const IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp"];
/** Reference video slots (HEL-643): MP4 / WebM / MOV, ≤ 500MB (upload cap). */
export const VIDEO_SLOT_ACCEPT =
"video/mp4,video/webm,video/quicktime,.mp4,.webm,.mov";
export const VIDEO_SLOT_MAX_BYTES = 500 * 1024 * 1024;
const VIDEO_EXTS = [".mp4", ".webm", ".mov"];
/** Reference audio slots (HEL-643): MP3 / WAV / M4A, ≤ 100MB. */
export const AUDIO_SLOT_ACCEPT =
"audio/mpeg,audio/wav,audio/mp4,.mp3,.wav,.m4a";
export const AUDIO_SLOT_MAX_BYTES = 100 * 1024 * 1024;
const AUDIO_EXTS = [".mp3", ".wav", ".m4a"];
export type MediaSlotKind = "image" | "video" | "audio";
export function mediaSlotAccept(kind: MediaSlotKind): string {
if (kind === "video") return VIDEO_SLOT_ACCEPT;
if (kind === "audio") return AUDIO_SLOT_ACCEPT;
return IMAGE_SLOT_ACCEPT;
}
/** Why this file cannot fill a media slot of this kind (null = fine). */
export function mediaFileProblem(
kind: MediaSlotKind,
file: { name: string; size: number },
): string | null {
const dot = file.name.lastIndexOf(".");
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : "";
const max =
kind === "video"
? VIDEO_SLOT_MAX_BYTES
: kind === "audio"
? AUDIO_SLOT_MAX_BYTES
: IMAGE_SLOT_MAX_BYTES;
if (kind === "video" && !VIDEO_EXTS.includes(ext))
return t("studio.errVideoFormat", { name: file.name });
if (kind === "audio" && !AUDIO_EXTS.includes(ext))
return t("studio.errAudioFormat", { name: file.name });
if (kind === "image" && !IMAGE_EXTS.includes(ext))
return t("studio.errImageFormat", { name: file.name });
if (file.size > max)
return kind === "image"
? t("studio.errImageSize", { name: file.name })
: t("studio.errMediaSize", { name: file.name });
return null;
}
/** Why this file cannot fill an image slot (null = fine). */
export function imageFileProblem(file: { name: string; size: number }): string | null {
const dot = file.name.lastIndexOf('.');
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : '';
if (!IMAGE_EXTS.includes(ext)) return t('studio.errImageFormat', { name: file.name });
if (file.size > IMAGE_SLOT_MAX_BYTES) return t('studio.errImageSize', { name: file.name });
return null;
export function imageFileProblem(file: {
name: string;
size: number;
}): string | null {
return mediaFileProblem("image", file);
}
export function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
/** Display name of an uploaded path (strips the uuid8- prefix the upload endpoint writes). */
export function uploadBaseName(p: string): string {
const raw = p.split(/[/\\]/).pop() ?? p;
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
const raw = p.split(/[/\\]/).pop() ?? p;
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
}
+107 -1
View File
@@ -8,7 +8,10 @@ export type ComfyInputKind =
| 'audio'
| 'video'
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
| 'image_list';
| 'image_list'
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
| 'video_list'
| 'audio_list';
export type ComfyOutputKind = 'image' | 'video' | 'other';
/** Structural finding on an imported workflow; `error` blocks generation. */
@@ -38,6 +41,8 @@ export interface ComfyDynamicInput {
options?: string[];
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
resolution?: { megapixels: string; multiple: number };
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
media_hint?: string;
}
export interface ComfyDynamicOutput {
@@ -84,6 +89,40 @@ export const USAGE_BY_KIND: Record<'image' | 'video', WorkflowUsage[]> = {
video: ['text2video', 'image2video', 'ref2video'],
};
/** One L2 connection-check finding against a live ComfyUI (HEL-643). */
export interface PreflightFinding {
level: 'error' | 'warn' | 'info' | 'ok';
code: string;
node_id?: string;
node_title?: string | null;
message: string;
hint?: string | null;
}
/** Result of checking a workflow against a target connection's /object_info. */
export interface WorkflowConnectionCheck {
endpoint_id: string | null;
endpoint_name: string | null;
base_url?: string | null;
/** False when any error-level finding exists (or the server was unreachable). */
ok: boolean;
/** False → 无法核查 (server down): honest, non-blocking. */
reachable: boolean;
findings: PreflightFinding[];
message?: string | null;
}
/** Slim live record of a workflow's latest test run (HEL-643). */
export interface WorkflowLastTest {
id: number;
status: string;
phase: string | null;
error: string | null;
created_at: string;
completed_at: string | null;
output_count: number;
}
/** Result of POST /api/workflows/analyze (import confirm step 1). */
export interface WorkflowAnalyzeResult {
inputs: ComfyDynamicInput[];
@@ -101,6 +140,8 @@ export interface WorkflowAnalyzeResult {
summary: string[];
};
usable_usage: WorkflowUsage[];
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
connection_check?: WorkflowConnectionCheck;
}
export interface WorkflowNode {
@@ -109,9 +150,70 @@ export interface WorkflowNode {
_meta?: { title?: string };
}
/** Persisted library document; list responses omit doc_json, detail GET includes it. */
export interface WorkflowDocument {
title?: string;
purpose?: string;
source_path?: string;
materials?: { slot?: string; desc?: string; required?: boolean }[];
io?: { inputs?: ComfyDynamicInput[]; outputs?: ComfyDynamicOutput[] };
dependencies?: {
models?: string[];
node_types?: string[];
node_packs?: string[];
check_state?: string;
checked_at?: string | null;
source?: string | null;
model_locations?: string;
missing?: unknown[];
};
params?: {
node_id: string;
field: string;
name?: string;
default?: unknown;
unit?: string | null;
min?: number | null;
max?: number | null;
options?: unknown[] | null;
range_source?: string;
effect?: string;
}[];
steps?: string[];
errors?: { symptom: string; fix: string }[];
status?: { imported?: boolean; doc_done?: boolean; exec_verified?: boolean };
source_notes?: { node_id: string; content: unknown }[];
source_review?: {
scope?: string[];
unconfirmed?: string[];
reviewed_named_fields?: {
node_id: string;
field: string;
label?: string;
category?: string;
source_value?: unknown;
scope?: string;
operation_check?: string;
connections?: unknown;
}[];
semantic_review?: {
conclusions?: string[];
material_requirements?: unknown;
[key: string]: unknown;
};
};
}
export interface Workflow {
id: number;
name: string;
doc_json?: WorkflowDocument | null;
import_state?: string | null;
source_format?: string | null;
source_repo_path?: string | null;
import_issues?: { message: string; code?: string; node_id?: string }[] | null;
doc_completed_at?: string | null;
exec_verified_at?: string | null;
kind: 'image' | 'video';
/** Five-way usage (HEL-636); null = pending human confirmation. */
usage?: WorkflowUsage | null;
@@ -130,6 +232,8 @@ export interface Workflow {
output_map_source?: 'auto' | 'user' | null;
/** True when the graph has unknown output nodes needing designation. */
needs_mapping?: boolean;
/** Latest test-run record, queried live (HEL-643); null = never tested. */
last_test?: WorkflowLastTest | null;
}
export interface Job {
@@ -152,6 +256,8 @@ export interface Job {
comfy_prompt_id?: string | null;
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
phase?: string | null;
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
origin?: string;
}
export interface SceneVideoSettings {
@@ -1,614 +0,0 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { jobsApi, type Job } from '$lib/api';
import type { CalliopeEvent, EventConnectionState } from '$lib/events';
import { t } from '$lib/i18n.svelte';
import JobRow from './JobRow.svelte';
import Button from './ui/Button.svelte';
import Icon from './ui/Icon.svelte';
interface Props {
logs: CalliopeEvent[];
projectId: number;
/** Real SSE connection state from events.ts — drives the live pip. */
connState: EventConnectionState;
collapsed?: boolean;
onToggle?: () => void;
}
let { logs, projectId, connState, collapsed = false, onToggle }: Props = $props();
type Tone = 'info' | 'ok' | 'warn' | 'err' | 'work';
type LogEntry = {
key: string;
ts: string;
title: string;
detail: string;
tone: Tone;
type: string;
};
function str(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return '';
}
function formatEvent(ev: CalliopeEvent, index: number): LogEntry | null {
// Poll spam — progress lives in the jobProgress store, not the log
if (ev.type === 'job.progress') return null;
const d = ev.data ?? {};
const msg = str(d.message).trim();
const kind = str(d.kind);
const jobId = d.job_id != null ? `#${d.job_id}` : '';
const err = str(d.error).trim();
const paths = Array.isArray(d.paths) ? d.paths.length : 0;
const outputs = Array.isArray(d.outputs) ? d.outputs.length : 0;
const time = ev.ts?.includes('T') ? ev.ts.slice(11, 19) : ev.ts?.slice(0, 8) || '';
let title = ev.type;
let detail = msg;
let tone: Tone = 'info';
switch (ev.type) {
case 'agent.thinking':
title = t('activity.agentWorking');
detail = msg || t('activity.working');
tone = 'work';
break;
case 'story.ready':
title = t('activity.storyReady');
detail = msg || t('activity.storylineDrafted');
tone = 'ok';
break;
case 'job.created':
title = t('activity.queued');
detail = msg || [kind, jobId].filter(Boolean).join(' ');
tone = 'info';
break;
case 'job.started':
title = t('activity.running');
detail = msg || [kind, jobId].filter(Boolean).join(' ');
tone = 'work';
break;
case 'job.completed':
title = t('activity.finished');
detail =
msg ||
[kind, jobId, outputs ? t('activity.files', { count: outputs }) : '']
.filter(Boolean)
.join(' · ');
tone = 'ok';
break;
case 'job.failed':
title = t('activity.failed');
detail = err || msg || [kind, jobId].filter(Boolean).join(' ');
tone = 'err';
break;
case 'asset.ready':
title = t('activity.assetReady');
detail = msg || (paths ? t('activity.filesSaved', { count: paths }) : t('activity.outputSaved'));
tone = 'ok';
break;
case 'job.deleted':
title = t('activity.removed');
detail = msg || jobId || t('activity.jobDeleted');
tone = 'warn';
break;
default:
title = ev.type.replace(/\./g, ' · ');
detail = msg || err || kind;
tone = 'info';
}
return {
key: `${ev.ts}-${ev.type}-${index}`,
ts: time,
title,
detail,
tone,
type: ev.type,
};
}
const entries = $derived.by(() => {
const raw = logs
.slice(-120)
.map((ev, i) => formatEvent(ev, i))
.filter((e): e is LogEntry => e != null);
// Collapse consecutive identical agent lines
const collapsed: LogEntry[] = [];
for (const e of raw) {
const prev = collapsed[collapsed.length - 1];
if (prev && prev.type === e.type && prev.detail === e.detail) {
continue;
}
collapsed.push(e);
}
return collapsed.slice(-60).reverse();
});
// Same query key as QueueStage — refreshed by SSE invalidation from the
// project page (job.* events), not by polling.
const jobsQuery = createQuery(
toStore(() => ({
queryKey: ['jobs', projectId],
queryFn: () => jobsApi.list(projectId),
})),
);
const allJobs = $derived(($jobsQuery.data ?? []) as Job[]);
function activeWeight(job: Job): number {
if (job.status === 'running') return 0;
if (job.status === 'pending') return 1;
return 2;
}
// Active jobs first, then most recent — capped so the log keeps room.
const queueRows = $derived(
[...allJobs].sort((a, b) => activeWeight(a) - activeWeight(b) || b.id - a.id).slice(0, 6),
);
const stats = $derived({
running: allJobs.filter((j) => j.status === 'running').length,
queued: allJobs.filter((j) => j.status === 'pending').length,
done: allJobs.filter((j) => j.status === 'done').length,
failed: allJobs.filter((j) => j.status === 'failed').length,
});
const busyCount = $derived(stats.running + stats.queued);
const connected = $derived(connState === 'open');
</script>
<aside class="activity" class:collapsed aria-label={t('activity.title')}>
{#if collapsed}
<button
type="button"
class="rail-toggle"
onclick={onToggle}
aria-expanded="false"
title={t('activity.show')}
>
<span class="pip rail-pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
{#if busyCount > 0}
<span class="rail-count" title={t('activity.busyCount', { count: busyCount })}>{busyCount}</span>
{/if}
<span class="rail-label">{t('activity.title')}</span>
<span class="rail-chevron" aria-hidden="true">‹</span>
</button>
{:else}
<header class="head">
<div class="head-title">
<span class="pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
<strong>{t('activity.title')}</strong>
{#if connState === 'reconnecting'}
<span class="conn-state warn">{t('activity.reconnecting')}</span>
{:else if connState === 'connecting'}
<span class="conn-state">{t('activity.connecting')}</span>
{/if}
</div>
<Button variant="ghost" size="sm" onclick={onToggle} title={t('activity.hide')}>
{t('activity.hide')} <Icon name="chevron-right" size={13} />
</Button>
</header>
<div class="body">
<section class="queue" aria-label={t('activity.queue')}>
<div class="section-head">
<p class="eyebrow">{t('activity.queue')}</p>
</div>
{#if queueRows.length === 0}
<p class="queue-empty">{t('activity.queueIdle')}</p>
{:else}
<div class="job-list">
{#each queueRows as job (job.id)}
<JobRow {job} />
{/each}
</div>
{/if}
<div class="stats-strip" aria-label={t('activity.queueStats')}>
<span><span class="sd sd-running" aria-hidden="true"></span>{stats.running} {t('activity.running').toLowerCase()}</span>
<span><span class="sd sd-queued" aria-hidden="true"></span>{stats.queued} {t('activity.queued').toLowerCase()}</span>
<span><span class="sd sd-done" aria-hidden="true"></span>{stats.done} {t('activity.done')}</span>
<span><span class="sd sd-failed" aria-hidden="true"></span>{stats.failed} {t('activity.failed').toLowerCase()}</span>
</div>
</section>
<section class="log-section" aria-label={t('activity.agentLog')}>
<p class="eyebrow">{t('activity.agentLog')}</p>
{#if entries.length === 0}
<div class="empty">
<p>{t('activity.quiet')}</p>
<p class="hint">{t('activity.quietHint')}</p>
</div>
{:else}
<ul class="log">
{#each entries as ev (ev.key)}
<li class="entry tone-{ev.tone}">
<span class="dot" aria-hidden="true"></span>
<div class="meta">
<div class="row">
<span class="title">{ev.title}</span>
<time class="ts" datetime={ev.ts}>{ev.ts}</time>
</div>
{#if ev.detail}
<p class="detail">{ev.detail}</p>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</section>
</div>
{/if}
</aside>
<style>
.activity {
--panel-w: 320px;
--rail-w: 44px;
width: var(--panel-w);
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
border-left: 1px solid var(--border);
background:
linear-gradient(180deg, rgba(139, 92, 246, 0.06) 0%, transparent 120px),
var(--bg-surface);
overflow: hidden;
transition: width 0.2s ease;
}
.activity.collapsed {
width: var(--rail-w);
background: var(--bg-surface);
}
.rail-toggle {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 12px;
width: 100%;
height: 100%;
margin: 0;
padding: 16px 0;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
overflow: hidden;
}
.rail-toggle:hover {
background: rgba(139, 92, 246, 0.08);
color: var(--text-primary);
}
.rail-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.rail-label {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-family: var(--font-display);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.rail-chevron {
font-size: 18px;
line-height: 1;
color: var(--text-muted);
}
.rail-count {
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
}
.pip {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.pip.live {
background: var(--accent);
box-shadow: 0 0 0 4px var(--accent-glow);
animation: live-pulse 1.6s ease-in-out infinite;
}
.pip.reconnecting {
background: var(--warning);
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.18);
}
@keyframes live-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.65;
transform: scale(0.92);
}
}
@media (prefers-reduced-motion: reduce) {
.activity {
transition: none;
}
.pip.live {
animation: none;
}
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 14px 10px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.head-title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.head-title strong {
font-family: var(--font-display);
font-size: 15px;
font-weight: 650;
letter-spacing: -0.02em;
}
.conn-state {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
.conn-state.warn {
color: var(--warning);
}
.body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 12px 10px 16px;
overflow: hidden;
}
.queue {
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
margin-bottom: 14px;
}
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-shrink: 0;
}
.job-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 216px;
overflow-y: auto;
padding-right: 4px;
}
.queue-empty {
margin: 0 6px 4px;
padding: 12px 14px;
border: 1px dashed var(--border);
border-radius: var(--radius-md);
background: rgba(0, 0, 0, 0.2);
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
.stats-strip {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin: 10px 6px 0;
padding-top: 10px;
border-top: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
flex-shrink: 0;
}
.stats-strip > span {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.sd {
width: 6px;
height: 6px;
border-radius: 50%;
}
.sd-running {
background: var(--info);
}
.sd-queued {
background: var(--text-muted);
}
.sd-done {
background: var(--success);
}
.sd-failed {
background: var(--error);
}
.log-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.eyebrow {
margin: 0 6px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
flex-shrink: 0;
}
.empty {
margin: 8px 6px;
padding: 16px;
border: 1px dashed var(--border);
border-radius: var(--radius-md);
background: rgba(0, 0, 0, 0.2);
}
.empty p {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
.empty .hint {
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.45;
}
.log {
list-style: none;
margin: 0;
padding: 0 4px 8px 0;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.entry {
display: grid;
grid-template-columns: 14px 1fr;
gap: 8px;
padding: 8px 8px 8px 4px;
border-radius: var(--radius-sm);
}
.entry:hover {
background: rgba(255, 255, 255, 0.03);
}
.dot {
width: 8px;
height: 8px;
margin-top: 5px;
border-radius: 50%;
background: var(--text-muted);
justify-self: center;
}
.tone-ok .dot {
background: var(--success);
}
.tone-err .dot {
background: var(--error);
}
.tone-warn .dot {
background: var(--warning);
}
.tone-work .dot {
background: var(--accent);
}
.tone-info .dot {
background: var(--info);
}
.meta {
min-width: 0;
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.title {
font-size: 12px;
font-weight: 650;
color: var(--text-primary);
}
.ts {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
flex-shrink: 0;
}
.detail {
margin: 3px 0 0;
font-size: 12px;
line-height: 1.4;
color: var(--text-secondary);
word-break: break-word;
}
.tone-err .detail {
color: #fca5a5;
}
</style>
@@ -15,8 +15,8 @@ interface Props {
<header class="header">
<div class="header-left">
<a class="logo" href="/canvas" title="Calliope Lab">
Calliope <span>Lab</span>
<a class="logo" href="/" title={t('shell.brand')}>
{t('shell.brand')}
</a>
{#if crumb}
<span class="crumb">{crumb}</span>
@@ -28,7 +28,7 @@ interface Props {
{/if}
<nav class="top-nav" aria-label={t('nav.primary')}>
<a class="nav-link" class:active={active === 'canvas'} href="/canvas">{t('nav.canvas')}</a>
<a class="nav-link" class:active={active === 'projects'} href="/projects">{t('nav.projects')}</a>
<a class="nav-link" class:active={active === 'projects'} href="/">{t('nav.projects')}</a>
<a class="nav-link" class:active={active === 'build-scene'} href="/build-scene">{t('nav.buildScene')}</a>
<a class="nav-link" class:active={active === 'library'} href="/library">{t('nav.library')}</a>
<a class="nav-link" class:active={active === 'agents'} href="/agents">{t('nav.agents')}</a>
@@ -69,18 +69,14 @@ interface Props {
flex-shrink: 0;
}
.logo {
font-family: var(--font-display);
font-size: 18px;
font-family: var(--font);
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
color: var(--text);
text-decoration: none;
letter-spacing: -0.02em;
letter-spacing: -0.01em;
white-space: nowrap;
}
.logo span {
color: var(--accent);
font-weight: 500;
}
.crumb {
color: var(--text-muted);
font-size: 13px;
@@ -122,7 +118,7 @@ interface Props {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (max-width: 640px) {
@media (max-width: 760px) {
.header {
padding: 12px 16px;
flex-direction: column;
@@ -1,133 +0,0 @@
<script lang="ts">
import { t } from '$lib/i18n.svelte';
import SafeMedia from '$lib/components/SafeMedia.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
interface Props {
src: string | null;
alt: string;
placeholder: string;
wide?: boolean;
/** Taller 4:3 media-first crop (asset card grids). */
tall?: boolean;
/** Latest generation-job state for this asset, if the parent tracks jobs. */
jobState?: 'generating' | 'failed' | null;
onPreview?: (src: string) => void;
}
let {
src,
alt,
placeholder,
wide = false,
tall = false,
jobState = null,
onPreview,
}: Props = $props();
let available = $state(false);
$effect(() => {
void src;
available = false;
});
// Job state (live from the queue) wins over the media-derived state.
const chip = $derived.by((): { status: string; label: string } => {
if (jobState === 'generating') return { status: 'generating', label: t('common.generating') };
if (jobState === 'failed') return { status: 'failed', label: t('common.failed') };
if (available) return { status: 'ready', label: t('common.ready') };
if (src) return { status: 'missing', label: t('common.missing') };
return { status: 'pending', label: t('common.pending') };
});
function open() {
if (available && src && onPreview) onPreview(src);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
open();
}
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
<div
class="thumb"
class:wide
class:tall
class:clickable={available && !!onPreview}
role={available && onPreview ? 'button' : undefined}
tabindex={available && onPreview ? 0 : undefined}
onclick={open}
onkeydown={available && onPreview ? onKey : undefined}
>
<SafeMedia
class="fill"
{src}
{alt}
label={placeholder}
onAvailable={() => (available = true)}
onUnavailable={() => (available = false)}
/>
<span class="thumb-chip">
<StatusChip status={chip.status} label={chip.label} />
</span>
</div>
<style>
.thumb {
aspect-ratio: 1;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
padding: 0;
margin: 0;
width: 100%;
color: inherit;
font: inherit;
box-sizing: border-box;
}
.thumb.wide {
aspect-ratio: 16 / 10;
}
.thumb.tall {
aspect-ratio: 4 / 3;
}
.thumb.clickable {
cursor: zoom-in;
}
.thumb.clickable:hover {
border-color: var(--accent, #a78bfa);
}
.thumb.clickable:focus-visible {
outline: 2px solid var(--accent, #a78bfa);
outline-offset: 2px;
}
.thumb :global(.fill) {
width: 100%;
height: 100%;
object-fit: cover;
border: none;
border-radius: 0;
min-height: 0;
}
.thumb :global(.safe-media-ph.fill) {
position: absolute;
inset: 0;
}
.thumb-chip {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
</style>
File diff suppressed because it is too large Load Diff
@@ -136,9 +136,9 @@
const projectList = $derived(($projectsQuery.data ?? []) as Project[]);
// Stage-URL contract: images land on Assets, clips on Video.
// Five-stage workbench URL contract: images land on 素材资产, clips on 镜头脚本.
const stageLink = $derived(
attached ? `/project/${attached.projectId}?stage=${isVideo ? 'video' : 'assets'}` : '',
attached ? `/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}` : '',
);
</script>
@@ -267,13 +267,13 @@
.attached {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: var(--radius-md);
font-size: 12px;
color: var(--text-secondary);
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--ok-soft);
border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent);
border-radius: var(--r-md);
font-size: var(--fs-aux);
color: var(--text-2);
}
.attached-check {
display: flex;
@@ -282,8 +282,8 @@
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--success);
color: #052e12;
background: var(--ok);
color: var(--bg);
flex-shrink: 0;
}
.attached-text {
@@ -293,23 +293,26 @@
white-space: nowrap;
}
.attached-text strong {
color: var(--text-primary);
color: var(--text);
font-weight: 600;
}
/* 普通链接走 ghost(§1.2):默认 --text-2,悬停 --text,不用金色文字链接。 */
.attached-link {
color: var(--accent);
font-weight: 600;
color: var(--text-2);
font-weight: 500;
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
}
.attached-link:hover {
color: var(--text);
text-decoration: underline;
text-underline-offset: 3px;
}
.attached-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
border-radius: var(--r-sm);
}
.attached-dismiss {
display: flex;
@@ -319,15 +322,15 @@
height: 22px;
margin-left: auto;
border: none;
border-radius: var(--radius-sm);
border-radius: var(--r-sm);
background: transparent;
color: var(--text-muted);
color: var(--text-3);
cursor: pointer;
flex-shrink: 0;
}
.attached-dismiss:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.06);
color: var(--text);
background: var(--bg-4);
}
.attached-dismiss:focus-visible {
outline: 2px solid var(--accent);
@@ -336,20 +339,20 @@
.panel {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
gap: var(--sp-1);
padding: var(--sp-5);
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.actions {
display: flex;
gap: 8px;
margin-top: 4px;
gap: var(--sp-3);
margin-top: var(--sp-1);
}
.err {
margin: 4px 0 0;
font-size: 12px;
color: var(--error);
margin: var(--sp-1) 0 0;
font-size: var(--fs-aux);
color: var(--err);
}
</style>
@@ -17,6 +17,10 @@
allowUpload?: boolean;
/** Force required-field error rings visible (e.g. after a blocked Generate). */
showErrors?: boolean;
/** Show each field's workflow value (原值) and mark fields that differ from it. */
showOrig?: boolean;
/** Extra per-field messages keyed by nodeId (e.g. range checks); shown with showErrors. */
errors?: Record<string, string>;
/** Emits labels of required-but-empty inputs whenever the set changes. */
onValidityChange?: (missing: string[]) => void;
onChange?: (values: Record<string, string | number>) => void;
@@ -30,10 +34,23 @@
assetOptions = [],
allowUpload = false,
showErrors = false,
showOrig = false,
errors = {},
onValidityChange,
onChange,
}: Props = $props();
function hasOrig(inp: ComfyDynamicInput): boolean {
return inp.defaultValue !== undefined && inp.defaultValue !== '';
}
function isChanged(inp: ComfyDynamicInput): boolean {
if (!showOrig) return false;
const v = values[inp.nodeId];
if (isBlank(v)) return hasOrig(inp);
return !hasOrig(inp) || String(v) !== String(inp.defaultValue);
}
// Unique per mounted form so label/id pairs never collide across instances.
const uid = Math.random().toString(36).slice(2, 8);
let touched = $state<Record<string, boolean>>({});
@@ -80,6 +97,11 @@
return isMissing(inp) && (showErrors || !!touched[inp.nodeId]);
}
function extraError(inp: ComfyDynamicInput): string | null {
const msg = errors[inp.nodeId]?.trim();
return msg && !isMissing(inp) && (showErrors || !!touched[inp.nodeId]) ? msg : null;
}
function thumbFor(current: string): string | null {
if (!current) return null;
const opt = assetOptions.find((o) => o.path === current);
@@ -224,7 +246,9 @@
{#each inputs as inp (inp.nodeId)}
{@const current = String(values[inp.nodeId] ?? '')}
{@const id = inputId(inp.nodeId)}
{@const errored = showError(inp)}
{@const extra = extraError(inp)}
{@const errored = showError(inp) || !!extra}
{@const changed = isChanged(inp)}
<div class="field">
<label class="label" for={id}>
{inp.label}
@@ -235,6 +259,9 @@
{#if inp.role}<span class="role">{inp.role}</span>{/if}
<span class="kind">{inp.kind}</span>
{/if}
{#if showOrig && hasOrig(inp)}
<span class="orig">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
{/if}
</label>
{#if readonly}
<div class="readonly">{values[inp.nodeId] ?? inp.defaultValue ?? '—'}</div>
@@ -243,6 +270,7 @@
{id}
rows="3"
class:invalid={errored}
class:changed
aria-required={inp.required || undefined}
aria-invalid={errored || undefined}
value={current}
@@ -254,6 +282,7 @@
{id}
type="number"
class:invalid={errored}
class:changed
aria-required={inp.required || undefined}
aria-invalid={errored || undefined}
value={values[inp.nodeId] ?? ''}
@@ -264,6 +293,7 @@
<select
{id}
class:invalid={errored}
class:changed
value={current || String(inp.defaultValue ?? '')}
onchange={(e) => setValue(inp.nodeId, e.currentTarget.value)}
onblur={() => markTouched(inp.nodeId)}
@@ -445,6 +475,7 @@
{id}
type="text"
class:invalid={errored}
class:changed
aria-required={inp.required || undefined}
aria-invalid={errored || undefined}
value={current}
@@ -453,7 +484,7 @@
/>
{/if}
{#if errored}
<span class="error-msg" role="alert">{t('cdf.requiredMsg', { label: inp.label })}</span>
<span class="error-msg" role="alert">{extra ?? t('cdf.requiredMsg', { label: inp.label })}</span>
{/if}
</div>
{/each}
@@ -516,11 +547,23 @@
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
input.changed,
textarea.changed,
select.changed {
border-color: var(--accent-line);
}
input.invalid,
textarea.invalid,
select.invalid {
border-color: var(--error);
}
.orig {
float: right;
font-weight: 400;
font-size: var(--fs-mini);
font-family: var(--mono);
color: var(--text-muted);
}
input.invalid:focus-visible,
textarea.invalid:focus-visible,
select.invalid:focus-visible {
@@ -1,7 +1,9 @@
<script lang="ts">
import SafeMedia from '$lib/components/SafeMedia.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import { t } from '$lib/i18n.svelte';
import { exportLastFrame } from '$lib/media/lastFrame';
interface Props {
src: string | null;
@@ -61,6 +63,17 @@
}
return alt ? `${alt.trim().replace(/\s+/g, '-').toLowerCase()}.png` : 'media';
});
let frameBusy = $state(false);
async function exportTail() {
if (!src || frameBusy) return;
frameBusy = true;
try {
await exportLastFrame(src);
} finally {
frameBusy = false;
}
}
</script>
<svelte:window onkeydown={src ? onKeydown : undefined} />
@@ -90,6 +103,26 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
<Icon name="zoom-in" size={17} />
</button>
{/if}
{#if isVideo}
<button
class="tool"
type="button"
aria-label={frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
title={frameBusy ? t('lastFrame.exporting') : `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
disabled={frameBusy}
aria-busy={frameBusy}
onclick={(e) => {
e.stopPropagation();
exportTail();
}}
>
{#if frameBusy}
<Spinner size="sm" />
{:else}
<Icon name="image" size={17} />
{/if}
</button>
{/if}
<a
class="tool"
href={src}
@@ -1,164 +0,0 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { jobsApi, type Job } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import { progressFor } from '$lib/jobProgress';
import { toast } from '$lib/toast';
import Button from './ui/Button.svelte';
import Icon from './ui/Icon.svelte';
import type { IconName } from './ui/icons';
import ProgressBar from './ui/ProgressBar.svelte';
import StatusChip from './ui/StatusChip.svelte';
interface Props {
job: Job;
/** Optional override for the leading label (defaults to "Kind #id"). */
label?: string;
}
let { job, label }: Props = $props();
const client = useQueryClient();
let busy = $state(false);
const kindIcon: IconName = $derived(
job.kind === 'video'
? 'video'
: job.kind === 'image'
? 'image'
: job.kind === 'export'
? 'film'
: 'queue',
);
const kindLabel = $derived(
job.kind === 'export'
? t('job.exportFilm')
: job.kind.charAt(0).toUpperCase() + job.kind.slice(1),
);
const title = $derived(label ?? t('job.title', { kind: kindLabel, id: String(job.id) }));
const active = $derived(job.status === 'pending' || job.status === 'running');
const entry = $derived(progressFor(job.id));
async function refresh() {
await client.invalidateQueries({ queryKey: ['jobs'] });
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
}
async function cancel() {
busy = true;
try {
await jobsApi.cancel(job.id);
await refresh();
toast.info(t('job.cancelled', { id: String(job.id) }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('job.cancelFailed'));
} finally {
busy = false;
}
}
async function retry() {
busy = true;
try {
await jobsApi.retry(job.id);
await refresh();
toast.success(t('job.requeued', { id: String(job.id) }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('job.retryFailed'));
} finally {
busy = false;
}
}
</script>
<div class="job-row">
<span class="kind" aria-hidden="true"><Icon name={kindIcon} size={14} /></span>
<div class="main">
<div class="top">
<span class="label" {title}>{title}</span>
<StatusChip status={job.status} />
</div>
{#if active}
<div class="bar">
<ProgressBar
size="sm"
value={entry?.progress ?? 0}
indeterminate={job.status === 'running' && entry == null}
label={entry?.message}
/>
</div>
{:else if job.status === 'failed' && job.error}
<p class="err" title={job.error}>{job.error}</p>
{/if}
</div>
<div class="actions">
{#if active}
<Button size="sm" variant="ghost" loading={busy} title={t('job.cancelJob')} onclick={cancel}>
<Icon name="close" size={13} />
</Button>
{:else if job.status === 'failed'}
<Button size="sm" variant="ghost" loading={busy} title={t('job.retryJob')} onclick={retry}>
<Icon name="retry" size={13} />
</Button>
{/if}
</div>
</div>
<style>
.job-row {
display: grid;
grid-template-columns: 20px 1fr auto;
gap: 8px;
align-items: start;
padding: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
}
.kind {
display: flex;
align-items: center;
justify-content: center;
height: 20px;
color: var(--text-muted);
}
.main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 20px;
}
.label {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar {
padding-bottom: 2px;
}
.err {
margin: 0;
font-size: 11px;
line-height: 1.4;
color: var(--error);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.actions {
display: flex;
align-items: center;
}
</style>
@@ -1,137 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import type { IconName } from '$lib/components/ui/icons';
const items = $derived(
[
{ id: 'story', key: 'nav.story', icon: 'story' },
{ id: 'assets', key: 'nav.assets', icon: 'assets' },
{ id: 'script', key: 'nav.script', icon: 'script' },
{ id: 'video', key: 'nav.video', icon: 'video' },
] as { id: string; key: string; icon: IconName }[],
);
interface Props {
active?: string;
onSelect?: (id: string) => void;
/** Optional per-stage counts — a nonzero value shows a small pill on the icon. */
badges?: Record<string, number>;
}
let { active = 'story', onSelect, badges = {} }: Props = $props();
</script>
<nav class="nav" aria-label={t('nav.projectStages')}>
<button
class="nav-item home"
title={t('nav.projects')}
aria-label={t('nav.backProjects')}
onclick={() => goto('/projects')}
>
<span class="icon"><Icon name="home" size={20} /></span>
<span class="label">{t('nav.home')}</span>
</button>
<div class="divider" aria-hidden="true"></div>
{#each items as item (item.id)}
{@const count = badges[item.id] ?? 0}
<button
class="nav-item"
class:active={active === item.id}
title={t(item.key)}
aria-current={active === item.id ? 'page' : undefined}
onclick={() => onSelect?.(item.id)}
>
<span class="icon">
<Icon name={item.icon} size={20} />
{#if count > 0}
<span class="badge" aria-label={t('nav.countActive', { count })}>
{count > 9 ? '9+' : count}
</span>
{/if}
</span>
<span class="label">{t(item.key)}</span>
</button>
{/each}
</nav>
<style>
.nav {
width: 64px;
background: var(--bg-surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-md) 0;
gap: var(--space-sm);
flex-shrink: 0;
}
.divider {
width: 32px;
height: 1px;
background: var(--border);
margin: 2px 0 4px;
}
.nav-item {
width: 56px;
border: none;
background: transparent;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 0;
cursor: pointer;
color: var(--text-muted);
font-size: 11px;
font-family: inherit;
transition: all 0.15s;
}
.nav-item:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
.nav-item.active {
background: var(--accent);
color: white;
box-shadow: 0 0 16px var(--accent-glow);
}
.nav-item.home:hover {
color: var(--accent);
}
.nav-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.icon {
position: relative;
display: inline-flex;
line-height: 1;
}
.badge {
position: absolute;
top: -6px;
right: -12px;
min-width: 15px;
height: 15px;
padding: 0 4px;
box-sizing: border-box;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 9px;
font-weight: 700;
line-height: 15px;
text-align: center;
box-shadow: 0 0 0 2px var(--bg-surface);
}
.nav-item.active .badge {
background: #fff;
color: var(--accent);
box-shadow: 0 0 0 2px var(--accent);
}
</style>
@@ -1,478 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { assetUrl, projects, type Project } from '$lib/api';
import { toast } from '$lib/toast';
import Button from '$lib/components/ui/Button.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
import { t } from '$lib/i18n.svelte';
interface Props {
project: Project;
}
const { project }: Props = $props();
const client = useQueryClient();
const stats = $derived(
project.stats ?? {
scene_count: 0,
character_count: 0,
asset_ready_count: 0,
asset_total_count: 0,
},
);
// Explicit project cover wins; otherwise fall back to the first asset image.
// The project prop is stable for the lifetime of a card, so the eager
// queryKey capture below is intentional.
// svelte-ignore state_referenced_locally
const assetsQuery = createQuery({
queryKey: ['assets', project.id],
queryFn: () => projects.getAssets(project.id),
staleTime: 60_000,
});
const thumbUrl = $derived.by(() => {
if (project.cover_path) return assetUrl(project.cover_path);
const data = $assetsQuery.data;
if (!data) return null;
const char = data.characters.find((c) => c.portrait_path || c.sheet_path);
const path =
char?.portrait_path ??
char?.sheet_path ??
data.locations.find((l) => l.reference_image_path)?.reference_image_path ??
null;
return assetUrl(path);
});
// A broken cover (deleted file, 404) falls back to the gradient tile.
let thumbFailedFor = $state<string | null>(null);
function relativeTime(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '';
const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
if (mins < 1) return t('projectCard.timeJustNow');
if (mins < 60) return t('projectCard.timeM', { count: mins });
const hours = Math.round(mins / 60);
if (hours < 24) return t('projectCard.timeH', { count: hours });
const days = Math.round(hours / 24);
if (days < 30) return t('projectCard.timeD', { count: days });
const months = Math.round(days / 30);
if (months < 12) return t('projectCard.timeMo', { count: months });
return t('projectCard.timeY', { count: Math.round(months / 12) });
}
const updatedLabel = $derived(relativeTime(project.updated_at));
const updatedTitle = $derived(new Date(project.updated_at).toLocaleString());
// Overflow menu (⋯) — closes on outside click, focus leaving, or Esc.
let menuOpen = $state(false);
let menuEl = $state<HTMLDivElement | null>(null);
$effect(() => {
if (!menuOpen) return;
const onDocClick = (e: MouseEvent) => {
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') menuOpen = false;
};
const onFocusIn = (e: FocusEvent) => {
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
};
window.addEventListener('click', onDocClick);
window.addEventListener('keydown', onKey);
document.addEventListener('focusin', onFocusIn);
return () => {
window.removeEventListener('click', onDocClick);
window.removeEventListener('keydown', onKey);
document.removeEventListener('focusin', onFocusIn);
};
});
let renameOpen = $state(false);
let renameValue = $state('');
const renameMutation = createMutation({
mutationFn: (title: string) => projects.update(project.id, { title }),
onSuccess: (updated) => {
client.invalidateQueries({ queryKey: ['projects'] });
toast.success(t('projectCard.renamedTo', { title: updated.title }));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('projectCard.renameFailed'));
},
});
function openRename() {
menuOpen = false;
renameValue = project.title;
renameOpen = true;
}
function submitRename() {
const title = renameValue.trim();
renameOpen = false;
if (!title || title === project.title) return;
$renameMutation.mutate(title);
}
let deleteOpen = $state(false);
const deleteMutation = createMutation({
mutationFn: () => projects.delete(project.id),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['projects'] });
toast.success(t('projectCard.deleted', { title: project.title }));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('projectCard.deleteFailed'));
},
});
</script>
<article class="project-card">
<button
type="button"
class="card-main"
aria-label={t('projectCard.open', { title: project.title })}
onclick={() => goto(`/project/${project.id}`)}
>
<div class="card-thumb">
{#if thumbUrl && thumbFailedFor !== thumbUrl}
<img
class="thumb-img"
src={thumbUrl}
alt=""
loading="lazy"
onerror={() => (thumbFailedFor = thumbUrl)}
/>
{:else}
<div class="card-thumb-icon"><Icon name="video" size={26} /></div>
{/if}
<div class="card-status">
<StatusChip status={project.status} label={t(`projectCard.status.${project.status}`)} />
</div>
</div>
<div class="card-body">
<div class="card-title">
{project.title}
<span class="open-arrow">→</span>
</div>
<p class="card-desc">{project.idea || t('projectCard.noIdea')}</p>
<div class="card-meta">
<div class="card-stats">
<span class="card-stat">
<Icon name="script" size={13} />
{t('projectCard.scenes', { count: stats.scene_count })}
</span>
<span class="card-stat">
<Icon name="assets" size={13} />
{t('projectCard.characters', { count: stats.character_count })}
</span>
<span class="card-stat">
<Icon name="image" size={13} />
{t('projectCard.assets', {
ready: stats.asset_ready_count,
total: stats.asset_total_count,
})}
</span>
</div>
<span title={updatedTitle}>{updatedLabel}</span>
</div>
</div>
</button>
<div class="menu-wrap" bind:this={menuEl}>
<button
type="button"
class="menu-btn"
aria-label={t('projectCard.actions', { title: project.title })}
aria-haspopup="menu"
aria-expanded={menuOpen}
onclick={() => (menuOpen = !menuOpen)}
>
⋯
</button>
{#if menuOpen}
<div class="menu" role="menu" aria-label={t('projectCard.menuLabel')}>
<button type="button" role="menuitem" class="menu-item" onclick={openRename}>
<Icon name="edit" size={14} />
{t('common.rename')}
</button>
<button
type="button"
role="menuitem"
class="menu-item"
onclick={() => {
menuOpen = false;
goto(`/project/${project.id}?stage=assets`);
}}
>
<Icon name="image" size={14} />
{t('projectCard.changeCover')}
</button>
<button
type="button"
role="menuitem"
class="menu-item danger"
onclick={() => {
menuOpen = false;
deleteOpen = true;
}}
>
<Icon name="trash" size={14} />
{t('common.delete')}
</button>
</div>
{/if}
</div>
</article>
<Modal bind:open={renameOpen} title={t('projectCard.renameTitle')}>
<form
onsubmit={(e) => {
e.preventDefault();
submitRename();
}}
>
<label class="field">
<span class="field-label">{t('common.title')}</span>
<input class="field-input" bind:value={renameValue} required />
</label>
</form>
{#snippet footer()}
<Button variant="ghost" onclick={() => (renameOpen = false)}>{t('common.cancel')}</Button>
<Button
variant="primary"
disabled={!renameValue.trim() || $renameMutation.isPending}
onclick={submitRename}
>
{t('common.rename')}
</Button>
{/snippet}
</Modal>
<ConfirmDialog
bind:open={deleteOpen}
title={t('projectCard.deleteTitle')}
message={t('projectCard.deleteMessage', { title: project.title })}
confirmLabel={t('common.delete')}
danger
onconfirm={() => $deleteMutation.mutate()}
/>
<style>
.project-card {
position: relative;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: all 0.2s;
}
.project-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
}
.project-card:hover .open-arrow {
opacity: 1;
transform: translateX(0);
}
.card-main {
display: block;
width: 100%;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.card-main:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.card-thumb {
height: 170px;
background: linear-gradient(135deg, var(--bg-elevated) 0%, #1a1625 100%);
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.card-thumb::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 30%, rgba(139, 92, 246, 0.12) 0%, transparent 40%),
radial-gradient(circle at 80% 70%, rgba(59, 130, 246, 0.08) 0%, transparent 40%);
}
.thumb-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.card-thumb-icon {
width: 64px;
height: 64px;
border-radius: var(--radius-md);
background: rgba(139, 92, 246, 0.12);
border: 1px solid rgba(139, 92, 246, 0.2);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
}
.card-status {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
.card-body {
padding: var(--space-md);
}
.card-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 var(--space-xs);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-sm);
}
.open-arrow {
color: var(--accent);
opacity: 0;
transform: translateX(-8px);
transition: all 0.2s;
font-size: 14px;
}
.card-desc {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.5;
margin: 0 0 var(--space-md);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-sm);
font-size: 12px;
color: var(--text-muted);
}
.card-stats {
display: flex;
gap: var(--space-md);
flex-wrap: wrap;
}
.card-stat {
display: flex;
align-items: center;
gap: 4px;
}
.menu-wrap {
position: absolute;
top: 8px;
left: 8px;
z-index: 3;
}
.menu-btn {
width: 30px;
height: 30px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: rgba(10, 10, 12, 0.55);
color: var(--text-secondary);
font-size: 16px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.15s;
}
.project-card:hover .menu-btn,
.menu-btn:focus-visible,
.menu-btn[aria-expanded='true'] {
opacity: 1;
}
.menu-btn:hover {
background: var(--bg-elevated);
color: var(--text-primary);
border-color: var(--border);
}
.menu-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.menu {
position: absolute;
top: 34px;
left: 0;
min-width: 140px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
padding: 4px;
display: flex;
flex-direction: column;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
cursor: pointer;
text-align: left;
}
.menu-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.menu-item.danger {
color: var(--error);
}
.menu-item.danger:hover {
background: rgba(239, 68, 68, 0.12);
}
.menu-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
@media (prefers-reduced-motion: reduce) {
.project-card:hover {
transform: none;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,867 +0,0 @@
<script lang="ts">
import { tick } from 'svelte';
import { goto } from '$app/navigation';
import { toStore } from 'svelte/store';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { assetUrl, jobsApi, projects, type Job, type Scene, type Clip } from '$lib/api';
import { estimateTargetSeconds } from '$lib/durationBudget';
import { agentDeepLink } from '$lib/agentTasks';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import Button from '$lib/components/ui/Button.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
interface Props {
projectId: number;
}
let { projectId }: Props = $props();
const client = useQueryClient();
let editing = $state<Scene | null>(null);
let editOpen = $state(false);
let highlightId = $state<number | null>(null);
let adding = $state(false);
let deletingId = $state<number | null>(null);
let sceneToDelete = $state<Scene | null>(null);
let deleteOpen = $state(false);
const scenesQuery = createQuery(
toStore(() => ({
queryKey: ['scenes', projectId],
queryFn: () => projects.getScenes(projectId),
})),
);
// Shared cache key with QueueStage — clip job state shows up on scene cards
// too. Refreshed via SSE invalidation from the project page, not polling.
const jobsQuery = createQuery(
toStore(() => ({
queryKey: ['jobs', projectId],
queryFn: () => jobsApi.list(projectId),
})),
);
// Cache-shared with the parent's story query — only used for the target-length hint.
const storyQuery = createQuery(
toStore(() => ({
queryKey: ['story', projectId],
queryFn: () => projects.getStory(projectId),
})),
);
// Cache-shared with AssetsStage — resolves scene location chips.
const assetsQuery = createQuery(
toStore(() => ({
queryKey: ['assets', projectId],
queryFn: () => projects.getAssets(projectId),
})),
);
const scenes = $derived($scenesQuery.data?.scenes ?? []);
const sceneCount = $derived(scenes.length);
const totalClips = $derived(scenes.reduce((n, s) => n + (s.clips?.length ?? 0), 0));
const totalSec = $derived($scenesQuery.data?.estimated_duration_sec ?? 0);
const targetSec = $derived.by(() => {
const target = $storyQuery.data?.project?.target_duration;
return target ? estimateTargetSeconds(target) : 0;
});
const busy = $derived(adding || deletingId != null);
// ── Break into shots (coverage expansion) ────────────────────────────
let expandingIds = $state<number[]>([]);
let expandingAll = $state(false);
let expandedIds = $state<number[]>([]);
function isExpanded(scene: Scene): boolean {
return (scene.clips?.length ?? 0) > 1 || expandedIds.includes(scene.id);
}
async function breakIntoShots(scene: Scene) {
if (expandingIds.length > 0 || expandingAll) return;
expandingIds = [...expandingIds, scene.id];
try {
const res = await projects.expandClips(projectId, { scene_ids: [scene.id] });
expandedIds = [...new Set([...expandedIds, scene.id])];
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(
t('script.toast.breakIntoShots', {
num: scene.order_index,
clips: res.scenes?.[0]?.clip_count ?? '?',
}),
);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.breakFail'));
} finally {
expandingIds = expandingIds.filter((id) => id !== scene.id);
}
}
async function breakAllIntoShots() {
if (expandingIds.length > 0 || expandingAll) return;
if (
!window.confirm(t('script.toast.breakAllConfirm', { count: sceneCount }))
)
return;
expandingAll = true;
try {
const res = await projects.expandClips(projectId, { all: true });
expandedIds = scenes.map((s) => s.id);
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.boardExpanded', { count: res.total_clips }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.breakAllFail'));
} finally {
expandingAll = false;
}
}
async function deleteClip(clip: Clip, scene: Scene) {
try {
await projects.deleteClip(projectId, clip.id);
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.deletedShot', { num: `${scene.order_index}.${clip.order_index}` }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.deleteClipFail'));
}
}
const saveMutation = createMutation({
mutationFn: () =>
projects.updateScene(projectId, editing!.id, {
heading: editing!.heading,
action: editing!.action,
dialog: editing!.dialog,
duration_sec: editing!.duration_sec,
}),
onSuccess: () => {
editing = null;
editOpen = false;
client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.sceneSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('script.toast.saveSceneFail'));
},
});
// Chain-from-previous toggle — persists on the scene; consumed at render time.
let chainPendingId = $state<number | null>(null);
async function toggleChain(scene: Scene) {
if (chainPendingId != null) return;
chainPendingId = scene.id;
try {
await projects.updateScene(projectId, scene.id, {
chain_from_prev: !scene.chain_from_prev,
});
await client.invalidateQueries({ queryKey: ['scenes'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.updateSceneFail'));
} finally {
chainPendingId = null;
}
}
function jobForScene(sceneId: number): Job | undefined {
const jobs = ($jobsQuery.data ?? []).filter(
(j) => j.scene_id === sceneId && j.kind === 'video',
);
if (jobs.length === 0) return undefined;
return [...jobs].sort((a, b) => b.id - a.id)[0];
}
function jobForClip(clipId: number): Job | undefined {
const jobs = ($jobsQuery.data ?? []).filter(
(j) => (j as Job & { clip_id?: number | null }).clip_id === clipId && j.kind === 'video',
);
if (jobs.length === 0) return undefined;
return [...jobs].sort((a, b) => b.id - a.id)[0];
}
/** Per-scene production status — only uses fields the API actually exposes. */
function sceneStatus(scene: Scene): { status: string; label: string } {
const job = jobForScene(scene.id);
if (job && (job.status === 'pending' || job.status === 'running')) {
return { status: 'generating', label: t('script.status.rendering') };
}
if (job?.status === 'failed') return { status: 'failed', label: t('script.status.renderFailed') };
if (scene.video_path || job?.status === 'done') {
return { status: 'ready', label: t('script.status.clipReady') };
}
return { status: 'idle', label: t('script.status.noClip') };
}
function locationName(scene: Scene): string | null {
if (scene.location_id == null) return null;
const locs = $assetsQuery.data?.locations ?? [];
return locs.find((l) => l.id === scene.location_id)?.name ?? null;
}
function avatarFor(char: { portrait_path: string | null; sheet_path: string | null }) {
return assetUrl(char.portrait_path ?? char.sheet_path);
}
function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
return parts
.slice(0, 2)
.map((p) => p[0].toUpperCase())
.join('');
}
/** Hide a broken avatar image (portrait moved/deleted on disk), revealing initials. */
function hideBrokenAvatar(e: Event) {
const img = e.currentTarget as HTMLImageElement | null;
if (img) img.style.display = 'none';
}
function formatClock(sec: number): string {
const s = Math.max(0, Math.round(sec));
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${r.toString().padStart(2, '0')}`;
}
async function move(sceneId: number, dir: -1 | 1) {
if (busy) return;
const list = $scenesQuery.data?.scenes ?? [];
const idx = list.findIndex((s) => s.id === sceneId);
const swap = idx + dir;
if (idx < 0 || swap < 0 || swap >= list.length) return;
const ids = list.map((s) => s.id);
[ids[idx], ids[swap]] = [ids[swap], ids[idx]];
await projects.reorderScenes(projectId, ids);
client.invalidateQueries({ queryKey: ['scenes'] });
}
/** Hand off script regeneration to a fresh, project-linked agent chat. */
function requestRegenerate() {
if (busy) return;
goto(agentDeepLink(projectId, 'script'));
}
function openEdit(scene: Scene) {
editing = { ...scene };
editOpen = true;
}
async function addScene() {
if (busy) return;
adding = true;
try {
const existing = $scenesQuery.data?.scenes ?? [];
const nextIndex = existing.length + 1;
const created = await projects.createScene(projectId, {
order_index: nextIndex,
heading: `NEW SCENE ${nextIndex}`,
action: '',
dialog: '',
duration_sec: 5,
});
// Keep order_index contiguous 1..n so Regenerate sees the real board size
const ids = [...existing.map((s) => s.id), created.id];
await projects.reorderScenes(projectId, ids);
await client.invalidateQueries({ queryKey: ['scenes'] });
await client.refetchQueries({ queryKey: ['scenes', projectId] });
editing = { ...created, order_index: nextIndex, heading: `NEW SCENE ${nextIndex}` };
editOpen = true;
highlightId = created.id;
await tick();
document
.getElementById(`scene-${created.id}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
toast.success(t('script.toast.sceneAdded', { num: nextIndex }));
window.setTimeout(() => {
if (highlightId === created.id) highlightId = null;
}, 2800);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.addSceneFail'));
} finally {
adding = false;
}
}
function requestDelete(scene: Scene) {
if (busy) return;
sceneToDelete = scene;
deleteOpen = true;
}
async function confirmDeleteScene() {
const scene = sceneToDelete;
sceneToDelete = null;
if (!scene || busy) return;
const label = scene.heading?.trim() || `Scene #${scene.order_index}`;
deletingId = scene.id;
try {
await projects.deleteScene(projectId, scene.id);
if (editing?.id === scene.id) {
editing = null;
editOpen = false;
}
const remaining = ($scenesQuery.data?.scenes ?? [])
.filter((s) => s.id !== scene.id)
.map((s) => s.id);
if (remaining.length > 0) {
await projects.reorderScenes(projectId, remaining);
}
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.deleted', { name: label }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.deleteFailed'));
} finally {
deletingId = null;
}
}
</script>
<header class="stage-header">
<div>
<h2>3. Script</h2>
<p class="muted">
Estimated duration: {formatClock(totalSec)}
{#if targetSec > 0}
/ target ~{formatClock(targetSec)}
{/if}
{#if sceneCount > 0}
· {sceneCount} scenes
{#if totalClips > 0}
/ {totalClips} shot clips
{/if}
{/if}
</p>
</div>
<div class="stage-actions">
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
<Icon name="plus" size={14} /> {t('script.addScene')}
</Button>
<Button
variant="secondary"
disabled={busy || expandingAll || expandingIds.length > 0 || sceneCount === 0}
loading={expandingAll}
onclick={breakAllIntoShots}
title={t('script.breakAllTitle')}
>
<Icon name="film" size={14} /> {t('script.breakAllIntoShots')}
</Button>
<Button variant="primary" disabled={busy} onclick={requestRegenerate}>
<Icon name="sparkle" size={15} /> {t('script.regenerate')}
</Button>
</div>
</header>
<div class="script-panel">
{#if $scenesQuery.isLoading}
<div class="card">{t('script.loadingScenes')}</div>
{:else if sceneCount === 0}
<EmptyState
title={t('script.noScenesTitle')}
body={t('script.noScenesBody')}
>
{#snippet icon()}
<Icon name="script" size={28} />
{/snippet}
{#snippet action()}
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
<Icon name="plus" size={14} /> {t('script.addScene')}
</Button>
{/snippet}
</EmptyState>
{:else}
{#each scenes as scene, i (scene.id)}
{@const st = sceneStatus(scene)}
{@const locName = locationName(scene)}
<article
id="scene-{scene.id}"
class="card scene"
class:fresh={highlightId === scene.id}
class:deleting={deletingId === scene.id}
>
<div class="scene-head">
<div class="scene-title">
<span class="grip" aria-hidden="true"><Icon name="drag" size={14} /></span>
<span class="num">#{scene.order_index}</span>
<strong>{scene.heading || t('script.untitledScene')}</strong>
{#if highlightId === scene.id}
<span class="fresh-tag">{t('script.justAdded')}</span>
{/if}
<StatusChip status={st.status} label={st.label} />
</div>
<div class="actions">
<button
type="button"
class="icon-btn"
aria-label={t('script.moveSceneUp')}
title={t('script.moveSceneUp')}
disabled={busy || i === 0}
onclick={() => move(scene.id, -1)}
>
<Icon name="chevron-up" size={15} />
</button>
<button
type="button"
class="icon-btn"
aria-label={t('script.moveSceneDown')}
title={t('script.moveSceneDown')}
disabled={busy || i === sceneCount - 1}
onclick={() => move(scene.id, 1)}
>
<Icon name="chevron-down" size={15} />
</button>
<Button variant="secondary" size="sm" disabled={busy} onclick={() => openEdit(scene)}>
{t('script.edit')}
</Button>
<Button
variant="danger"
size="sm"
disabled={busy}
loading={deletingId === scene.id}
onclick={() => requestDelete(scene)}
>
{t('script.delete')}
</Button>
</div>
</div>
{#if scene.action}
<p class="muted">{scene.action}</p>
{:else}
<p class="muted empty-line">{t('script.noActionYet')}</p>
{/if}
{#if scene.dialog}
<pre class="dialog">{scene.dialog}</pre>
{/if}
<div class="clip-block">
<div class="clip-head">
<span class="clip-title">
<Icon name="film" size={12} />
{(scene.clips ?? []).length}
{((scene.clips ?? []).length === 1 ? t('script.shotClipSingular') : t('script.shotClipPlural', { n: (scene.clips ?? []).length }))}
{#if (scene.clips ?? []).some((c) => c.clip_path)}
· {t('script.renderedCount', { count: (scene.clips ?? []).filter((c) => c.clip_path).length })}
{/if}
</span>
<Button
variant="secondary"
size="sm"
disabled={busy || expandingIds.length > 0 || expandingAll}
loading={expandingIds.includes(scene.id)}
onclick={() => breakIntoShots(scene)}
title={isExpanded(scene) ? t('script.rebreakTitle') : t('script.breakTitle')}
>
{isExpanded(scene) ? t('script.rebreak') : t('script.break')}
</Button>
</div>
{#if isExpanded(scene) && (scene.clips ?? []).length > 1}
<ul class="clip-list">
{#each scene.clips ?? [] as clip (clip.id)}
{@const clipJob = jobForClip(clip.id)}
<li class="clip-row">
<span class="clip-num" class:ready={Boolean(clip.clip_path)}>
#{scene.order_index}.{clip.order_index}
</span>
<span class="clip-desc">{clip.description || t('script.untitledShot')}</span>
{#if clip.shot_size}
<span class="chip">{clip.shot_size}</span>
{/if}
{#if clipJob && (clipJob.status === 'pending' || clipJob.status === 'running')}
<span class="chip chip-render">{t('script.rendering')}</span>
{:else if clip.clip_path}
<span class="chip chip-ok">✓ {t('script.clipDone')}</span>
{/if}
{#if clip.duration_sec}
<span class="clip-dur">{clip.duration_sec}s</span>
{/if}
<button
type="button"
class="icon-btn"
aria-label={t('script.deleteShotAria', { id: `${scene.order_index}.${clip.order_index}` })}
title={t('script.deleteShotTitle')}
onclick={() => deleteClip(clip, scene)}
>
<Icon name="trash" size={13} />
</button>
</li>
{/each}
</ul>
{/if}
</div>
<div class="chips">
{#each scene.characters ?? [] as c (c.id)}
{@const avatar = avatarFor(c)}
<span class="chip">
<span class="avatar">
<span class="initials" aria-hidden="true">{initialsOf(c.name)}</span>
{#if avatar}
<img src={avatar} alt="" loading="lazy" onerror={hideBrokenAvatar} />
{/if}
</span>
{c.name}
</span>
{/each}
{#if locName}
<span class="chip"><Icon name="folder" size={12} /> {locName}</span>
{/if}
{#if scene.duration_sec}
<span class="chip"><Icon name="clock" size={12} /> {formatClock(scene.duration_sec)}</span>
{/if}
{#if i > 0}
<button
type="button"
class="chip chip-toggle"
class:chip-on={Boolean(scene.chain_from_prev)}
disabled={chainPendingId === scene.id}
title={t('script.chainTitle')}
onclick={() => toggleChain(scene)}
>
<Icon name="film" size={12} />
{Boolean(scene.chain_from_prev) ? t('script.chainOn') : t('script.chainOff')}
</button>
{/if}
</div>
</article>
{/each}
{/if}
</div>
<Modal
bind:open={editOpen}
title={editing ? t('script.editSceneNumbered', { n: editing.order_index }) : t('script.editScene')}
onclose={() => (editing = null)}
>
{#if editing}
<label class="field">
<span class="field-label">{t('script.headingField')}</span>
<input class="field-input" bind:value={editing.heading} placeholder={t('script.headingPlaceholder')} />
</label>
<label class="field">
<span class="field-label">{t('script.actionField')}</span>
<textarea
class="field-textarea"
bind:value={editing.action}
rows="4"
placeholder={t('script.actionPlaceholder')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('script.dialogField')}</span>
<textarea
class="field-textarea"
bind:value={editing.dialog}
rows="4"
placeholder={t('script.dialogPlaceholder')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('script.durationField')}</span>
<input class="field-input" type="number" bind:value={editing.duration_sec} min="1" />
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (editOpen = false)}>{t('common.cancel')}</Button>
<Button
variant="primary"
loading={$saveMutation.isPending}
onclick={() => $saveMutation.mutate()}
>
{t('script.save')}
</Button>
{/snippet}
</Modal>
<ConfirmDialog
bind:open={deleteOpen}
title={t('script.deleteSceneTitle')}
message={sceneToDelete
? t('script.deleteSceneMessage', {
heading: sceneToDelete.heading?.trim() || t('script.sceneNumber', { n: sceneToDelete.order_index }),
})
: ''}
confirmLabel={t('script.delete')}
danger
onconfirm={() => void confirmDeleteScene()}
oncancel={() => (sceneToDelete = null)}
/>
<style>
.stage-header {
/* Pins inside the stage scroll container so Add Scene / Regenerate
stay reachable while scrolling long scripts. */
position: sticky;
top: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-lg);
gap: var(--space-md);
padding: 8px 0 12px;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
}
.stage-header h2 {
margin: 0 0 4px;
font-size: 22px;
font-weight: 700;
}
.stage-actions {
display: flex;
gap: 8px;
}
.muted {
color: var(--text-secondary);
margin: 0;
}
.script-panel {
position: relative;
min-height: 220px;
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-bottom: var(--space-md);
scroll-margin-top: 84px;
scroll-margin-bottom: 24px;
}
.scene {
transition:
border-color 0.25s ease,
box-shadow 0.25s ease,
opacity 0.2s ease;
}
.scene.fresh {
border-color: var(--accent);
box-shadow:
0 0 0 1px var(--accent),
0 0 24px var(--accent-glow);
animation: scene-in 0.45s ease-out;
}
.scene.deleting {
opacity: 0.45;
}
@keyframes scene-in {
from {
opacity: 0.35;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.scene.fresh {
animation: none;
}
}
.scene-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 8px;
}
.scene-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.grip {
display: inline-flex;
color: var(--text-muted);
cursor: grab;
opacity: 0.6;
}
.num {
color: var(--accent);
font-weight: 700;
}
.fresh-tag {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-glow);
border-radius: 999px;
padding: 2px 8px;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.icon-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
border-color: var(--border);
}
.icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.icon-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.empty-line {
font-style: italic;
color: var(--text-muted);
}
.dialog {
background: var(--bg-elevated);
padding: 12px;
border-radius: var(--radius-sm);
white-space: pre-wrap;
font-family: var(--font-mono);
font-size: 13px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
color: var(--text-secondary);
}
.chip-toggle {
cursor: pointer;
font-family: inherit;
}
.chip-toggle:hover {
color: var(--text-primary);
border-color: #52525b;
}
.chip-toggle:disabled {
opacity: 0.6;
cursor: wait;
}
.chip-on {
color: var(--accent);
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--bg-elevated));
}
.clip-block {
margin-top: 10px;
border-top: 1px dashed var(--border);
padding-top: 8px;
}
.clip-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.clip-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.clip-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.clip-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 4px 8px;
font-size: 12px;
}
.clip-num {
font-family: var(--font-mono);
font-weight: 700;
color: var(--text-secondary);
flex-shrink: 0;
}
.clip-num.ready {
color: var(--accent);
}
.clip-desc {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
.clip-dur {
color: var(--text-muted);
flex-shrink: 0;
}
.chip-ok {
color: var(--accent);
border-color: var(--accent);
}
.chip-render {
color: var(--warning, #eab308);
border-color: var(--warning, #eab308);
}
.avatar {
position: relative;
width: 20px;
height: 20px;
border-radius: 50%;
overflow: hidden;
background: var(--bg-surface);
border: 1px solid var(--border);
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.avatar img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.initials {
font-size: 9px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.02em;
}
</style>
@@ -1,947 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import {
projects,
type Beat,
type Character,
type Item,
type Location,
type StoryData,
} from '$lib/api';
import {
buildStoryDraftPromptPreview,
estimateTargetSeconds,
recommendBeatCount,
recommendSceneCount,
} from '$lib/durationBudget';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { agentDeepLink } from '$lib/agentTasks';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
interface Props {
projectId: number;
story: StoryData;
configured: boolean;
onGoAssets?: () => void;
}
let { projectId, story, configured, onGoAssets }: Props = $props();
const client = useQueryClient();
const GENRES = [
'Adventure / Mystery',
'Drama',
'Sci-Fi',
'Fantasy',
'Horror',
'Romance',
'Thriller',
];
const LENGTHS_HINT = $derived(t('story.lengthsHint'));
const TONES = [
'Cinematic, atmospheric',
'Dark, tense',
'Whimsical, warm',
'Gritty, realistic',
'Epic, sweeping',
];
const GENRE_KEYS: Record<string, string> = {
'Adventure / Mystery': 'genres.adventure',
Drama: 'genres.drama',
'Sci-Fi': 'genres.scifi',
Fantasy: 'genres.fantasy',
Horror: 'genres.horror',
Romance: 'genres.romance',
Thriller: 'genres.thriller',
};
const TONE_KEYS: Record<string, string> = {
'Cinematic, atmospheric': 'tones.cinematic',
'Dark, tense': 'tones.dark',
'Whimsical, warm': 'tones.whimsical',
'Gritty, realistic': 'tones.gritty',
'Epic, sweeping': 'tones.epic',
};
function genreLabel(v: string): string {
return t(GENRE_KEYS[v] ?? v);
}
function toneLabel(v: string): string {
return t(TONE_KEYS[v] ?? v);
}
const EXAMPLE = {
idea: 'A lone cartographer discovers a map that rewrites itself every midnight, leading her into a forgotten city beneath the desert.',
genre: 'Adventure / Mystery',
tone: 'Cinematic, atmospheric',
target_duration: '2 minutes',
};
let editingBeat = $state<Beat | null>(null);
let editingChar = $state<Character | null>(null);
let editingLoc = $state<Location | null>(null);
let editingItem = $state<Item | null>(null);
let beatModalOpen = $state(false);
let charModalOpen = $state(false);
let locModalOpen = $state(false);
let itemModalOpen = $state(false);
let confirmExampleOpen = $state(false);
let deletingBeat = $state<Beat | null>(null);
let beatDeleteOpen = $state(false);
let addingBeat = $state(false);
// Static defaults — the $effect below syncs all drafts from the story prop.
let ideaDraft = $state('');
let genreDraft = $state(GENRES[0]);
let toneDraft = $state(TONES[0]);
let lengthDraft = $state('2 minutes');
let showDraftPrompt = $state(false);
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
let saveState = $state<SaveState>('idle');
let savedTimer: ReturnType<typeof setTimeout> | null = null;
const beatBudget = $derived(recommendBeatCount(lengthDraft));
const sceneBudget = $derived(recommendSceneCount(lengthDraft));
const secsBudget = $derived(estimateTargetSeconds(lengthDraft));
const draftPromptPreview = $derived(
buildStoryDraftPromptPreview({
title: story.project.title,
idea: ideaDraft,
genre: genreDraft,
tone: toneDraft,
targetDuration: lengthDraft,
}),
);
$effect(() => {
ideaDraft = story.project.idea ?? '';
genreDraft = story.project.genre || GENRES[0];
toneDraft = story.project.tone || TONES[0];
lengthDraft = story.project.target_duration || '2 minutes';
});
$effect(() => {
return () => {
if (savedTimer) clearTimeout(savedTimer);
};
});
function armSavedTimer() {
if (savedTimer) clearTimeout(savedTimer);
savedTimer = setTimeout(() => {
if (saveState === 'saved') saveState = 'idle';
}, 2000);
}
const saveProject = createMutation({
mutationFn: (payload: {
idea?: string;
genre?: string;
tone?: string;
target_duration?: string;
}) => projects.update(projectId, payload),
onMutate: () => {
saveState = 'saving';
},
onSuccess: () => {
client.invalidateQueries({ queryKey: ['story'] });
saveState = 'saved';
armSavedTimer();
},
onError: (err) => {
saveState = 'error';
toast.error(err instanceof Error ? err.message : t('story.saveSettingsFailed'));
},
});
/** Autosave path — returns false on failure (mutation onError already toasted). */
async function persistSettings(): Promise<boolean> {
try {
await $saveProject.mutateAsync({
idea: ideaDraft,
genre: genreDraft || undefined,
tone: toneDraft || undefined,
target_duration: lengthDraft || undefined,
});
return true;
} catch {
return false;
}
}
async function loadExample() {
ideaDraft = EXAMPLE.idea;
genreDraft = EXAMPLE.genre;
toneDraft = EXAMPLE.tone;
lengthDraft = EXAMPLE.target_duration;
const saved = await persistSettings();
if (saved) toast.success(t('story.exampleLoaded'));
}
function requestLoadExample() {
if (ideaDraft.trim()) confirmExampleOpen = true;
else void loadExample();
}
/** Hand off to a fresh, project-linked agent chat with the composer pre-filled. */
async function draftStoryline() {
if (!configured) return;
const saved = await persistSettings();
if (!saved) return; // persistSettings already toasted the failure
goto(agentDeepLink(projectId, 'story'));
}
function openBeat(beat: Beat) {
editingBeat = { ...beat };
beatModalOpen = true;
}
async function addBeat() {
if (addingBeat) return;
addingBeat = true;
try {
const nextIndex =
story.beats.reduce((max, b) => Math.max(max, b.order_index), 0) + 1;
// No api.ts binding exists yet for POST /beats — call the endpoint directly.
const res = await fetch(`/api/projects/${projectId}/beats`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_index: nextIndex,
title: t('story.newBeat', { n: nextIndex }),
description: '',
}),
});
if (!res.ok) {
const body = await res.text().catch(() => 'unknown error');
throw new Error(`${res.status}: ${body}`);
}
const created = (await res.json()) as Beat;
await client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.beatAdded', { index: nextIndex }));
openBeat(created);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('story.addBeatFailed'));
} finally {
addingBeat = false;
}
}
function requestDeleteBeat(beat: Beat) {
deletingBeat = beat;
beatDeleteOpen = true;
}
const deleteBeatMutation = createMutation({
mutationFn: (beatId: number) => projects.deleteBeat(projectId, beatId),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.beatDeleted'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.deleteBeatFailed'));
},
});
const saveBeat = createMutation({
mutationFn: () =>
projects.updateBeat(projectId, editingBeat!.id, {
title: editingBeat!.title,
description: editingBeat!.description,
order_index: editingBeat!.order_index,
}),
onSuccess: () => {
editingBeat = null;
beatModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.beatSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveBeatFailed'));
},
});
const saveChar = createMutation({
mutationFn: () =>
projects.updateCharacter(projectId, editingChar!.id, {
name: editingChar!.name,
role: editingChar!.role,
age: editingChar!.age,
appearance: editingChar!.appearance,
personality: editingChar!.personality,
consistency_prompt: editingChar!.consistency_prompt,
}),
onSuccess: () => {
editingChar = null;
charModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.characterSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveCharacterFailed'));
},
});
const saveLoc = createMutation({
mutationFn: () =>
projects.updateLocation(projectId, editingLoc!.id, {
name: editingLoc!.name,
description: editingLoc!.description,
consistency_prompt: editingLoc!.consistency_prompt,
}),
onSuccess: () => {
editingLoc = null;
locModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.locationSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveLocationFailed'));
},
});
const saveItem = createMutation({
mutationFn: () =>
projects.updateItem(projectId, editingItem!.id, {
name: editingItem!.name,
description: editingItem!.description,
consistency_prompt: editingItem!.consistency_prompt,
}),
onSuccess: () => {
editingItem = null;
itemModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.itemSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveItemFailed'));
},
});
function goToAssets() {
goto('?stage=assets', { keepFocus: true, noScroll: true });
}
function beatCountLabel(n: number): string {
return t('story.beats', { n });
}
</script>
{#snippet saveIndicator()}
{#if saveState === 'saving'}
<span class="save-ind"><Spinner size="sm" /> {t('common.saving')}</span>
{:else if saveState === 'saved'}
<span class="save-ind saved"><Icon name="check" size={13} /> {t('common.saved')}</span>
{:else if saveState === 'error'}
<span class="save-ind err">
<Icon name="alert" size={13} /> {t('common.saveFailed')}
<Button variant="ghost" size="sm" onclick={() => void persistSettings()}>{t('common.retry')}</Button>
</span>
{/if}
{/snippet}
<header class="stage-header">
<h2>{t('story.stageTitle')}</h2>
<div class="stage-actions">
<Button
variant="secondary"
disabled={$saveProject.isPending}
onclick={requestLoadExample}
>
{t('story.loadExample')}
</Button>
<Button variant="primary" disabled={!configured} onclick={() => void draftStoryline()}>
<Icon name="sparkle" size={15} /> {t('story.draftStoryline')}
</Button>
</div>
</header>
{#if !configured}
<div class="banner">{t('story.llmBanner')}</div>
{/if}
<div class="stack-col">
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.ideaTitle')}</h3>
{@render saveIndicator()}
{/snippet}
<textarea
class="field-textarea"
bind:value={ideaDraft}
rows="5"
placeholder={t('story.ideaPlaceholder')}
onblur={() => void persistSettings()}
></textarea>
</Card>
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.settingsTitle')}</h3>
{@render saveIndicator()}
{/snippet}
<div class="grid-3">
<label class="field">
<span class="field-label">{t('projects.genre')}</span>
<select
class="field-select"
bind:value={genreDraft}
onchange={() => void persistSettings()}
>
{#each GENRES as g}
<option value={g}>{genreLabel(g)}</option>
{/each}
{#if genreDraft && !GENRES.includes(genreDraft)}
<option value={genreDraft}>{genreLabel(genreDraft)}</option>
{/if}
</select>
</label>
<label class="field">
<span class="field-label">{t('story.targetLength')}</span>
<input
class="field-input"
type="text"
bind:value={lengthDraft}
placeholder={LENGTHS_HINT}
onblur={() => void persistSettings()}
/>
<span class="field-hint beat-hint">
{t('story.lengthHint', { secs: secsBudget, beats: beatBudget, scenes: sceneBudget })}
</span>
</label>
<label class="field">
<span class="field-label">{t('projects.tone')}</span>
<select
class="field-select"
bind:value={toneDraft}
onchange={() => void persistSettings()}
>
{#each TONES as tone}
<option value={tone}>{toneLabel(tone)}</option>
{/each}
{#if toneDraft && !TONES.includes(toneDraft)}
<option value={toneDraft}>{toneLabel(toneDraft)}</option>
{/if}
</select>
</label>
</div>
</Card>
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.draftPromptTitle')}</h3>
<Button variant="ghost" size="sm" onclick={() => (showDraftPrompt = !showDraftPrompt)}>
{showDraftPrompt ? t('story.hidePrompt') : t('story.showPrompt')}
</Button>
{/snippet}
{#if showDraftPrompt}
<pre class="prompt-preview">{draftPromptPreview}</pre>
<p class="field-hint">
{t('story.promptExplain')}
</p>
{:else}
<p class="field-hint prompt-collapsed">
{t('story.promptCollapsedHint')}
</p>
{/if}
</Card>
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.beatsTitle')}</h3>
<div class="head-actions">
{#if story.beats.length}
<StatusChip status="ready" label={beatCountLabel(story.beats.length)} />
{/if}
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
<Icon name="plus" size={14} /> {t('story.addBeat')}
</Button>
</div>
{/snippet}
{#if story.beats.length === 0}
<EmptyState
title={t('story.noBeats')}
body={t('story.noBeatsBody')}
>
{#snippet icon()}
<Icon name="story" size={28} />
{/snippet}
{#snippet action()}
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
<Icon name="plus" size={14} /> {t('story.addBeatManual')}
</Button>
{/snippet}
</EmptyState>
{:else}
<div class="beat-list">
{#each story.beats as beat (beat.id)}
<div class="beat-row">
<button type="button" class="beat" onclick={() => openBeat(beat)}>
<div class="beat-number">{beat.order_index}</div>
<div class="beat-body">
<h4>{beat.title}</h4>
<p>{beat.description}</p>
</div>
</button>
<div class="beat-actions">
<Button
variant="ghost"
size="sm"
title={t('story.deleteBeat', { index: beat.order_index })}
onclick={() => requestDeleteBeat(beat)}
>
<Icon name="trash" size={14} /><span class="sr-only">{t('story.deleteBeat', { index: beat.order_index })}</span>
</Button>
</div>
</div>
{/each}
</div>
<div class="beats-footer">
<Button variant="primary" onclick={goToAssets}>
{t('story.continueAssets')} <Icon name="chevron-right" size={15} />
</Button>
</div>
{/if}
</Card>
{#if story.characters.length || story.locations.length || story.items.length}
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.extracted')}</h3>
<Button variant="ghost" size="sm" onclick={goToAssets}>
{t('story.openAssets')} <Icon name="chevron-right" size={14} />
</Button>
{/snippet}
<div class="chips">
{#each story.characters as char (char.id)}
<button
type="button"
class="chip"
onclick={() => {
editingChar = { ...char };
charModalOpen = true;
}}
title={char.appearance ?? ''}
>
<span class="chip-tag">{t('story.charTag')}</span>
{char.name}
</button>
{/each}
{#each story.locations as loc (loc.id)}
<button
type="button"
class="chip"
onclick={() => {
editingLoc = { ...loc };
locModalOpen = true;
}}
title={loc.description ?? ''}
>
<span class="chip-tag">{t('story.envTag')}</span>
{loc.name}
</button>
{/each}
{#each story.items as item (item.id)}
<button
type="button"
class="chip"
onclick={() => {
editingItem = { ...item };
itemModalOpen = true;
}}
title={item.description ?? ''}
>
<span class="chip-tag">{t('story.itemTag')}</span>
{item.name}
</button>
{/each}
</div>
</Card>
{/if}
</div>
<Modal bind:open={beatModalOpen} title={t('story.editBeatTitle')} onclose={() => (editingBeat = null)}>
{#if editingBeat}
<label class="field">
<span class="field-label">{t('story.fieldTitle')}</span>
<input class="field-input" bind:value={editingBeat.title} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldDescription')}</span>
<textarea class="field-textarea" rows="4" bind:value={editingBeat.description}></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (beatModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveBeat.isPending} onclick={() => $saveBeat.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<Modal bind:open={charModalOpen} title={t('story.editCharTitle')} onclose={() => (editingChar = null)}>
{#if editingChar}
<label class="field">
<span class="field-label">{t('story.fieldName')}</span>
<input class="field-input" bind:value={editingChar.name} placeholder={t('story.fieldName')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldRole')}</span>
<input class="field-input" bind:value={editingChar.role} placeholder={t('story.fieldRole')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldAge')}</span>
<input class="field-input" bind:value={editingChar.age} placeholder={t('story.fieldAge')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldAppearance')}</span>
<textarea
class="field-textarea"
rows="3"
bind:value={editingChar.appearance}
placeholder={t('story.fieldAppearance')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldPersonality')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingChar.personality}
placeholder={t('story.fieldPersonality')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldConsistency')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingChar.consistency_prompt}
placeholder={t('story.fieldConsistency')}
></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (charModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveChar.isPending} onclick={() => $saveChar.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<Modal bind:open={locModalOpen} title={t('story.editLocTitle')} onclose={() => (editingLoc = null)}>
{#if editingLoc}
<label class="field">
<span class="field-label">{t('story.fieldName')}</span>
<input class="field-input" bind:value={editingLoc.name} placeholder={t('story.fieldName')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldDescription')}</span>
<textarea
class="field-textarea"
rows="3"
bind:value={editingLoc.description}
placeholder={t('story.fieldDescription')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldConsistency')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingLoc.consistency_prompt}
placeholder={t('story.fieldConsistency')}
></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (locModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveLoc.isPending} onclick={() => $saveLoc.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<Modal bind:open={itemModalOpen} title={t('story.editItemTitle')} onclose={() => (editingItem = null)}>
{#if editingItem}
<label class="field">
<span class="field-label">{t('story.fieldName')}</span>
<input class="field-input" bind:value={editingItem.name} placeholder={t('story.fieldName')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldDescription')}</span>
<textarea
class="field-textarea"
rows="3"
bind:value={editingItem.description}
placeholder={t('story.fieldDescription')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldConsistency')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingItem.consistency_prompt}
placeholder={t('story.fieldConsistency')}
></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (itemModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveItem.isPending} onclick={() => $saveItem.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<ConfirmDialog
bind:open={confirmExampleOpen}
title={t('story.confirmExampleTitle')}
message={t('story.confirmExampleMsg')}
confirmLabel={t('story.loadExample')}
onconfirm={() => void loadExample()}
/>
<ConfirmDialog
bind:open={beatDeleteOpen}
title={t('story.confirmDeleteBeatTitle')}
message={deletingBeat
? t('story.confirmDeleteBeatMsg', { index: deletingBeat.order_index, title: deletingBeat.title })
: ''}
confirmLabel={t('common.delete')}
danger
onconfirm={() => {
const id = deletingBeat?.id;
deletingBeat = null;
if (id != null) $deleteBeatMutation.mutate(id);
}}
oncancel={() => (deletingBeat = null)}
/>
<style>
.stage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
gap: var(--space-md);
}
.stage-header h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
}
.stage-actions {
display: flex;
gap: var(--space-sm);
}
.banner {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
color: var(--warning);
padding: var(--space-md);
border-radius: var(--radius-md);
margin-bottom: var(--space-lg);
font-size: 13px;
}
.stack-col {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.card-h {
margin: 0;
font-size: 16px;
color: var(--text-secondary);
font-weight: 600;
}
.head-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.save-ind {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.save-ind.saved {
color: var(--success);
animation: saved-fade 2s ease forwards;
}
.save-ind.err {
color: var(--error);
}
@keyframes saved-fade {
0% {
opacity: 0;
}
12% {
opacity: 1;
}
78% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-md);
}
@media (max-width: 900px) {
.grid-3 {
grid-template-columns: 1fr;
}
}
.grid-3 .field {
margin-bottom: 0;
}
.beat-hint {
display: block;
}
.beat-hint strong {
color: var(--accent);
font-weight: 600;
}
.prompt-preview {
margin: 0 0 var(--space-sm);
padding: 12px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
color: var(--text-secondary);
max-height: 280px;
overflow: auto;
}
.prompt-collapsed {
margin: 0;
}
.beat-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.beat-row {
display: flex;
align-items: flex-start;
gap: var(--space-xs);
}
.beat {
display: flex;
flex: 1;
min-width: 0;
gap: var(--space-md);
align-items: flex-start;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
padding: 8px;
cursor: pointer;
text-align: left;
color: inherit;
font: inherit;
}
.beat:hover {
border-color: var(--border);
background: var(--bg-elevated);
}
.beat:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.beat-actions {
flex-shrink: 0;
padding-top: 8px;
}
.beat-number {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: var(--text-secondary);
flex-shrink: 0;
}
.beat-body h4 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
}
.beat-body p {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.45;
}
.beats-footer {
display: flex;
justify-content: flex-end;
margin-top: var(--space-md);
padding-top: var(--space-md);
border-top: 1px solid var(--border);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
}
.chip:hover {
border-color: var(--accent);
}
.chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.chip-tag {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent);
font-weight: 700;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
@@ -37,8 +37,13 @@
<span class="dot"></span>
<Icon name={kindIcon[toast.kind]} size={15} />
</span>
<span class="msg">{toast.message}</span>
<button
<span class="msg">{toast.message}</span>
{#if toast.action}
<button type="button" class="action" onclick={() => (toast.action?.run(), dismissToast(toast.id))}>
{toast.action.label}
</button>
{/if}
<button
type="button"
class="dismiss"
aria-label={t('toast.dismiss')}
@@ -52,15 +57,17 @@
{/if}
<style>
/* §四.5 Toast:右下角堆叠,220ms 上移入场;成功绿点 / 失败红点。 */
.toast-host {
position: fixed;
top: 72px;
right: 20px;
z-index: 1000;
right: 24px;
bottom: 24px;
/* Above every overlay, incl. the 10000 lightbox, so feedback stays visible. */
z-index: 10001;
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(360px, calc(100vw - 32px));
max-width: min(400px, calc(100vw - 32px));
pointer-events: none;
}
.toast {
@@ -69,23 +76,10 @@
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg-surface);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
animation: toast-in 0.22s ease-out;
}
.toast.success {
border-color: rgba(34, 197, 94, 0.45);
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(34, 197, 94, 0.12);
}
.toast.error {
border-color: rgba(239, 68, 68, 0.5);
}
.toast.info {
border-color: rgba(59, 130, 246, 0.45);
border-radius: var(--r-lg);
background: var(--float-bg);
box-shadow: var(--shadow-float);
animation: toast-in var(--d-enter) var(--ease-out);
}
.badge {
display: inline-flex;
@@ -101,21 +95,44 @@
background: currentColor;
}
.toast.success .badge {
color: var(--success);
color: var(--ok);
}
.toast.error .badge {
color: var(--error);
color: var(--err);
}
.toast.info .badge {
color: var(--info);
color: var(--text-2);
}
.msg {
flex: 1;
font-size: 13px;
font-size: var(--fs-body);
line-height: 1.4;
color: var(--text-primary);
font-weight: 500;
}
/* §四.5: Toast 动作(如撤销)为金色文字 */
.action {
flex-shrink: 0;
align-self: center;
padding: var(--sp-1) var(--sp-2);
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--accent);
font: inherit;
font-size: var(--fs-aux);
font-weight: 600;
cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
.action:hover {
background: var(--accent-soft);
}
}
.action:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.dismiss {
display: inline-flex;
align-items: center;
@@ -127,10 +144,12 @@
padding: 0 2px;
min-width: 28px;
min-height: 28px;
border-radius: 4px;
border-radius: var(--r-sm);
}
.dismiss:hover {
color: var(--text-primary);
@media (hover: hover) and (pointer: fine) {
.dismiss:hover {
color: var(--text-primary);
}
}
.dismiss:focus-visible {
outline: 2px solid var(--accent);
@@ -139,7 +158,7 @@
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(-8px);
transform: translateY(8px);
}
to {
opacity: 1;
@@ -151,10 +170,12 @@
animation: none;
}
}
@media (max-width: 640px) {
:global(:root[data-reduce='1']) .toast {
animation: none;
}
@media (max-width: 760px) {
.toast-host {
top: auto;
bottom: 20px;
bottom: calc(var(--tabbar-h) + var(--sp-5) + env(safe-area-inset-bottom));
right: 16px;
left: 16px;
max-width: none;
@@ -0,0 +1,246 @@
<script lang="ts">
import { toStore } from "svelte/store";
import { createQuery } from "@tanstack/svelte-query";
import { workflows, apiErrorMessage, type Workflow } from "$lib/api";
import Drawer from "./ui/Drawer.svelte";
import Button from "./ui/Button.svelte";
import Card from "./ui/Card.svelte";
import StatusChip from "./ui/StatusChip.svelte";
let {
open = $bindable(false),
workflow,
}: { open?: boolean; workflow: Workflow | null } = $props();
// Keep the list lightweight. Only this read-only GET loads the selected document.
const detail = createQuery(
toStore(() => {
const id = workflow?.id;
return {
queryKey: ["workflow-document", id],
queryFn: () => workflows.get(id!),
enabled: open && workflow !== null,
staleTime: 0,
};
}),
);
const wf = $derived($detail.data);
const doc = $derived(wf?.doc_json);
const review = $derived(doc?.source_review);
const deps = $derived(doc?.dependencies);
const categories: Record<string, string> = {
runtime_parameter: "有源侧用途依据",
frontend_state: "前端状态(非运行参数)",
current_value_only: "仅确认现值",
semantic_unknown: "语义待确认",
};
function value(v: unknown): string {
if (v === undefined) return "未记录";
return typeof v === "string" ? v : JSON.stringify(v, null, 2);
}
</script>
<Drawer bind:open title="工作流说明" width={440}>
<div class="document-content" aria-live="polite">
<h3>{workflow?.name}</h3>
{#if $detail.isPending || $detail.isFetching}
<p role="status">正在读取说明…</p>
{:else if $detail.isError}
<p role="alert">说明读取失败:{apiErrorMessage($detail.error)}</p>
<Button onclick={() => $detail.refetch()}>重试</Button>
{:else if wf}
<div class="document-status">
<StatusChip status="pending" label="已入库" />
<StatusChip
status="pending"
label={doc?.status?.doc_done && wf.doc_completed_at
? "说明已定稿"
: "说明未定稿"}
/>
<StatusChip
status="pending"
label={doc?.status?.exec_verified && wf.exec_verified_at
? "有实跑通过记录"
: "未验证实跑"}
/>
</div>
{#if wf.import_state === "needs_conversion"}
<Card title="待转换 / 待补材料">
<p>此条目尚不可执行,仍可阅读说明。</p>
<p>{wf.import_issues?.[0]?.message || "可靠转换尚未完成。"}</p>
<details>
<summary>全部转换阻碍({wf.import_issues?.length ?? 0})</summary>
{#each wf.import_issues ?? [] as issue}<p>{issue.message}</p>{/each}
</details>
</Card>
{/if}
<Card title="用途与来源">
<p>
{doc?.purpose ||
wf.description ||
"此旧工作流尚无详细说明,保留现有输入输出供核对。"}
</p>
{#if doc?.source_path || wf.source_repo_path}<p>
源文件:{doc?.source_path || wf.source_repo_path}
</p>{/if}
{#if review?.scope?.length}<p>
已审范围:{review.scope.join(";")}
</p>{/if}
{#each review?.semantic_review?.conclusions ?? [] as conclusion}<p>
{conclusion}
</p>{/each}
</Card>
<Card title="素材要求与输入输出">
{#each doc?.materials ?? [] as material}
<p>
素材槽 {material.slot}:{material.desc}({material.required
? "必需"
: "可选"})
</p>
{/each}
<h4>输入映射</h4>
{#each doc?.io?.inputs ?? wf.input_schema ?? [] as input}
<p>
{input.label} · {input.kind} · 节点 {input.nodeId} / {input.role ??
"未标记角色"}({input.required ? "必需" : "可选"})
</p>
{:else}<p>未记录可靠输入映射;不能据此判定无需素材。</p>{/each}
<h4>输出映射</h4>
{#each doc?.io?.outputs ?? wf.output_schema ?? [] as output}
<p>{output.label} · {output.kind} · 节点 {output.nodeId}</p>
{:else}<p>未记录可靠输出映射,实际输出待核对。</p>{/each}
{#if review?.semantic_review?.material_requirements}
<details>
<summary>源文件素材审校</summary>
<pre>{value(review.semantic_review.material_requirements)}</pre>
</details>
{/if}
</Card>
<Card title="参数与源现值">
<p>
源现值不代表推荐值。下列审校分类与单位、范围、调整效果的确认分别记录。
</p>
{#each doc?.params ?? [] as param}
{@const fields = (review?.reviewed_named_fields ?? []).filter(
(f) => f.node_id === param.node_id && f.field === param.field,
)}
<details>
<summary>节点 {param.node_id} · {param.field}</summary>
<p>源现值:</p>
<pre>{value(param.default)}</pre>
{#each fields as field}
<p>
{categories[field.category ?? ""] ??
"源字段局部审校"}:{field.label}
</p>
{#if field.scope}<p>{field.scope}</p>{/if}
{#if field.operation_check}<p>{field.operation_check}</p>{/if}
{#if field.connections}<details>
<summary>源连线依据</summary>
<pre>{value(field.connections)}</pre>
</details>{/if}
{:else}<p>已保留源字段与现值;语义待确认。</p>{/each}
<p>单位:{param.unit ?? "待确认"}</p>
<p>
范围:{param.range_source && param.range_source !== "unverified"
? `${param.min ?? "未记录下界"} ~ ${param.max ?? "未记录上界"}(${param.range_source})`
: "待确认"}
</p>
{#if param.options}<p>可选值:{value(param.options)}</p>{/if}
<p>调整效果:{param.effect || "待确认"}</p>
</details>
{:else}<p>暂无具名参数说明;位置控件不能按顺序猜测。</p>{/each}
</Card>
<Card title="模型与节点引用">
<p>
环境检查:{deps?.check_state === "unchecked" || !deps?.check_state
? "未检查"
: deps.check_state}。源引用不等于实际加载依赖,未检查不能判定缺失或就位。
</p>
{#if deps?.checked_at}<p>检查时间:{deps.checked_at}</p>{/if}
{#if deps?.source}<p>定义来源:{deps.source}</p>{/if}
<p>模型位置:{deps?.model_locations || "未检查"}</p>
<details>
<summary>模型引用({deps?.models?.length ?? 0})</summary>
{#each deps?.models ?? [] as model}<p>{model}</p>{:else}<p>
无记录,不代表环境就绪。
</p>{/each}
</details>
<details>
<summary>源节点类型({deps?.node_types?.length ?? 0})</summary>
{#each deps?.node_types ?? [] as node}<p>{node}</p>{/each}
</details>
{#if deps?.node_packs?.length}<p>
节点包:{deps.node_packs.join(";")}
</p>{/if}
</Card>
<Card title="使用步骤">
<ol>
{#each doc?.steps ?? [] as step}<li>{step}</li>{:else}<li>
此工作流尚未补充使用步骤。
</li>{/each}
</ol>
</Card>
<Card title="常见问题与待确认项">
{#each doc?.errors ?? [] as error}<details>
<summary>{error.symptom}</summary>
<p>{error.fix}</p>
</details>{/each}
{#each review?.unconfirmed ?? [] as item}<p>待确认:{item}</p>{/each}
{#if !doc}<p>
此旧工作流暂无说明数据,说明定稿、依赖环境和实跑状态均待核对。
</p>{/if}
</Card>
{#if doc?.source_notes?.length}
<Card title="源文件注释">
{#each doc.source_notes as note}<details>
<summary>节点 {note.node_id}</summary>
<pre>{value(note.content)}</pre>
</details>{/each}
</Card>
{/if}
{/if}
</div>
{#snippet footer()}<Button onclick={() => (open = false)}>关闭</Button
>{/snippet}
</Drawer>
<style>
.document-content {
display: grid;
gap: 12px;
min-width: 0;
overflow-wrap: anywhere;
}
.document-content :global(*) {
min-width: 0;
}
.document-status {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
h3,
h4,
p {
margin: 0 0 8px;
}
details {
margin: 8px 0;
}
summary {
cursor: pointer;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
font: inherit;
margin: 8px 0;
}
ol {
margin: 0;
padding-left: 20px;
}
li + li {
margin-top: 8px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,827 @@
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import {
apiErrorMessage,
assetUrl,
playgroundApi,
jobsApi,
workflows,
type ComfyEndpoint,
type Job,
type Workflow,
} from '$lib/api';
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
import Button from '$lib/components/ui/Button.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Drawer from '$lib/components/ui/Drawer.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import ProgressBar from '$lib/components/ui/ProgressBar.svelte';
import SafeMedia from '$lib/components/SafeMedia.svelte';
import { mediaFileProblem, type MediaSlotKind } from '$lib/comfy/imageFile';
import { jobStatusInfo } from '$lib/jobStatus';
import { progressFor } from '$lib/jobProgress';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
/**
* 测试工作流 (HEL-643 L3, E 批抽屉化): fill the workflow's REAL input
* slots, submit one REAL run to the chosen ComfyUI through the ordinary
* job queue, and call it a pass only when at least one downloadable
* output of the right media kind came back. Test artifacts are isolated
* from projects. Presentation follows HEL-649 §3.8: right-side drawer,
* cyan progress + mono stage log while running, result thumbnails in a
* horizontal row.
*/
interface Props {
open?: boolean;
workflow: Workflow | null;
endpoints: ComfyEndpoint[];
onchanged?: () => void;
}
let {
open = $bindable(false),
workflow,
endpoints,
onchanged,
}: Props = $props();
const client = useQueryClient();
let endpointId = $state('');
let values = $state<Record<string, string | number>>({});
let listValues = $state<Record<string, string[]>>({});
let sizes = $state<Record<string, number>>({});
let uploadingName = $state<Record<string, string>>({});
let rejects = $state<Record<string, { name: string; message: string }[]>>({});
let submitError = $state('');
let confirmOpen = $state(false);
let submitting = $state(false);
let testJobId = $state<number | null>(null);
let errorExpanded = $state(false);
let logBox = $state<HTMLDivElement | null>(null);
const wf = $derived(workflow);
const schema = $derived(wf?.input_schema ?? []);
const listEntries = $derived(
schema.filter(
(i) =>
i.kind === 'image_list' ||
i.kind === 'video_list' ||
i.kind === 'audio_list',
),
);
const formEntries = $derived(
schema.filter(
(i) =>
i.kind !== 'image_list' &&
i.kind !== 'video_list' &&
i.kind !== 'audio_list',
),
);
const hasH3Hint = $derived(
listEntries.some((e) => e.media_hint === 'minimax_h3_ref2va'),
);
const outputKind = $derived<'image' | 'video'>(
wf?.kind === 'video' ? 'video' : 'image',
);
const testsQuery = createQuery(
toStore(() => ({
queryKey: ['workflow-tests', wf?.id ?? 0],
queryFn: () => workflows.tests(wf!.id),
enabled: !!wf && open,
refetchInterval: 2000,
})),
);
const jobsQuery = createQuery(
toStore(() => ({
queryKey: ['jobs'],
queryFn: () => jobsApi.list(),
enabled: !!wf && open,
refetchInterval: 5000,
})),
);
const queuedCount = $derived(
($jobsQuery.data ?? []).filter(
(j) => j.status === 'pending' || j.status === 'running',
).length,
);
const history = $derived(($testsQuery.data ?? []) as Job[]);
const activeJob = $derived.by(() => {
if (testJobId != null) {
const mine = history.find((j) => j.id === testJobId);
if (mine && (mine.status === 'pending' || mine.status === 'running'))
return mine;
}
return (
history.find((j) => j.status === 'pending' || j.status === 'running') ??
null
);
});
const finishedJob = $derived.by(() => {
if (testJobId != null)
return history.find((j) => j.id === testJobId) ?? null;
return null;
});
const queueBusy = $derived(!!activeJob || submitting);
const liveProgress = $derived(
activeJob ? progressFor(activeJob.id) : undefined,
);
const activeInfo = $derived(activeJob ? jobStatusInfo(activeJob) : null);
// Rolling mono stage log (§3.8): one line per distinct worker message.
let stageLog = $state<string[]>([]);
$effect(() => {
if (!activeJob || !activeInfo) {
stageLog = [];
return;
}
const line = liveProgress?.message || activeInfo.detail;
if (!line) return;
if (stageLog[stageLog.length - 1] !== line) stageLog = [...stageLog, line];
});
$effect(() => {
stageLog.length;
if (logBox) logBox.scrollTop = logBox.scrollHeight;
});
$effect(() => {
if (!open || !wf) return;
endpointId = wf.endpoint_id ?? '';
values = Object.fromEntries(
schema
.filter((i) => i.defaultValue !== undefined && i.defaultValue !== null)
.map((i) => [i.nodeId, i.defaultValue as string | number]),
);
listValues = {};
sizes = {};
rejects = {};
uploadingName = {};
submitError = '';
errorExpanded = false;
stageLog = [];
testJobId =
wf.last_test &&
(wf.last_test.status === 'pending' || wf.last_test.status === 'running')
? wf.last_test.id
: null;
});
function kindOf(entry: { kind: string }): MediaSlotKind {
if (entry.kind === 'video_list') return 'video';
if (entry.kind === 'audio_list') return 'audio';
return 'image';
}
function unitOf(kind: MediaSlotKind): string {
return kind === 'image' ? '张' : '段';
}
function setRejects(key: string, next: { name: string; message: string }[]) {
rejects = { ...rejects, [key]: next };
}
async function uploadOne(file: File): Promise<string | null> {
const res = await playgroundApi.upload(file);
sizes = { ...sizes, [res.path]: file.size };
return res.path;
}
async function mediaAdd(entry: (typeof listEntries)[number], files: File[]) {
const kind = kindOf(entry);
const key = entry.nodeId;
const max = entry.max ?? 9;
const current = listValues[key] ?? [];
const ok: string[] = [];
const bad: { name: string; message: string }[] = [];
for (const f of files) {
const problem = mediaFileProblem(kind, f);
if (problem) bad.push({ name: f.name, message: problem });
else ok.push(f.name);
}
if (current.length + ok.length > max) {
toast.error(
t('studio.errRefMax', { max, n: current.length, k: ok.length }),
);
if (bad.length) setRejects(key, [...(rejects[key] ?? []), ...bad]);
return;
}
setRejects(key, [...(rejects[key] ?? []), ...bad]);
for (const f of files) {
if (!ok.includes(f.name)) continue;
uploadingName = { ...uploadingName, [key]: f.name };
try {
const path = await uploadOne(f);
if (path)
listValues = {
...listValues,
[key]: [...(listValues[key] ?? []), path],
};
} catch (err) {
toast.error(apiErrorMessage(err));
} finally {
uploadingName = { ...uploadingName, [key]: '' };
}
}
}
async function mediaReplace(
entry: (typeof listEntries)[number],
index: number,
file: File,
) {
const kind = kindOf(entry);
const key = entry.nodeId;
const problem = mediaFileProblem(kind, file);
if (problem) {
setRejects(key, [
...(rejects[key] ?? []),
{ name: file.name, message: problem },
]);
return;
}
uploadingName = { ...uploadingName, [key]: file.name };
try {
const path = await uploadOne(file);
if (path) {
const next = [...(listValues[key] ?? [])];
next[index] = path;
listValues = { ...listValues, [key]: next };
}
} catch (err) {
toast.error(apiErrorMessage(err));
} finally {
uploadingName = { ...uploadingName, [key]: '' };
}
}
function mediaRemove(entry: (typeof listEntries)[number], index: number) {
const key = entry.nodeId;
listValues = {
...listValues,
[key]: (listValues[key] ?? []).filter((_, i) => i !== index),
};
}
/** Mirrors the backend's validate_input_values for immediate feedback. */
function validate(): string[] {
const errs: string[] = [];
for (const e of schema) {
const label = e.label;
if (
e.kind === 'image_list' ||
e.kind === 'video_list' ||
e.kind === 'audio_list'
) {
const n = (listValues[e.nodeId] ?? []).length;
const unit = unitOf(kindOf(e));
if (e.required && n < (e.min ?? 1))
errs.push(t('wft.errListMin', { n: e.min ?? 1, unit, label }));
} else if (e.auto && e.required && e.role === 'prompt') {
const v = values[e.nodeId];
if (
!(typeof v === 'string' && v.trim()) &&
!String(e.defaultValue ?? '').trim()
) {
errs.push(t('wft.errRequired', { label }));
}
} else if (
e.required &&
(e.kind === 'image' || e.kind === 'video' || e.kind === 'audio') &&
e.role !== 'mask'
) {
if (!values[e.nodeId]) errs.push(t('wft.errRequired', { label }));
}
}
return errs;
}
async function submitTest() {
if (!wf) return;
const errs = validate();
if (errs.length) {
submitError = errs.join(';');
return;
}
submitError = '';
confirmOpen = true;
}
async function reallyRunTest() {
if (!wf) return;
submitting = true;
try {
const inputValues: Record<string, unknown> = { ...values };
for (const [k, v] of Object.entries(listValues)) {
if (v.length) inputValues[k] = v;
}
const res = await workflows.test(wf.id, {
...(endpointId ? { endpoint_id: endpointId } : {}),
input_values: inputValues as Record<string, string | number | string[]>,
});
testJobId = res.job.id;
if (res.reused) toast.info(t('wft.reusedToast'));
void $testsQuery.refetch();
} catch (err) {
submitError = apiErrorMessage(err);
} finally {
submitting = false;
confirmOpen = false;
}
}
async function cancelTest() {
if (!activeJob) return;
try {
await jobsApi.cancel(activeJob.id);
void $testsQuery.refetch();
} catch (err) {
toast.error(apiErrorMessage(err));
}
}
async function clearRecords() {
if (!wf) return;
try {
await workflows.clearTests(wf.id);
testJobId = null;
await $testsQuery.refetch();
onchanged?.();
toast.success(t('wft.cleared'));
} catch (err) {
toast.error(apiErrorMessage(err));
}
}
function resultLine(job: Job): string {
if (job.status === 'done' && (job.output_paths?.length ?? 0) > 0) {
return t('wft.passedShort', { n: job.output_paths.length });
}
if (job.error === 'cancelled') return t('wft.cancelledShort');
return job.error ? job.error.slice(0, 120) : t('wft.failedShort');
}
function timeOf(at: string | null): string {
if (!at) return '—';
const ts = Date.parse(at.replace(' ', 'T') + 'Z');
const d = new Date(Number.isFinite(ts) ? ts : at);
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
const endpointLabel = $derived(
endpoints.find((e) => e.id === endpointId)?.name ??
t('conn.wfConnectionActive'),
);
</script>
<Drawer bind:open title={t('wft.title')} width={420}>
{#if wf}
<div class="wft">
<p class="wft-sub">
{wf.name}
<span class="mono wft-id">#{wf.id}</span>
</p>
<label class="field">
<span class="field-label">{t('wft.connection')}</span>
<select
class="a-select full"
bind:value={endpointId}
disabled={queueBusy}
>
<option value="">{t('conn.wfConnectionActive')}</option>
{#each endpoints as ep (ep.id)}
<option value={ep.id}>{ep.name}</option>
{/each}
</select>
<span class="field-hint">{t('wft.costNote')}</span>
</label>
{#if hasH3Hint}
<div class="wft-note" role="note">
<Icon name="info" size={14} />
<p>{t('wfMedia.h3Limits')}</p>
</div>
{/if}
{#if activeJob && activeInfo}
<div class="wft-active">
<div class="wft-active-head">
<span class="chip run">
<span class="chip-dot" aria-hidden="true"></span>
{t('wft.running')}
</span>
<span class="wft-active-detail">{activeInfo.detail}</span>
<Button variant="secondary" size="sm" onclick={cancelTest}
>{t('wft.cancel')}</Button
>
</div>
<ProgressBar
value={liveProgress?.progress ?? 0}
indeterminate={activeJob.status === 'pending' && !liveProgress}
label={activeInfo.label}
/>
{#if stageLog.length}
<div class="wft-log mono" bind:this={logBox} aria-live="polite">
{#each stageLog as line, i (i)}
<p>{line}</p>
{/each}
</div>
{/if}
</div>
{:else if finishedJob && finishedJob.status === 'done' && (finishedJob.output_paths?.length ?? 0) > 0}
<div class="wft-result ok">
<p class="wft-verdict">
{t('wft.passed', { n: finishedJob.output_paths.length })}
</p>
<div class="wft-outputs">
{#each finishedJob.output_paths as path (path)}
<a
class="wft-out"
href={assetUrl(path)}
download
target="_blank"
rel="noreferrer"
title={t('wft.download')}
>
<SafeMedia
kind={outputKind}
src={assetUrl(path)}
controls={outputKind === 'video'}
/>
</a>
{/each}
</div>
</div>
{:else if finishedJob && finishedJob.status !== 'pending' && finishedJob.status !== 'running'}
<div class="wft-result bad">
<p class="wft-verdict">{t('wft.failed')}</p>
<p class="wft-error">
{finishedJob.error === 'cancelled'
? t('wft.cancelledShort')
: errorExpanded || (finishedJob.error ?? '').length <= 160
? finishedJob.error
: (finishedJob.error ?? '').slice(0, 160) + '…'}
</p>
{#if finishedJob.error && finishedJob.error !== 'cancelled' && finishedJob.error.length > 160}
<button
type="button"
class="wft-expand"
onclick={() => (errorExpanded = !errorExpanded)}
>
{errorExpanded ? t('wfd.hideDetails') : t('wfd.showDetails')}
</button>
{/if}
</div>
{/if}
{#if !queueBusy}
<div class="wft-form">
{#each listEntries as entry (entry.nodeId)}
{@const kind = kindOf(entry)}
<RefImageGrid
label={entry.label}
mediaKind={kind}
paths={listValues[entry.nodeId] ?? []}
max={entry.max ?? 9}
{sizes}
uploadingName={uploadingName[entry.nodeId] || undefined}
rejects={rejects[entry.nodeId] ?? []}
onAdd={(files) => mediaAdd(entry, files)}
onReplace={(i, file) => mediaReplace(entry, i, file)}
onRemove={(i) => mediaRemove(entry, i)}
onDismissReject={(i) =>
setRejects(
entry.nodeId,
(rejects[entry.nodeId] ?? []).filter((_, j) => j !== i),
)}
/>
{/each}
{#if formEntries.length}
<ComfyDynamicForm
inputs={formEntries}
bind:values
allowUpload
showErrors={!!submitError}
/>
{/if}
</div>
{#if submitError}<span class="field-error" role="alert"
>{submitError}</span
>{/if}
<div class="wft-actions">
<Button variant="primary" loading={submitting} onclick={submitTest}>
{t('wft.submit')}
</Button>
</div>
{/if}
<div class="wft-history">
<div class="wft-history-head">
<span class="field-label"
>{t('wft.history', { n: history.length })}</span
>
{#if history.length}
<button type="button" class="link-danger" onclick={clearRecords}>
{t('wft.clear')}
</button>
{/if}
</div>
{#if history.length === 0}
<p class="wft-empty">{t('wft.never')}</p>
{:else}
<ul class="wft-rows">
{#each history as job (job.id)}
<li class:active={job.id === activeJob?.id}>
<span class="mono wft-row-id">#{job.id}</span>
<span class="wft-row-time">{timeOf(job.created_at)}</span>
<span
class="wft-row-result"
class:ok={job.status === 'done' &&
(job.output_paths?.length ?? 0) > 0}
>
{resultLine(job)}
</span>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{/if}
</Drawer>
<ConfirmDialog
bind:open={confirmOpen}
title={t('wft.confirmTitle')}
message={t('wft.confirmBody', { endpoint: endpointLabel, n: queuedCount })}
confirmLabel={t('wft.submit')}
onconfirm={reallyRunTest}
/>
<style>
.wft {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.wft-sub {
margin: 0;
font-size: 13px;
color: var(--text-2);
}
.wft-id {
color: var(--text-3);
font-size: 11px;
margin-left: 6px;
}
.a-select.full {
width: 100%;
}
.wft-note {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 8px 10px;
border: 1px solid var(--run-soft);
border-radius: var(--r-sm);
background: var(--run-soft);
color: var(--text-2);
font-size: 12px;
line-height: 1.6;
}
.wft-note :global(.icon) {
color: var(--run);
flex-shrink: 0;
margin-top: 2px;
}
.wft-note p {
margin: 0;
}
.wft-active,
.wft-result {
border: 1px solid var(--line-2);
border-radius: var(--r-md);
padding: 10px 12px;
background: var(--bg-2);
display: flex;
flex-direction: column;
gap: 8px;
}
.wft-active {
border-color: var(--run-soft);
}
.wft-active-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 20px;
padding: 0 8px;
border-radius: var(--r-sm);
font-size: 12px;
font-weight: 600;
}
.chip.run {
color: var(--run);
background: var(--run-soft);
}
.chip-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--run);
animation: wft-breathe 1.6s ease-in-out infinite;
}
@keyframes wft-breathe {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
:global([data-reduce='1']) .chip-dot {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.chip-dot {
animation: none;
}
}
.wft-active-detail {
flex: 1;
min-width: 120px;
font-size: 12px;
color: var(--text-2);
}
.wft-log {
max-height: 110px;
overflow-y: auto;
padding: 8px 10px;
border-radius: var(--r-sm);
background: var(--bg);
border: 1px solid var(--line);
font-size: 12px;
line-height: 1.6;
color: var(--text-2);
}
.wft-log p {
margin: 0;
}
.wft-result.ok {
border-color: var(--ok);
}
.wft-result.bad {
border-color: var(--err);
}
.wft-verdict {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.wft-result.ok .wft-verdict {
color: var(--ok);
}
.wft-result.bad .wft-verdict {
color: var(--err);
}
.wft-error {
margin: 0;
font-size: 12px;
color: var(--err);
line-height: 1.6;
word-break: break-all;
}
.wft-expand {
align-self: flex-start;
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11px;
color: var(--text-2);
text-decoration: underline;
cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
.wft-expand:hover {
color: var(--text);
}
}
.wft-outputs {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.wft-out {
display: block;
flex: 0 0 auto;
width: 148px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
overflow: hidden;
background: var(--bg);
}
.wft-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.wft-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.wft-history {
border-top: 1px solid var(--line);
padding-top: 10px;
}
.wft-history-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wft-history-head .field-label {
margin-bottom: 0;
}
.link-danger {
background: none;
border: none;
padding: 4px 8px;
font-family: inherit;
font-size: 12px;
color: var(--err);
cursor: pointer;
border-radius: var(--r-sm);
opacity: 0.85;
display: inline-flex;
align-items: center;
}
@media (hover: hover) and (pointer: fine) {
.link-danger:hover {
opacity: 1;
background: var(--err-soft);
}
}
.wft-empty {
margin: 6px 0 0;
font-size: 12px;
color: var(--text-3);
}
.wft-rows {
list-style: none;
margin: 6px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.wft-rows li {
display: flex;
gap: 10px;
align-items: baseline;
font-size: 12px;
color: var(--text-2);
padding: 4px 6px;
border-radius: var(--r-sm);
}
.wft-rows li.active {
background: var(--bg-3);
}
.wft-row-id {
color: var(--text-3);
font-size: 11px;
}
.wft-row-time {
color: var(--text-3);
font-size: 11px;
white-space: nowrap;
}
.wft-row-result {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wft-row-result.ok {
color: var(--ok);
}
</style>
@@ -357,16 +357,13 @@
.empty-mark {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
border-radius: var(--r-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
background:
radial-gradient(circle at 30% 25%, rgba(139, 92, 246, 0.35) 0%, transparent 60%),
var(--bg-elevated);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
box-shadow: 0 0 32px var(--accent-glow);
color: var(--text-2);
background: var(--bg-3);
border: 1px solid var(--line-2);
margin-bottom: 6px;
}
.empty-title {
@@ -391,21 +388,24 @@
margin-top: 16px;
}
.suggestion {
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-secondary);
border-radius: 999px;
padding: 8px 16px;
font-size: 13px;
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--text-2);
border-radius: var(--r-pill);
padding: var(--sp-3) var(--sp-6);
font-size: var(--fs-body);
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
transition:
color var(--d-state) ease,
border-color var(--d-state) ease,
background-color var(--d-state) ease;
}
.suggestion:hover {
color: var(--text-primary);
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface));
color: var(--text);
border-color: var(--line-2);
background: var(--bg-4);
}
.suggestion:focus-visible {
outline: 2px solid var(--accent);
@@ -430,11 +430,11 @@
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-size: var(--fs-mini);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--agent, var(--accent));
color: var(--agent, var(--text-2));
margin-bottom: 4px;
padding-left: 2px;
}
@@ -443,7 +443,7 @@
height: 8px;
border-radius: 999px;
flex-shrink: 0;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--accent)) 25%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--text-2)) 25%, transparent);
}
.reasoning-wrap {
margin-bottom: 4px;
@@ -451,28 +451,29 @@
}
.bubble {
max-width: 86%;
border-radius: var(--radius-md);
border-radius: var(--r-lg);
padding: 10px 14px;
font-size: 13.5px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
/* 对话气泡按规范 §3.2/§3.6:用户 --bg-3,Agent --bg-2 + --line 描边。 */
.user-bubble {
background: color-mix(in srgb, var(--accent) 16%, var(--bg-surface));
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
color: var(--text-primary);
background: var(--bg-3);
border: 1px solid var(--line);
color: var(--text);
}
.user-bubble.steering {
border-style: dashed;
}
.steer-tag {
display: inline-block;
font-size: 10px;
font-size: var(--fs-mini);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
color: var(--text-3);
margin-bottom: 4px;
}
.user-thumbs {
@@ -486,36 +487,37 @@
width: 48px;
height: 48px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
background: var(--bg-elevated);
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--bg-3);
}
.user-thumb.file {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
color: var(--text-3);
}
.user-text {
white-space: pre-wrap;
}
/* 工作流提及为次级内容:中性底 + --text,不占金色。 */
.wf-chip {
display: inline;
padding: 1px 7px;
margin: 0 1px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 22%, var(--bg-elevated));
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
color: var(--accent);
border-radius: var(--r-pill);
background: var(--bg-4);
border: 1px solid var(--line-2);
color: var(--text);
font-weight: 600;
font-size: 13px;
font-size: var(--fs-body);
white-space: nowrap;
}
.assistant-bubble {
background: var(--bg-surface);
border: 1px solid var(--border);
border-left: 3px solid var(--agent, var(--accent));
color: var(--text-primary);
background: var(--bg-2);
border: 1px solid var(--line);
border-left: 3px solid var(--agent, var(--text-2));
color: var(--text);
}
.question-card {
display: flex;
@@ -525,15 +527,15 @@
max-width: 420px;
margin: 2px 0 6px;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--accent) 7%, var(--bg-surface));
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-2);
}
.question-text {
margin: 0;
font-size: 13px;
font-size: var(--fs-body);
line-height: 1.5;
color: var(--text-primary);
color: var(--text);
white-space: pre-wrap;
}
.question-options {
@@ -542,40 +544,39 @@
gap: 6px;
}
.question-option {
border: 1px solid color-mix(in srgb, var(--agent, var(--accent)) 45%, transparent);
background: var(--bg-elevated);
color: var(--text-primary);
border-radius: var(--radius-sm);
border: 1px solid var(--line-2);
background: var(--bg-3);
color: var(--text);
border-radius: var(--r-sm);
padding: 8px 12px;
font-size: 13px;
font-size: var(--fs-body);
font-weight: 600;
cursor: pointer;
text-align: left;
transition:
background 120ms ease,
border-color 120ms ease;
background-color var(--d-press) ease,
border-color var(--d-press) ease;
}
.question-option:hover:enabled {
background: color-mix(in srgb, var(--agent, var(--accent)) 18%, var(--bg-elevated));
border-color: var(--agent, var(--accent));
background: var(--bg-4);
border-color: var(--agent, var(--text-2));
}
.question-option:disabled {
opacity: 0.5;
cursor: default;
}
.question-option.affirmative {
border-color: var(--accent);
color: var(--accent);
border-color: var(--line-2);
}
.question-hint {
margin: 0;
font-size: 11px;
color: var(--text-muted);
font-size: var(--fs-mini);
color: var(--text-3);
}
.assistant-bubble.err {
border-color: rgba(239, 68, 68, 0.4);
border-left-color: rgba(239, 68, 68, 0.6);
color: var(--error);
border-color: var(--err-line);
border-left-color: var(--err);
color: var(--err);
}
.bubble.live {
display: flex;
@@ -604,7 +605,7 @@
width: 7px;
height: 14px;
margin-left: 2px;
background: var(--accent);
background: var(--run);
animation: blink 1s step-end infinite;
vertical-align: text-bottom;
}
@@ -617,14 +618,15 @@
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
color: var(--text-2);
font-size: var(--fs-body);
}
/* 进行中指示 = 信号青(§1.3),不占金色。 */
.pulse {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--accent);
background: var(--run);
animation: pulse 1.1s ease-in-out infinite;
}
@keyframes pulse {
@@ -711,12 +711,12 @@ let {
display: inline;
padding: 1px 7px;
margin: 0 1px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 22%, var(--bg-elevated));
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
color: var(--accent);
border-radius: var(--r-pill);
background: var(--bg-4);
border: 1px solid var(--line-2);
color: var(--text);
font-weight: 600;
font-size: 13px;
font-size: var(--fs-body);
white-space: nowrap;
user-select: none;
}
@@ -724,12 +724,12 @@ let {
display: inline;
padding: 1px 7px;
margin: 0 1px;
border-radius: 999px;
background: color-mix(in srgb, #38bdf8 20%, var(--bg-elevated));
border: 1px solid color-mix(in srgb, #38bdf8 45%, transparent);
color: #7dd3fc;
border-radius: var(--r-pill);
background: var(--bg-4);
border: 1px dashed var(--line-2);
color: var(--text-2);
font-weight: 600;
font-size: 13px;
font-size: var(--fs-body);
white-space: nowrap;
user-select: none;
}
@@ -77,7 +77,7 @@
}
.head:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.03);
background: var(--bg-3);
}
.name {
flex: 1;
@@ -68,7 +68,7 @@
}
.head:hover {
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.02);
background: var(--bg-3);
}
.icon-wrap {
display: inline-flex;
@@ -135,9 +135,7 @@
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow-float);
padding: 4px;
}
.empty {
@@ -1,22 +1,23 @@
/**
* Agent identity palette: a stable display name + accent color per agent.
*
* The main loop runs with `agent_name = null`; we label it "Calliope". Each
* swarm sub-agent ("planner", "story-agent", …) gets its own fixed color so
* differently-authored bubbles are visually distinct, plus a deterministic
* fallback for any future agent name.
* Identity colors answer "who is speaking" only (avatar / name / bubble
* tint) — they never express task status (spec v2 §3.6). The curated set
* reuses the spec §3.6 avatar five-color family plus one neutral slate, so
* no tailwind rainbow hues leak into the fusion palette; the default
* main-loop agent is neutral `--text-2` slate.
*/
export const DEFAULT_AGENT_NAME = 'Calliope';
// Fixed identity map (keyed by the raw agent_name the backend emits).
const IDENTITIES: Record<string, { label: string; color: string }> = {
planner: { label: 'Planner', color: '#22d3ee' }, // cyan
'story-agent': { label: 'Story Agent', color: '#22c55e' }, // green
'script-agent': { label: 'Script Agent', color: '#f59e0b' }, // amber
'assets-agent': { label: 'Assets Agent', color: '#ec4899' }, // pink
'video-agent': { label: 'Video Agent', color: '#fb923c' }, // orange
'research-agent': { label: 'Research Agent', color: '#a78bfa' }, // violet
planner: { label: 'Planner', color: '#5b8def' }, // avatar-2 blue
'story-agent': { label: 'Story Agent', color: '#3fb97f' }, // avatar-3 green
'script-agent': { label: 'Script Agent', color: '#e8a33d' }, // avatar-1 amber
'assets-agent': { label: 'Assets Agent', color: '#b98be0' }, // avatar-4 violet
'video-agent': { label: 'Video Agent', color: '#d98e6a' }, // avatar-5 clay
'research-agent': { label: 'Research Agent', color: '#8fa3c8' }, // neutral slate
// The five named agents (HEL-613) — the runner stamps their zh names on
// named-session replies; colors mirror the spec §2.1 avatar five-set.
剧本创作: { label: '剧本创作', color: '#e8a33d' },
@@ -26,19 +27,9 @@ const IDENTITIES: Record<string, { label: string; color: string }> = {
镜头脚本: { label: '镜头脚本', color: '#d98e6a' },
};
// Fallback palette for unknown names (stable hash → deterministic color).
const FALLBACK_COLORS = [
'#22d3ee',
'#22c55e',
'#f59e0b',
'#ec4899',
'#fb923c',
'#a78bfa',
'#38bdf8',
'#4ade80',
'#f472b6',
'#facc15',
];
// Fallback palette for unknown names (stable hash → deterministic color),
// narrowed to the avatar identity family + neutral slate (spec v2 §十.4).
const FALLBACK_COLORS = ['#5b8def', '#3fb97f', '#e8a33d', '#b98be0', '#d98e6a', '#8fa3c8'];
function hashString(s: string): number {
let h = 0;
@@ -48,8 +39,8 @@ function hashString(s: string): number {
return h;
}
/** The default "Calliope" accent (matches --accent). */
export const DEFAULT_AGENT_COLOR = '#8b5cf6';
/** Neutral slate (--text-2) — the default agent borrows no signal color. */
export const DEFAULT_AGENT_COLOR = '#aab2c5';
export function agentDisplayName(name: string | null | undefined): string {
const key = (name || '').trim().toLowerCase();
@@ -0,0 +1,410 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import ShellPop from '$lib/components/shell/ShellPop.svelte';
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import ParamChip from './ParamChip.svelte';
import SlotTray from './SlotTray.svelte';
import WorkflowPicker from './WorkflowPicker.svelte';
import { chipValue, type BlockReason, type DeskSchema, type FormValue, type FormValues } from './schema';
/**
* 输入台 (spec-v2 §3.3): slots from the workflow JSON, the prompt, and one
* row of chips — workflow, connection, common params, 参数 — ending in
* 生成 with its reason line. The whole composer takes dropped files.
* ≤760px it collapses to one line and rises as a bottom sheet on focus.
*/
interface Props {
ds: DeskSchema;
values: FormValues;
workflows: Workflow[];
current: Workflow | null;
endpoints: Array<{ id: string; name: string }>;
endpointId: string;
conn: { state: 'ok' | 'fail' | 'untested' | 'dry'; ms?: number | null };
errors: Record<string, string>;
showErrors: boolean;
block: BlockReason | null;
changedCount: number;
submitting: boolean;
/** Shows 已提交 on the button for --d-flash. */
sent: boolean;
slotBusy?: boolean;
promptEl?: HTMLTextAreaElement | null;
onSet: (nodeId: string, value: FormValue) => void;
onPickWorkflow: (wf: Workflow) => void;
onPickEndpoint: (id: string) => void;
onSubmit: () => void;
onReveal: () => void;
onOpenParams: () => void;
onFromResults: (nodeId: string) => void;
}
let {
ds,
values,
workflows,
current,
endpoints,
endpointId,
conn,
errors,
showErrors,
block,
changedCount,
submitting,
sent,
slotBusy = $bindable(false),
promptEl = $bindable(null),
onSet,
onPickWorkflow,
onPickEndpoint,
onSubmit,
onReveal,
onOpenParams,
onFromResults,
}: Props = $props();
let tray = $state<ReturnType<typeof SlotTray> | null>(null);
let root = $state<HTMLElement | null>(null);
let dragDepth = $state(0);
let expanded = $state(false);
let connOpen = $state(false);
const hasSlots = $derived(ds.slots.length + ds.lists.length > 0);
const promptVal = $derived(ds.prompt ? String(values[ds.prompt.nodeId] ?? '') : '');
const promptErr = $derived(showErrors && ds.prompt ? errors[ds.prompt.nodeId] : undefined);
const endpointName = $derived(endpoints.find((e) => e.id === endpointId)?.name ?? t('desk.noConn'));
/** Composer chips: ratio, the width × height pair, resolution, duration, seed. */
const chips = $derived.by(() => {
const out: Array<{ key: string; inputs: ComfyDynamicInput[]; text: string }> = [];
if (ds.ratio) out.push({ key: ds.ratio.nodeId, inputs: [ds.ratio], text: chipValue(ds.ratio, values, ds) });
if (ds.width && ds.height) {
const w = values[ds.width.nodeId] ?? ds.width.defaultValue ?? '?';
const h = values[ds.height.nodeId] ?? ds.height.defaultValue ?? '?';
out.push({ key: ds.width.nodeId, inputs: [ds.width, ds.height], text: `${w}×${h}` });
} else {
for (const inp of [ds.width, ds.height]) {
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: `${inp.label} ${chipValue(inp, values, ds)}` });
}
}
for (const inp of [ds.resolution, ds.duration, ds.seed]) {
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: chipValue(inp, values, ds) });
}
return out;
});
function onDrop(e: DragEvent) {
e.preventDefault();
dragDepth = 0;
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) tray?.dropFiles(files);
}
function hasFiles(e: DragEvent): boolean {
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
}
function onFocusOut(e: FocusEvent) {
const next = e.relatedTarget as Node | null;
if (!next || !root?.contains(next)) expanded = false;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && expanded && !e.defaultPrevented) {
expanded = false;
(document.activeElement as HTMLElement | null)?.blur();
}
}
</script>
<div class="composer-wrap">
<div class="a-sprocket" aria-hidden="true"></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={root}
class="composer"
class:drag={dragDepth > 0}
class:expanded
onfocusin={() => (expanded = true)}
onfocusout={onFocusOut}
onkeydown={onKeydown}
ondragenter={(e) => {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth += 1;
}}
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
ondragover={(e) => hasFiles(e) && e.preventDefault()}
ondrop={onDrop}
>
{#if hasSlots}
<div class="slots">
<SlotTray bind:this={tray} bind:busy={slotBusy} {ds} {values} {showErrors} {errors} {onSet} {onFromResults} />
</div>
{/if}
{#if ds.prompt}
<textarea
bind:this={promptEl}
id="desk-prompt"
class="prompt"
class:invalid={!!promptErr}
rows="2"
value={promptVal}
placeholder={t('desk.promptPh')}
aria-label={t('studio.promptLabel')}
aria-invalid={!!promptErr || undefined}
aria-keyshortcuts="Control+Enter Meta+Enter /"
oninput={(e) => onSet(ds.prompt!.nodeId, e.currentTarget.value)}
></textarea>
{#if promptErr}<p class="a-slot-hint err prompt-err" role="alert">{promptErr}</p>{/if}
{:else if current}
<p class="no-prompt">{t('desk.noPromptInput')}</p>
{/if}
<div class="foot">
<WorkflowPicker {workflows} {current} {endpoints} onPick={onPickWorkflow} />
<div class="conn opt">
<button
type="button"
id="desk-conn"
class="a-chip"
class:err={conn.state === 'fail'}
aria-haspopup="dialog"
aria-expanded={connOpen}
title={t('studio.connLabel')}
onclick={() => (connOpen = !connOpen)}
>
<span class="status-chip-dot {conn.state === 'ok' ? 'run' : conn.state === 'fail' ? 'fail' : 'idle'}"></span>
<span class="lbl">{endpointName}</span>
{#if conn.state === 'ok' && conn.ms != null}<span class="mono">· {Math.round(conn.ms)} ms</span>{/if}
{#if conn.state === 'dry'}<span>· {t('desk.dryRun')}</span>{/if}
</button>
<ShellPop bind:open={connOpen} label={t('studio.connLabel')} placement="top" width="280px">
<div class="a-menu-group">{t('studio.connLabel')}</div>
{#each endpoints as ep (ep.id)}
<button
type="button"
class="a-menu-item"
aria-current={ep.id === endpointId ? 'true' : undefined}
onclick={() => {
onPickEndpoint(ep.id);
connOpen = false;
}}
>
<span class="a-menu-label">{ep.name}</span>
</button>
{/each}
<a class="a-menu-item" href="/connections">
<Icon name="link" size={15} />
<span class="a-menu-label">{t('studio.connManage')}</span>
</a>
</ShellPop>
</div>
{#each chips as c (c.key)}
<div class="opt">
<ParamChip
inputs={c.inputs}
{values}
text={c.text}
invalid={showErrors && c.inputs.some((i) => !!errors[i.nodeId]?.trim())}
{onSet}
/>
</div>
{/each}
{#if ds.common.length + ds.advanced.length > 0}
<button type="button" class="a-chip opt" class:changed={changedCount > 0} onclick={onOpenParams}>
<Icon name="settings" size={14} />
<span class="lbl">
{changedCount > 0 ? t('desk.paramsChanged', { n: changedCount }) : t('desk.paramsAll')}
</span>
</button>
{/if}
<div class="submit">
{#if block}
<button type="button" class="why" class:err={block.tone === 'err'} onclick={onReveal}>
{block.text}
</button>
{/if}
<button
type="button"
class="a-btn primary gen"
class:sent
disabled={!!block || submitting || slotBusy}
aria-keyshortcuts="Control+Enter Meta+Enter"
title={t('desk.genHint')}
onclick={onSubmit}
>
<span class="l-idle"><Icon name="sparkle" size={13} />{t('desk.generate')}</span>
<span class="l-sent" aria-hidden={!sent}><Icon name="check" size={13} />{t('desk.sent')}</span>
</button>
</div>
</div>
</div>
</div>
<style>
.composer-wrap {
padding: var(--sp-2) var(--sp-7) var(--sp-6);
}
.a-sprocket {
margin: 0 var(--sp-2) var(--sp-3);
}
.composer {
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
background: var(--composer-bg);
transition:
border-color var(--d-state) ease,
box-shadow var(--d-state) ease;
}
.composer:focus-within {
border-color: var(--accent-line);
box-shadow: var(--composer-focus);
}
.composer.drag {
border-color: var(--accent-line);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.slots {
max-height: 40vh;
overflow-y: auto;
}
.prompt {
display: block;
box-sizing: border-box;
width: 100%;
min-height: 52px;
max-height: 180px;
padding: var(--sp-5) var(--sp-6) var(--sp-2);
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
line-height: 1.6;
resize: none;
field-sizing: content;
}
.prompt:focus-visible {
outline: 0;
}
.prompt::placeholder {
color: var(--text-3);
}
.prompt-err,
.no-prompt {
margin: 0;
padding: 0 var(--sp-6);
}
.no-prompt {
padding-top: var(--sp-5);
font-size: var(--fs-aux);
color: var(--text-3);
}
.foot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3) var(--sp-3);
}
.conn {
position: relative;
}
.submit {
display: flex;
align-items: center;
gap: var(--sp-4);
margin-left: auto;
}
.why {
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: var(--fs-aux);
color: var(--warn);
white-space: nowrap;
text-decoration: underline dotted;
text-underline-offset: 3px;
cursor: pointer;
}
.why.err {
color: var(--err);
}
/* 生成:按下即缩,提交后文字换成「已提交」约 --d-flash,期间仍可再次提交 */
.gen {
display: inline-grid;
min-width: 84px;
border-radius: var(--r-pill);
}
.gen > span {
grid-area: 1 / 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-1);
transition:
opacity var(--d-state) ease,
transform var(--d-pop) var(--ease-out);
}
.gen .l-sent {
opacity: 0;
transform: translateY(8px);
}
.gen.sent .l-idle {
opacity: 0;
transform: translateY(-8px);
}
.gen.sent .l-sent {
opacity: 1;
transform: none;
}
:global(:root[data-reduce='1']) .gen > span {
transform: none;
}
@media (max-width: 1179px) {
.composer-wrap {
padding: var(--sp-2) var(--sp-6) var(--sp-5);
}
}
@media (max-width: 760px) {
.composer-wrap {
padding: var(--sp-1) var(--sp-4) var(--sp-4);
}
.composer:not(.expanded) .slots,
.composer:not(.expanded) .opt {
display: none;
}
.composer:not(.expanded) .prompt {
min-height: 40px;
max-height: 40px;
padding: var(--sp-4) var(--sp-5) var(--sp-1);
white-space: nowrap;
overflow: hidden;
}
.composer.expanded {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
z-index: 45;
border-bottom: 0;
border-radius: var(--r-lg) var(--r-lg) 0 0;
box-shadow: var(--shadow-float);
max-height: calc(100dvh - var(--topbar-h) - var(--tabbar-h));
overflow-y: auto;
}
.composer.expanded .slots {
max-height: none;
}
.submit {
gap: var(--sp-3);
}
}
</style>
@@ -0,0 +1,70 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
import { backHref, type DeskContext } from './context';
/**
* 上下文条 (spec-v2 §3.3): the desk was opened from a creation stage.
* Says where from, and plainly that takes stay in the desk feed — the
* desk's generate call has no shot/asset link to fill back.
*/
interface Props {
ctx: DeskContext;
projectTitle: string;
onDismiss: () => void;
}
let { ctx, projectTitle, onDismiss }: Props = $props();
const stageName = $derived(t(`workbench.stage.${ctx.stage}`));
</script>
<div class="context-bar" role="status">
<Icon name="link" size={14} />
<span class="from">
<b>{t('desk.ctxFrom', { project: projectTitle })}</b>{#if ctx.label}&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,565 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import type { Job, Workflow } from '$lib/comfy/types';
import { progressFor } from '$lib/jobProgress';
import { jobStatusInfo } from '$lib/jobStatus';
import { parseDbTs } from '$lib/dbtime';
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
import { t } from '$lib/i18n.svelte';
import { exportLastFrame } from '$lib/media/lastFrame';
import { usageLabel } from './schema';
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf, specOf } from './takes';
/**
* 检视区 (spec-v2 §3.3): 结果 shows the selected take on the projection
* stage with its spec, inputs and workflow; 参数 hosts the full form.
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
* fullscreen — `overlay` / `open` drive the last two.
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
* ↑↓/J/K 即时无动画), a finished/just-picked video auto-plays muted,
* failed takes get 重试, and a done take whose files no longer load
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
*/
interface Props {
tab: 'result' | 'params';
job: Job | null;
wf: Workflow | undefined;
nowMs: number;
open: boolean;
/** Collapsed or off-screen — nothing inside may take focus. */
inactive: boolean;
/** Current workflow can take an image output (送入当前图片槽). */
canSendToSlot: boolean;
/** Animate this result change (pointer pick) — 150ms cross-fade. */
crossfade: boolean;
/** Bump to auto-play the stage video (pointer pick / completion). */
playSignal: number;
/** The stage <video> element, for the desk's space-bar toggle. */
videoEl?: HTMLVideoElement | null;
params: Snippet;
onClose: () => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onSendToSlot: (path: string) => void;
onDelete: (job: Job) => void;
}
let {
tab = $bindable(),
job,
wf,
nowMs,
open,
inactive,
canSendToSlot,
crossfade,
playSignal,
videoEl = $bindable(null),
params,
onClose,
onReuse,
onRetry,
onSendToSlot,
onDelete,
}: Props = $props();
let outIndex = $state(0);
$effect.pre(() => {
void job?.id;
outIndex = 0;
});
const st = $derived(job ? jobStatusInfo(job) : null);
// §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files
// cannot be fetched — the failed case stays red.
const gonePaths = $derived(missingOutputsOf(job));
const fileGone = $derived(gonePaths.length > 0);
const outputs = $derived(job && st?.tone === 'ok' ? job.output_paths.filter((p) => !gonePaths.includes(p)) : []);
const output = $derived(outputs[Math.min(outIndex, outputs.length - 1)] ?? null);
const allOutputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
const video = $derived(!!job && !!output && isVideoOutput(job, output));
const wfDeleted = $derived(!!job && job.workflow_id == null);
const prompt = $derived(job ? promptOf(job, wf) : '');
const inputs = $derived(job ? inputsOf(job, wf) : []);
const spec = $derived(job ? specOf(job, wf) : []);
const endpoint = $derived(
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ?? '',
);
const progress = $derived.by(() => {
if (!job || job.status !== 'running') return 0;
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase;
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
return live?.progress ?? 3;
});
let dims = $state('');
$effect.pre(() => {
void output;
dims = '';
});
function noteImg(e: Event) {
const img = e.currentTarget as HTMLImageElement;
dims = `${img.naturalWidth}×${img.naturalHeight}`;
}
function noteVideo(e: Event) {
const v = e.currentTarget as HTMLVideoElement;
dims = `${v.videoWidth}×${v.videoHeight}`;
}
// 完成或点选后自动播放(静音);键盘切换不触发。
$effect(() => {
void playSignal;
if (inactive || tab !== 'result' || !videoEl) return;
videoEl.muted = true;
videoEl.play().catch(() => {});
});
function reduced(): boolean {
return (
document.documentElement.dataset.reduce === '1' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
/** 150ms 交叉淡变 on pointer picks; keyboard switches stay instant. */
function xfade(_node: Element) {
const ms = crossfade ? (reduced() ? 120 : 150) : 0;
return { duration: ms, css: (t: number) => `opacity: ${t}` };
}
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
let frameBusy = $state(false);
async function exportTail() {
const src = assetUrl(output);
if (!src || frameBusy) return;
frameBusy = true;
try {
await exportLastFrame(src);
} finally {
frameBusy = false;
}
}
</script>
<aside class="inspector" class:open inert={inactive} aria-label={t('desk.inspector')}>
<div class="tabs">
<div class="a-seg" role="group" aria-label={t('desk.inspector')}>
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}>{t('desk.tabResult')}</button>
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}>{t('desk.tabParams')}</button>
</div>
<span class="keys"><span class="a-kbd">]</span>{t('desk.keyInspector')}</span>
<button type="button" class="a-btn ghost sm close" aria-label={t('desk.closeInspector')} onclick={onClose}>
<Icon name="close" size={14} />
</button>
</div>
<div class="body">
{#if tab === 'params'}
{@render params()}
{:else if !job || !st}
<div class="a-stage stage"><div class="stage-empty">{t('desk.pickTake')}</div></div>
{:else}
<div class="a-stage stage">
{#key mediaKey}
<div class="stage-media" in:xfade out:xfade>
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={videoEl}
src={assetUrl(output)}
controls
playsinline
preload="metadata"
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
{:else if output}
<img
src={assetUrl(output)}
alt={prompt || t('desk.tabResult')}
onload={(e) => {
clearOutputMissing(output);
noteImg(e);
}}
onerror={() => output && markOutputMissing(output)}
/>
{:else if fileGone}
<div class="stage-empty gone">
<span>{t('desk.missingTitle')}</span>
<span class="sub">{t('desk.missingSub')}</span>
</div>
{:else}
<div class="stage-empty">
<span>{st.label}</span>
<span class="sub">{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span>
{#if progress > 0}
<div class="a-progress stage-bar" style:--p={progress / 100}><i></i></div>
{/if}
</div>
{/if}
</div>
{/key}
</div>
{#if allOutputs.length > 1}
<div class="strip" role="group" aria-label={t('desk.outputs')}>
{#each allOutputs as p, i (p)}
<button
type="button"
class="strip-item"
class:gone={gonePaths.includes(p)}
aria-pressed={p === output}
aria-label={t('desk.outputN', { n: i + 1 })}
onclick={() => (outIndex = i)}
>
{#if isVideoOutput(job, p)}
<Icon name="film" size={14} />
{:else}
<img src={assetUrl(p)} alt="" loading="lazy" />
{/if}
</button>
{/each}
</div>
{/if}
<div class="meta">
{#if dims}<span class="mono"><b>{dims}</b></span>{/if}
{#each spec as s (s.label)}
{#if !(dims && s.label === t('desk.specSize'))}
<span>{s.label} <b class="mono">{s.value}</b></span>
{/if}
{/each}
<span>{t('desk.elapsed')} <b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span>
{#if endpoint}<span>{endpoint}</span>{/if}
</div>
<div class="actions">
{#if fileGone && !outputs.length}
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('desk.redo')}</button>
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('desk.removeRecord')}</button>
{:else}
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}>{t('desk.reuseToComposer')}</button>
{#if output && !video && canSendToSlot}
<button type="button" class="a-btn secondary sm" onclick={() => onSendToSlot(output)}>
{t('desk.sendToSlot')}
</button>
{/if}
{#if output}
<a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a>
{/if}
{#if output && video}
<button
type="button"
class="a-btn secondary sm"
title={t('lastFrame.hint')}
disabled={frameBusy}
aria-busy={frameBusy}
onclick={exportTail}
>
{frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
</button>
{/if}
{#if st.tone === 'fail'}
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button>
{/if}
{#if job.status !== 'pending' && job.status !== 'running'}
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
{/if}
{/if}
</div>
{#if st.tone === 'fail'}
<div class="note err" role="alert">{st.detail}</div>
{:else if fileGone}
<div class="note warn" role="alert">{t('jobst.missingDetail')}</div>
{/if}
{#if inputs.length || prompt}
<div class="section-title">{t('desk.inputs')}</div>
{#if inputs.length}
<div class="inputs">
{#each inputs as inp (inp.label + inp.path)}
{#if inp.kind === 'image'}
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
{:else}
<span class="mini glyph" title={inp.label}><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span>
{/if}
{/each}
</div>
{/if}
{#if prompt}<p class="prompt">{prompt}</p>{/if}
{/if}
<div class="section-title">{t('desk.workflow')}</div>
<dl class="kv">
<dt>{t('desk.kvName')}</dt>
<dd>
{wf?.name ?? t('studio.jobName', { id: job.id })}
{#if wfDeleted}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
</dd>
{#if wf?.usage}
<dt>{t('desk.kvUsage')}</dt>
<dd>{usageLabel(wf.usage)}</dd>
{/if}
<dt>{t('desk.kvCreated')}</dt>
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
{#if job.comfy_prompt_id}
<dt>{t('desk.kvPromptId')}</dt>
<dd class="mono">{job.comfy_prompt_id}</dd>
{/if}
</dl>
{/if}
</div>
</aside>
<style>
.inspector {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
border-left: 1px solid var(--line);
background: var(--bg);
}
.tabs {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--line);
}
.keys {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
font-size: var(--fs-mini);
color: var(--text-3);
}
.close {
padding: 0 var(--sp-3);
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--sp-6);
}
.stage {
aspect-ratio: 16 / 10;
display: grid;
place-items: center;
}
.stage-media {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stage-media > img,
.stage-media > video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.stage-empty {
display: grid;
place-items: center;
gap: var(--sp-2);
padding: var(--sp-6);
text-align: center;
color: var(--text-3);
font-size: var(--fs-aux);
}
.stage-empty.gone {
color: var(--warn);
}
.stage-empty .sub {
color: var(--text-3);
font-size: var(--fs-mini);
}
.stage-bar {
width: 160px;
}
.strip {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.strip-item {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
overflow: hidden;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--bg-3);
color: var(--text-3);
cursor: pointer;
}
.strip-item[aria-pressed='true'] {
border-color: var(--accent);
}
.strip-item.gone {
border-style: dashed;
border-color: var(--warn);
color: var(--warn);
}
.strip-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-5);
margin: var(--sp-5) 0;
font-size: var(--fs-aux);
color: var(--text-3);
}
.meta b {
color: var(--text-2);
font-weight: 500;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.note {
margin-top: var(--sp-5);
padding: var(--sp-3) var(--sp-4);
border-radius: var(--r-md);
font-size: var(--fs-aux);
word-break: break-word;
}
.note.err {
background: var(--err-soft);
color: var(--err);
}
.note.warn {
background: var(--warn-soft);
color: var(--warn);
}
.section-title {
margin: var(--sp-7) 0 var(--sp-3);
font-size: var(--fs-aux);
color: var(--text-3);
}
.inputs {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.mini {
width: 40px;
height: 40px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
object-fit: cover;
}
.mini.glyph {
display: grid;
place-items: center;
background: var(--bg-3);
color: var(--text-3);
box-sizing: border-box;
}
.prompt {
margin: 0;
padding: var(--sp-4) var(--sp-5);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-2);
font-size: var(--fs-aux);
white-space: pre-wrap;
word-break: break-word;
}
.kv {
display: grid;
grid-template-columns: 92px 1fr;
gap: var(--sp-2) var(--sp-4);
margin: 0;
font-size: var(--fs-aux);
}
.kv dt {
color: var(--text-3);
}
.kv dd {
margin: 0;
color: var(--text-2);
word-break: break-all;
}
.kv .wf-gone {
margin-left: var(--sp-2);
}
/* ≥1180: a column of the desk grid; the close button hides it (]) */
@media (max-width: 1179px) {
.inspector {
position: fixed;
top: var(--topbar-h);
right: 0;
bottom: 0;
z-index: 40;
width: min(420px, 100vw);
box-shadow: var(--shadow-float);
transform: translateX(100%);
visibility: hidden;
transition:
transform var(--d-drawer) var(--ease-drawer),
visibility 0s linear var(--d-drawer);
}
.inspector.open {
transform: none;
visibility: visible;
transition:
transform var(--d-drawer) var(--ease-drawer),
visibility 0s;
}
:global(:root[data-reduce='1']) .inspector {
transform: none;
opacity: 0;
transition:
opacity var(--d-drawer) ease,
visibility 0s linear var(--d-drawer);
}
:global(:root[data-reduce='1']) .inspector.open {
opacity: 1;
transition:
opacity var(--d-drawer) ease,
visibility 0s;
}
.keys {
display: none;
}
.close {
margin-left: auto;
}
}
@media (max-width: 760px) {
.inspector {
top: 0;
z-index: 70;
width: 100vw;
border-left: 0;
}
.body {
padding: var(--sp-5);
}
}
</style>
@@ -0,0 +1,179 @@
<script lang="ts">
import ShellPop from '$lib/components/shell/ShellPop.svelte';
import type { ComfyDynamicInput } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { inputRole, isChanged, type FormValue, type FormValues } from './schema';
/**
* One common parameter as a composer chip (spec-v2 §3.3 参数芯片): the
* pop edits it in place — options list for selects, a number field for
* the rest (seed adds 随机) — and 恢复原值 returns to the workflow value.
* `inputs` holds two entries for the width × height pair.
*/
interface Props {
inputs: ComfyDynamicInput[];
values: FormValues;
text: string;
title?: string;
invalid?: boolean;
onSet: (nodeId: string, value: FormValue) => void;
}
let { inputs, values, text, title, invalid = false, onSet }: Props = $props();
let open = $state(false);
const changed = $derived(inputs.some((i) => isChanged(i, values)));
const single = $derived(inputs.length === 1 ? inputs[0] : null);
const uid = Math.random().toString(36).slice(2, 8);
function current(inp: ComfyDynamicInput): string {
const v = values[inp.nodeId] ?? inp.defaultValue ?? '';
return Array.isArray(v) ? v.join(',') : String(v);
}
function setNumber(inp: ComfyDynamicInput, raw: string) {
if (raw === '') return onSet(inp.nodeId, '');
const n = Number(raw);
onSet(inp.nodeId, Number.isNaN(n) ? raw : n);
}
function restore() {
for (const inp of inputs) onSet(inp.nodeId, inp.defaultValue ?? '');
}
function randomSeed(inp: ComfyDynamicInput) {
const max = Math.min(inp.max ?? 2 ** 32 - 1, Number.MAX_SAFE_INTEGER);
const min = Math.max(inp.min ?? 0, 0);
onSet(inp.nodeId, min + Math.floor(Math.random() * (max - min + 1)));
}
function pickOption(inp: ComfyDynamicInput, opt: string) {
onSet(inp.nodeId, opt);
open = false;
}
</script>
<div class="param-chip">
<button
type="button"
class="a-chip"
class:changed={changed && !invalid}
class:err={invalid}
aria-haspopup="dialog"
aria-expanded={open}
title={title ?? inputs.map((i) => i.label).join(' × ')}
onclick={() => (open = !open)}
>
<span class="lbl">{text}</span>
</button>
<ShellPop bind:open label={inputs.map((i) => i.label).join(' × ')} placement="top" width="260px">
{#if single?.kind === 'select' && single.options?.length}
<div class="a-menu-group">{single.label}</div>
{#each single.options as opt (opt)}
<button
type="button"
class="a-menu-item"
aria-current={current(single) === opt ? 'true' : undefined}
onclick={() => pickOption(single, opt)}
>
<span class="a-menu-label">{opt}</span>
{#if String(single.defaultValue ?? '') === opt}
<span class="a-menu-meta">{t('desk.origValue')}</span>
{/if}
</button>
{/each}
{:else}
<div class="fields">
{#each inputs as inp (inp.nodeId)}
{@const id = `pc-${uid}-${inp.nodeId.replace(/[^a-zA-Z0-9_-]/g, '')}`}
<div class="field-row">
<label class="field-label" for={id}>
{inp.label}
{#if inp.defaultValue !== undefined && inp.defaultValue !== ''}
<span class="orig mono">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
{/if}
</label>
<div class="inline">
{#if inp.kind === 'number'}
<input
{id}
class="a-input mono"
class:invalid
type="number"
inputmode="numeric"
value={current(inp)}
oninput={(e) => setNumber(inp, e.currentTarget.value)}
onkeydown={(e) => e.key === 'Enter' && (open = false)}
/>
{:else}
<input
{id}
class="a-input"
class:invalid
type="text"
value={current(inp)}
oninput={(e) => onSet(inp.nodeId, e.currentTarget.value)}
onkeydown={(e) => e.key === 'Enter' && (open = false)}
/>
{/if}
{#if inputRole(inp) === 'seed'}
<button type="button" class="a-btn secondary sm" onclick={() => randomSeed(inp)}>
{t('desk.seedRandom')}
</button>
{/if}
</div>
{#if inp.min != null || inp.max != null}
<span class="field-hint mono">{inp.min ?? '−∞'} – {inp.max ?? '∞'}</span>
{/if}
</div>
{/each}
</div>
{/if}
{#if changed}
<div class="foot">
<button type="button" class="a-btn ghost sm" onclick={restore}>{t('desk.restoreOrig')}</button>
</div>
{/if}
</ShellPop>
</div>
<style>
.param-chip {
position: relative;
min-width: 0;
}
.fields {
display: grid;
gap: var(--sp-5);
padding: var(--sp-3);
}
.field-row {
display: grid;
gap: var(--sp-2);
}
.field-row .field-label {
display: flex;
justify-content: space-between;
gap: var(--sp-3);
margin: 0;
}
.orig {
font-size: var(--fs-mini);
color: var(--text-3);
font-weight: 400;
}
.inline {
display: flex;
gap: var(--sp-2);
}
.field-row .field-hint {
margin: 0;
}
.foot {
display: flex;
justify-content: flex-end;
padding: var(--sp-2) var(--sp-1) 0;
border-top: 1px solid var(--line);
margin-top: var(--sp-2);
}
</style>
@@ -0,0 +1,198 @@
<script lang="ts">
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import type { DeskSchema, FormValue, FormValues } from './schema';
/**
* 参数 (spec-v2 §3.3 检视区 · 参数): every non-media input the workflow
* declares, each with its 原值 and a gold edge once changed. Common ones
* first; the rest fold under 高级参数 — untouched, they keep the
* workflow's own values.
*/
interface Props {
workflow: Workflow | null;
ds: DeskSchema;
values: FormValues;
errors: Record<string, string>;
showErrors: boolean;
changedCount: number;
onSet: (nodeId: string, value: FormValue) => void;
onRestoreAll: () => void;
}
let { workflow, ds, values, errors, showErrors, changedCount, onSet, onRestoreAll }: Props = $props();
let advancedOpen = $state(false);
const brokenReasons = $derived(
(workflow?.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message),
);
const advancedErr = $derived(showErrors && ds.advanced.some((i) => !!errors[i.nodeId]?.trim()));
$effect(() => {
if (advancedErr) advancedOpen = true;
});
function patch(inputs: ComfyDynamicInput[], next: Record<string, string | number>) {
for (const inp of inputs) {
const v = next[inp.nodeId];
if (v !== undefined && v !== values[inp.nodeId]) onSet(inp.nodeId, v);
}
}
</script>
<div class="params">
{#if !workflow}
<p class="note">{t('desk.pickWorkflow')}</p>
{:else}
{#if brokenReasons.length}
<div class="note err" role="alert">
<strong>{t('studio.wfBrokenTitle')}</strong>
<ul>
{#each brokenReasons as r (r)}<li>{r}</li>{/each}
</ul>
<span>{t('studio.wfBrokenHint')}</span>
</div>
{/if}
{#if !(workflow.input_schema ?? []).length}
<p class="note">{t('studio.wfNoParamsNote')}</p>
{:else if (workflow.input_schema ?? []).some((i) => i.auto)}
<p class="note">{t('studio.wfAutoParamsNote', { n: workflow.input_schema.length })}</p>
{/if}
{#if ds.common.length}
<div class="section-title">{t('desk.paramsCommon')}</div>
<ComfyDynamicForm
inputs={ds.common}
values={values as Record<string, string | number>}
quiet
showOrig
{errors}
{showErrors}
onChange={(v) => patch(ds.common, v)}
/>
{/if}
{#if ds.advanced.length}
<div class="fold">
<button
type="button"
class="fold-head"
class:err={advancedErr}
aria-expanded={advancedOpen}
aria-controls="desk-advanced"
onclick={() => (advancedOpen = !advancedOpen)}
>
<span class="chev" aria-hidden="true"><Icon name="play" size={10} /></span>
{t('desk.advanced', { n: ds.advanced.length })}
</button>
<div id="desk-advanced" class="collapse" class:open={advancedOpen} inert={!advancedOpen}>
<div class="collapse-in">
<ComfyDynamicForm
inputs={ds.advanced}
values={values as Record<string, string | number>}
quiet
showOrig
{errors}
{showErrors}
onChange={(v) => patch(ds.advanced, v)}
/>
</div>
</div>
</div>
{/if}
{#if changedCount > 0}
<div class="restore">
<button type="button" class="a-btn ghost sm" onclick={onRestoreAll}>
<Icon name="retry" size={13} />
{t('desk.restoreAll')}
</button>
</div>
{/if}
{/if}
</div>
<style>
.params {
display: grid;
gap: var(--sp-4);
}
.note {
margin: 0;
padding: var(--sp-3) var(--sp-4);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
font-size: var(--fs-aux);
}
.note.err {
background: var(--err-soft);
color: var(--err);
}
.note ul {
margin: var(--sp-2) 0;
padding-left: 18px;
}
.section-title {
font-size: var(--fs-aux);
color: var(--text-3);
}
.fold {
border-top: 1px solid var(--line);
}
.fold-head {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: var(--sp-4) 0;
border: 0;
background: none;
color: var(--text-2);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
cursor: pointer;
transition: color var(--d-state) ease;
}
.fold-head.err {
color: var(--err);
}
.chev {
display: inline-grid;
transition: transform var(--d-pop) var(--ease-out);
}
.fold-head[aria-expanded='true'] .chev {
transform: rotate(90deg);
}
@media (hover: hover) and (pointer: fine) {
.fold-head:hover {
color: var(--text);
}
}
.collapse {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows var(--d-enter) var(--ease-out),
opacity var(--d-state) ease;
}
.collapse.open {
grid-template-rows: 1fr;
opacity: 1;
}
.collapse-in {
min-height: 0;
overflow: hidden;
}
.collapse.open .collapse-in {
padding-bottom: var(--sp-2);
}
.restore {
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,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,568 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import type { Job, Workflow } from '$lib/comfy/types';
import { progressFor } from '$lib/jobProgress';
import { jobStatusInfo } from '$lib/jobStatus';
import { parseDbTs } from '$lib/dbtime';
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from './schema';
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf } from './takes';
/**
* 一条记录 (spec-v2 §3.3 记录卡): workflow · usage · time, the prompt, the
* inputs it used, its real state (phase / progress / time / error) and
* the first output. The whole card selects; actions sit above it.
* D 批 (HEL-654): the gold sel-mark frame slides between cards on
* pointer selection (driven by the feed), new takes enter with a
* 220ms rise, completion flashes once, failure shakes once, a done
* take whose files no longer load turns amber (文件缺失) with
* 用相同参数重做 / 移除记录, and takes on a deleted workflow are
* marked as such instead of pretending nothing changed.
*/
interface Props {
job: Job;
wf: Workflow | undefined;
selected: boolean;
nowMs: number;
busy?: boolean;
/** Play the 220ms entrance rise (new take / undo restore). */
enter?: boolean;
/** One-shot 900ms gold flash on completion. */
flash?: boolean;
/** One-shot failure shake. */
shake?: boolean;
onSelect: () => void;
onReuse: () => void;
onRetry: () => void;
onCancel: () => void;
onDelete: () => void;
}
let {
job,
wf,
selected,
nowMs,
busy = false,
enter = false,
flash = false,
shake = false,
onSelect,
onReuse,
onRetry,
onCancel,
onDelete,
}: Props = $props();
const st = $derived(jobStatusInfo(job));
// §3.4 红/琥珀界线: red follows the backend task status only. Amber is
// reserved for a SUCCEEDED take whose product files no longer load.
const gonePaths = $derived(missingOutputsOf(job));
const fileGone = $derived(gonePaths.length > 0);
const tone = $derived(fileGone ? 'warn' : st.tone === 'queue' ? '' : st.tone === 'fail' ? 'err' : st.tone);
const label = $derived(fileGone ? t('jobst.missing') : st.label);
const wfDeleted = $derived(job.workflow_id == null);
const prompt = $derived(promptOf(job, wf));
const inputs = $derived(inputsOf(job, wf).filter((i) => i.kind === 'image'));
const output = $derived(
fileGone
? (job.output_paths.find((p) => !gonePaths.includes(p)) ?? null)
: (job.output_paths[0] ?? null),
);
const progress = $derived.by(() => {
if (job.status !== 'running') return 0;
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase;
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
return live?.progress ?? 3;
});
const active = $derived(job.status === 'pending' || job.status === 'running');
const name = $derived(wf?.name ?? t('studio.jobName', { id: job.id }));
let portrait = $state(false);
let clipSec = $state<number | null>(null);
let errOpen = $state(false);
function noteImage(e: Event) {
const img = e.currentTarget as HTMLImageElement;
portrait = img.naturalHeight > img.naturalWidth;
}
function noteVideo(e: Event) {
const v = e.currentTarget as HTMLVideoElement;
portrait = v.videoHeight > v.videoWidth;
if (Number.isFinite(v.duration)) clipSec = v.duration;
}
</script>
<article
class="take"
class:selected
class:enter
class:done-flash={flash}
class:shake
data-job={job.id}
>
<span class="sel-mark" aria-hidden="true"></span>
<button
type="button"
class="hit"
aria-pressed={selected}
aria-label={`${name} · ${label}`}
onclick={onSelect}
></button>
<div class="head">
<span class="name">{name}</span>
{#if wfDeleted}<span class="a-tag">{t('studio.wfDeletedTag')}</span>{/if}
{#if wf?.usage}<span class="a-tag">{usageLabel(wf.usage)}</span>{/if}
<span class="meta mono">{fmtClock(parseDbTs(job.created_at))}</span>
</div>
{#if prompt}<p class="prompt">{prompt}</p>{/if}
{#if inputs.length}
<div class="inputs" aria-label={t('desk.inputs')}>
{#each inputs.slice(0, 5) as inp (inp.path + inp.label)}
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
{/each}
{#if inputs.length > 5}<span class="more">+{inputs.length - 5}</span>{/if}
</div>
{/if}
<div class="state">
<span class="a-tag {tone}">{label}</span>
{#if st.tone === 'run'}
{#if progress > 0}
<div class="a-progress bar" style:--p={progress / 100}><i></i></div>
{/if}
<span class="txt">{fmtDur(elapsedSec(job, nowMs))}</span>
{:else if st.tone === 'ok' && !fileGone}
<span class="txt">{t('desk.took', { d: fmtDur(elapsedSec(job, nowMs)) })}</span>
{:else if st.tone === 'fail'}
<button
type="button"
class="err-txt"
class:open={errOpen}
title={st.detail}
aria-expanded={errOpen}
onclick={() => (errOpen = !errOpen)}
>
{st.detail}
</button>
{:else if fileGone}
<span class="gone-txt">{t('jobst.missingDetail')}</span>
{:else}
<span class="txt">{st.detail}</span>
{/if}
<div class="actions">
{#if fileGone}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>
{t('desk.redo')}
</button>
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('desk.removeRecord')}</button>
{:else}
<button type="button" class="a-btn ghost xs" onclick={onReuse}>{t('desk.reuse')}</button>
{#if st.tone === 'fail' && job.error !== 'cancelled'}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>{t('common.retry')}</button>
{/if}
{#if active}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onCancel}>{t('common.cancel')}</button>
{/if}
{#if output}
<a class="a-btn ghost xs" href={assetUrl(output)} download>{t('desk.download')}</a>
{/if}
{#if !active}
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('studio.deleteBtn')}</button>
{/if}
{/if}
</div>
</div>
<div class="out">
{#if output}
<div class="thumb" class:portrait>
{#if isVideoOutput(job, output)}
<video
src={assetUrl(output)}
preload="metadata"
muted
playsinline
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
<span class="play" aria-hidden="true"><Icon name="play" size={10} /></span>
{#if clipSec != null}<span class="badge mono">{fmtDur(clipSec)}</span>{/if}
{:else}
<img
src={assetUrl(output)}
alt=""
loading="lazy"
onload={(e) => {
clearOutputMissing(output);
noteImage(e);
}}
onerror={() => output && markOutputMissing(output)}
/>
{/if}
{#if job.output_paths.length > 1}<span class="count mono">×{job.output_paths.length}</span>{/if}
</div>
{:else if active}
<div class="thumb pending">{st.label}</div>
{:else if fileGone}
<div class="thumb gone">{t('jobst.missing')}</div>
{:else}
<div class="thumb missing">{t('desk.notGenerated')}</div>
{/if}
</div>
</article>
<style>
.take {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-4) var(--sp-6);
margin-bottom: var(--sp-3);
padding: var(--sp-5) var(--sp-6);
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--bg-2);
transition:
border-color var(--d-state) ease,
background-color var(--d-state) ease,
box-shadow var(--d-state) ease;
}
/* 选中框:金色描边从上一条记录滑到新选中的记录(feed 驱动 WAAPI) */
.sel-mark {
position: absolute;
inset: -1px;
z-index: 3;
display: none;
border: 1px solid var(--accent-line);
border-radius: inherit;
pointer-events: none;
}
.take.selected {
border-color: transparent;
background: var(--accent-wash), var(--bg-3);
box-shadow: var(--sel-shadow);
}
.take.selected > .sel-mark {
display: block;
}
@media (hover: hover) and (pointer: fine) {
.take:hover:not(.selected) {
border-color: var(--line-2);
background: var(--bg-3);
}
}
/* 新记录 / 撤销恢复:220ms 上移 8px 入场(减动效只余淡变) */
.take.enter {
animation: take-in var(--d-enter) var(--ease-out);
}
@keyframes take-in {
from {
opacity: 0;
transform: translateY(8px);
}
}
/* 完成一次性金色光晕 900ms;失败原地轻抖一次 */
.take.done-flash {
animation: done-flash var(--d-flash) var(--ease-out);
}
@keyframes done-flash {
from {
box-shadow: var(--accent-glow), 0 0 0 1px var(--accent-line);
}
}
.take.shake {
animation: take-shake 320ms var(--ease-out);
}
@keyframes take-shake {
20% {
transform: translateX(-4px);
}
45% {
transform: translateX(4px);
}
70% {
transform: translateX(-2px);
}
}
:global(:root[data-reduce='1']) .take.enter {
animation: take-fade var(--d-state) ease;
}
:global(:root[data-reduce='1']) .take.done-flash,
:global(:root[data-reduce='1']) .take.shake {
animation: none;
}
@keyframes take-fade {
from {
opacity: 0;
}
}
.hit {
position: absolute;
inset: 0;
z-index: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.hit:focus-visible {
outline-offset: -2px;
border-radius: var(--r-lg);
}
.head,
.prompt,
.inputs,
.state {
grid-column: 1;
position: relative;
pointer-events: none;
}
.head {
display: flex;
align-items: center;
gap: var(--sp-3);
min-width: 0;
}
.name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
color: var(--text-3);
font-size: var(--fs-aux);
white-space: nowrap;
}
.prompt {
margin: 0;
color: var(--text-2);
font-size: var(--fs-body-sm);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.inputs {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.mini {
width: 26px;
height: 26px;
border-radius: 5px;
object-fit: cover;
border: 1px solid var(--line-2);
}
.more {
padding-left: 2px;
font-size: var(--fs-mini);
color: var(--text-3);
}
.state {
display: flex;
align-items: center;
gap: var(--sp-3);
min-height: 22px;
font-size: var(--fs-aux);
}
.bar {
flex: 0 0 140px;
}
.txt {
color: var(--text-2);
white-space: nowrap;
}
.gone-txt {
min-width: 0;
color: var(--warn);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 失败:一行中文结论自动展开,点开看完整详情 */
.err-txt {
min-width: 0;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
color: var(--err);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: auto;
cursor: pointer;
}
.err-txt.open {
white-space: normal;
word-break: break-word;
}
.err-txt:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--r-sm);
}
.actions {
display: flex;
gap: var(--sp-1);
margin-left: auto;
pointer-events: auto;
opacity: 0;
transition: opacity var(--d-state) ease;
}
.take:focus-within .actions,
.take.selected .actions {
opacity: 1;
}
@media (hover: hover) and (pointer: fine) {
.take:hover .actions {
opacity: 1;
}
}
@media (hover: none) {
.actions {
opacity: 1;
}
}
.out {
grid-column: 2;
grid-row: 1 / span 4;
align-self: start;
position: relative;
pointer-events: none;
}
.thumb {
position: relative;
width: 150px;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: var(--r-md);
background: var(--bg-4);
box-shadow: inset 0 0 0 1px var(--on-media-line);
}
.thumb.portrait {
width: 96px;
aspect-ratio: 3 / 4;
}
.thumb img,
.thumb video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.badge,
.count {
position: absolute;
bottom: var(--sp-2);
padding: 1px 5px;
border-radius: 4px;
background: var(--media-scrim);
color: var(--on-media);
font-size: var(--fs-mini);
}
.badge {
right: var(--sp-2);
}
.count {
right: var(--sp-2);
top: var(--sp-2);
bottom: auto;
}
.play {
position: absolute;
left: var(--sp-2);
bottom: var(--sp-2);
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--media-scrim);
color: var(--on-media);
}
.thumb.pending,
.thumb.missing,
.thumb.gone {
display: grid;
place-items: center;
padding: var(--sp-2);
box-sizing: border-box;
text-align: center;
font-size: var(--fs-mini);
color: var(--text-3);
border: 1px dashed var(--line-2);
background: var(--bg-2);
box-shadow: none;
}
.thumb.pending {
border-style: solid;
color: var(--run);
}
.thumb.gone {
color: var(--warn);
border-color: var(--warn);
}
.thumb.pending::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 30%, var(--run-soft) 50%, transparent 70%);
animation: take-sheen 1.4s linear infinite;
}
@keyframes take-sheen {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
:global(:root[data-reduce='1']) .thumb.pending::after {
animation: none;
}
@media (max-width: 760px) {
.take {
padding: var(--sp-4);
}
.head {
flex-wrap: wrap;
row-gap: 2px;
}
.out {
grid-row: 1 / span 2;
}
.state {
grid-column: 1 / -1;
flex-wrap: wrap;
}
.thumb {
width: 108px;
}
.thumb.portrait {
width: 72px;
}
.bar {
flex-basis: 80px;
}
}
</style>
@@ -0,0 +1,344 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { SvelteSet } from 'svelte/reactivity';
import Icon from '$lib/components/ui/Icon.svelte';
import type { Job, Workflow } from '$lib/comfy/types';
import { parseDbTs } from '$lib/dbtime';
import { t } from '$lib/i18n.svelte';
import TakeCard from './TakeCard.svelte';
import { dayLabel, matchesFilter, type FeedFilter } from './takes';
/**
* 记录流 (spec-v2 §3.3): every desk take, newest last above the composer,
* filtered 全部 / 图片 / 视频 / 失败 and grouped by day.
* D 批 (HEL-654): rows pending their 5-second undo collapse out
* (200ms) instead of vanishing, new/restored takes rise in (220ms),
* the gold selection frame slides between cards on pointer picks
* (键盘 ↑↓/J/K 即时切换无动画 — driven by the desk through the
* exported helpers), and long histories page in from the top
* (spec §五: 记录流超 200 条分页).
*/
interface Props {
jobs: Job[];
workflowOf: (job: Job) => Workflow | undefined;
filter: FeedFilter;
selectedId: number | null;
nowMs: number;
busyId: number | null;
loading: boolean;
/** Takes whose delete is inside the 5s undo window — hidden. */
pendingIds: SvelteSet<number>;
/** One-shot completion flashes / failure shakes (driven by SSE). */
flashIds: SvelteSet<number>;
shakeIds: SvelteSet<number>;
/** Ordered visible ids (oldest first) for ↑↓/J/K. */
orderedIds?: number[];
/** Shown under the filters (e.g. the workbench context bar). */
banner?: Snippet;
/** Inspector toggle when it is hidden (desktop) or an overlay (mid). */
inspectorToggle?: Snippet;
onSelect: (job: Job) => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onCancel: (job: Job) => void;
onDelete: (job: Job) => void;
}
let {
jobs,
workflowOf,
filter = $bindable(),
selectedId,
nowMs,
busyId,
loading,
pendingIds,
flashIds,
shakeIds,
orderedIds = $bindable(),
banner,
inspectorToggle,
onSelect,
onReuse,
onRetry,
onCancel,
onDelete,
}: Props = $props();
const FILTERS: Array<{ id: FeedFilter; key: string }> = [
{ id: 'all', key: 'desk.filterAll' },
{ id: 'image', key: 'desk.filterImage' },
{ id: 'video', key: 'desk.filterVideo' },
{ id: 'failed', key: 'desk.filterFailed' },
];
/** §五 分页: render the newest PAGE_SIZE takes; older ones load on demand. */
const PAGE_SIZE = 80;
let limit = $state(PAGE_SIZE);
let scroller = $state<HTMLElement | null>(null);
/** Oldest first so the newest take sits right above the composer. */
const shown = $derived(
jobs
.filter((j) => matchesFilter(j, filter) && !pendingIds.has(j.id))
.sort((a, b) => parseDbTs(a.created_at) - parseDbTs(b.created_at)),
);
const hiddenEarlier = $derived(Math.max(0, shown.length - limit));
const visible = $derived(hiddenEarlier > 0 ? shown.slice(-limit) : shown);
$effect(() => {
orderedIds = visible.map((j) => j.id);
});
const activeCount = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running').length);
const days = $derived.by(() => {
const out: Array<{ label: string; items: Job[] }> = [];
for (const job of visible) {
const label = dayLabel(parseDbTs(job.created_at), nowMs);
const last = out[out.length - 1];
if (last?.label === label) last.items.push(job);
else out.push({ label, items: [job] });
}
return out;
});
// Stick to the bottom as takes arrive, unless the user scrolled up to read.
let pinned = true;
function onScroll() {
if (!scroller) return;
pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80;
}
$effect(() => {
void visible.length;
void filter;
if (!scroller || !pinned) return;
const el = scroller;
requestAnimationFrame(() => (el.scrollTop = el.scrollHeight));
});
// ------------------------------------------------------------------
// Entrance bookkeeping: only takes that appear AFTER the first fill
// (or come back from an undo) play the 220ms rise.
// ------------------------------------------------------------------
const seenIds = new Set<number>();
const animateIds = new SvelteSet<number>();
let hydrated = false;
$effect(() => {
if (!hydrated) {
if (jobs.length) {
for (const j of jobs) seenIds.add(j.id);
hydrated = true;
}
return;
}
for (const j of jobs) {
if (seenIds.has(j.id)) continue;
seenIds.add(j.id);
animateIds.add(j.id);
}
});
export function markEnter(jobId: number) {
seenIds.add(jobId);
animateIds.add(jobId);
}
function reduced(): boolean {
return (
document.documentElement.dataset.reduce === '1' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
// ------------------------------------------------------------------
// Gold selection frame (HEL-648 slideMark): pointer picks only.
// ------------------------------------------------------------------
export function markRect(): DOMRect | null {
const el = scroller?.querySelector(`.take[data-job="${selectedId}"]`);
return el instanceof HTMLElement ? el.getBoundingClientRect() : null;
}
export function slideFrom(fromRect: DOMRect | null) {
if (!fromRect || reduced()) return;
const el = scroller?.querySelector(`.take[data-job="${selectedId}"]`);
const mark = el?.querySelector('.sel-mark');
if (!(el instanceof HTMLElement) || !(mark instanceof HTMLElement)) return;
const to = el.getBoundingClientRect();
if (Math.abs(fromRect.top - to.top) < 1 && Math.abs(fromRect.height - to.height) < 1) return;
mark.animate(
[
{ top: `${fromRect.top - to.top - 1}px`, bottom: `${to.bottom - fromRect.bottom - 1}px` },
{ top: '-1px', bottom: '-1px' },
],
{ duration: 220, easing: 'cubic-bezier(0.23, 1, 0.32, 1)' },
);
}
/** Bring a keyboard-selected take into view without animation. */
export function scrollToJob(jobId: number) {
const el = scroller?.querySelector(`.take[data-job="${jobId}"]`);
el?.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
// ------------------------------------------------------------------
// Row transitions: undo-pending rows collapse out (200ms 收起 + 补位),
// new/restored takes rise in (220ms). Everything else is instant.
// ------------------------------------------------------------------
function takeEnter(_node: HTMLElement, should: () => boolean) {
if (!should()) return { duration: 0 };
if (reduced()) return { duration: 120, css: (t: number) => `opacity: ${t}` };
return {
duration: 220,
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: translateY(${8 * (1 - t)}px)`,
};
}
function takeLeave(node: HTMLElement, isPending: () => boolean) {
if (!isPending()) return { duration: 0 };
if (reduced()) return { duration: 120, css: (t: number) => `opacity: ${t}` };
const h = node.offsetHeight;
const cs = getComputedStyle(node);
const num = (v: string) => parseFloat(v) || 0;
const padT = num(cs.paddingTop);
const padB = num(cs.paddingBottom);
const mb = num(cs.marginBottom);
return {
duration: 200,
easing: cubicOut,
css: (t: number, u: number) =>
`height:${h * t}px;padding-top:${padT * t}px;padding-bottom:${padB * t}px;` +
`margin-bottom:${mb * t}px;opacity:${t};transform:translateX(${-10 * u}px);overflow:hidden;`,
};
}
</script>
<div class="filters">
<div class="a-seg" role="group" aria-label={t('desk.filterAria')}>
{#each FILTERS as f (f.id)}
<button type="button" aria-pressed={filter === f.id} onclick={() => (filter = f.id)}>{t(f.key)}</button>
{/each}
</div>
<span class="count">
{t('desk.countTakes', { n: shown.length })}{#if activeCount}&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;
}
@@ -39,7 +39,8 @@
function segClass(key: string): string {
const state = stats.stage_status?.[key];
if (state === 'confirmed') return 'seg-confirmed';
if (state === 'running' || state === 'awaiting_confirm') return 'seg-draft';
if (state === 'running') return 'seg-running';
if (state === 'awaiting_confirm') return 'seg-draft';
return '';
}
@@ -70,7 +71,7 @@
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.running')}` };
}
if (state === 'awaiting_confirm') {
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.draft')}` };
return { cls: 'st-pending', label: `${stageName} · ${t('workbench.state.draft')}` };
}
if (stats.stage_confirmed > 0) {
return { cls: 'st-progress', label: `${stageName} · ${t('home.statusProgress')}` };
@@ -329,7 +330,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(22, 19, 10, 0.82);
color: color-mix(in srgb, var(--accent-ink) 82%, transparent);
opacity: 0.9;
}
.status-tag {
@@ -340,7 +341,7 @@
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
background: rgba(13, 14, 17, 0.78);
background: var(--media-scrim);
border: 1px solid var(--border-strong);
backdrop-filter: blur(4px);
}
@@ -350,7 +351,10 @@
color: var(--text-2);
}
.st-progress {
color: var(--accent);
color: var(--run);
}
.st-pending {
color: var(--warn);
}
.st-done {
color: var(--success);
@@ -377,7 +381,7 @@
white-space: nowrap;
}
.arrow {
color: var(--accent);
color: var(--text-2);
font-size: 13px;
opacity: 0;
transform: translateX(-6px);
@@ -409,8 +413,11 @@
border-radius: 2px;
background: var(--surface-3);
}
.seg-running {
background: var(--run);
}
.seg-draft {
background: var(--accent);
background: var(--warn);
}
.seg-confirmed {
background: var(--success);
@@ -425,8 +432,8 @@
margin-left: auto;
padding: 1px 8px;
border-radius: 999px;
background: var(--accent-dim);
color: var(--accent);
background: var(--run-soft);
color: var(--run);
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
@@ -462,7 +469,7 @@
height: 28px;
border: 1px solid transparent;
border-radius: 7px;
background: rgba(13, 14, 17, 0.6);
background: var(--media-scrim);
color: var(--text-2);
font-size: 15px;
line-height: 1;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,218 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { projects } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import type { IconName } from '$lib/components/ui/icons';
import { NAV } from './nav';
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
let { open = $bindable(false) }: { open?: boolean } = $props();
interface Entry {
key: string;
group: 'pages' | 'projects';
label: string;
icon: IconName;
href: string;
}
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
let query = $state('');
let activeIndex = $state(0);
let list = $state<HTMLDivElement | null>(null);
const projectsQuery = createQuery(
toStore(() => ({
queryKey: ['projects'],
queryFn: projects.list,
enabled: open,
staleTime: 30_000,
})),
);
const entries = $derived.by<Entry[]>(() => {
const pages: Entry[] = NAV.map((item) => ({
key: `page-${item.id}`,
group: 'pages',
label: t(item.labelKey),
icon: item.icon,
href: item.href,
}));
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
key: `project-${p.id}`,
group: 'projects',
label: p.title,
icon: 'story',
href: `/workbench/${p.id}`,
}));
const q = query.trim().toLowerCase();
const all = [...pages, ...projectEntries];
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
});
$effect(() => {
void query;
activeIndex = 0;
});
$effect(() => {
if (!open) query = '';
});
function optionId(index: number) {
return `${listId}-${index}`;
}
function go(entry: Entry) {
open = false;
void goto(entry.href);
}
function move(step: number) {
if (entries.length === 0) return;
activeIndex = (activeIndex + step + entries.length) % entries.length;
list?.querySelector(`#${optionId(activeIndex)}`)?.scrollIntoView({ block: 'nearest' });
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
move(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
move(-1);
} else if (event.key === 'Enter') {
const entry = entries[activeIndex];
if (entry) {
event.preventDefault();
go(entry);
}
}
}
function focusOnMount(node: HTMLInputElement) {
requestAnimationFrame(() => node.focus());
}
</script>
<Modal bind:open title={t('shell.searchTitle')}>
<div class="palette">
<div class="palette-search">
<Icon name="search" size={16} />
<input
use:focusOnMount
class="palette-input"
type="text"
bind:value={query}
placeholder={t('shell.searchPlaceholder')}
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={entries.length ? optionId(activeIndex) : undefined}
aria-label={t('shell.searchTitle')}
onkeydown={handleKeydown}
/>
</div>
<div class="palette-list" id={listId} role="listbox" bind:this={list} aria-label={t('shell.searchTitle')}>
{#if entries.length === 0}
<p class="palette-empty">{t('shell.searchEmpty', { q: query.trim() })}</p>
{/if}
{#each entries as entry, i (entry.key)}
{#if i === 0 || entries[i - 1].group !== entry.group}
<div class="a-menu-group" role="presentation">
{entry.group === 'pages' ? t('shell.searchGroupPages') : t('shell.searchGroupProjects')}
</div>
{/if}
<div
id={optionId(i)}
class="a-menu-item"
class:active={i === activeIndex}
role="option"
tabindex="-1"
aria-selected={i === activeIndex}
onpointermove={() => (activeIndex = i)}
onmousedown={(e) => e.preventDefault()}
onclick={() => go(entry)}
onkeydown={handleKeydown}
>
<Icon name={entry.icon} size={15} />
<span class="a-menu-label">{entry.label}</span>
<span class="a-menu-meta mono">{entry.href}</span>
</div>
{/each}
</div>
<p class="palette-hint">
<span class="a-kbd">↑↓</span>{t('shell.searchHintMove')}
<span class="a-kbd">Enter</span>{t('shell.searchHintOpen')}
<span class="a-kbd">Esc</span>{t('shell.searchHintClose')}
</p>
</div>
</Modal>
<style>
.palette {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.palette-search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 0 var(--sp-5);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
transition: border-color var(--d-state) ease;
}
.palette-search:focus-within {
border-color: var(--accent-line);
}
.palette-input {
flex: 1;
min-width: 0;
height: 40px;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: var(--fs-section);
}
.palette-input::placeholder {
color: var(--text-3);
}
.palette-list {
max-height: min(360px, 50vh);
overflow-y: auto;
}
.palette-empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
text-align: center;
}
.palette-hint {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
font-size: var(--fs-mini);
color: var(--text-3);
}
.palette-hint .a-kbd:not(:first-child) {
margin-left: var(--sp-3);
}
@media (hover: none) {
.palette-hint {
display: none;
}
}
</style>
@@ -0,0 +1,127 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { projects } from '$lib/api';
import { parseDbTs } from '$lib/dbtime';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import ShellPop from './ShellPop.svelte';
/**
* 顶栏范围胶囊 — the project picker that replaced the old sidebar project
* entry: shows the project the page belongs to (or 全部项目) and lists
* recent projects, each opening its five-stage workbench.
*/
interface Props {
projectId: number | null;
onProjectsList: boolean;
}
let { projectId, onProjectsList }: Props = $props();
const RECENT_LIMIT = 8;
let open = $state(false);
const projectsQuery = createQuery(
toStore(() => ({
queryKey: ['projects'],
queryFn: projects.list,
enabled: open || projectId != null,
staleTime: 30_000,
})),
);
const all = $derived($projectsQuery.data ?? []);
const current = $derived(projectId == null ? null : (all.find((p) => p.id === projectId) ?? null));
const recent = $derived(
[...all]
.sort((a, b) => (parseDbTs(b.updated_at) || 0) - (parseDbTs(a.updated_at) || 0))
.slice(0, RECENT_LIMIT),
);
</script>
<div class="scope">
<button
type="button"
class="status-chip scope-btn"
aria-haspopup="dialog"
aria-expanded={open}
title={t('shell.scopeHint')}
onclick={() => (open = !open)}
>
<span class="scope-label">{current?.title ?? t('shell.scopeAll')}</span>
<Icon name="chevron-down" size={12} />
</button>
<ShellPop bind:open label={t('shell.scopeHint')} width="300px">
<a
class="a-menu-item"
href="/"
aria-current={onProjectsList ? 'page' : undefined}
onclick={() => (open = false)}
>
<Icon name="folder" size={15} />
<span class="a-menu-label">{t('shell.scopeAllList')}</span>
{#if all.length > 0}<span class="a-menu-meta mono">{all.length}</span>{/if}
</a>
<div class="a-menu-group">{t('shell.scopeRecent')}</div>
{#if $projectsQuery.isPending}
<p class="scope-note">{t('common.loading')}…</p>
{:else if $projectsQuery.isError}
<p class="scope-note err">{t('shell.scopeLoadFailed')}</p>
{:else if recent.length === 0}
<a class="a-menu-item" href="/" onclick={() => (open = false)}>
<Icon name="plus" size={15} />
<span class="a-menu-label">{t('shell.scopeEmpty')}</span>
</a>
{:else}
{#each recent as project (project.id)}
<a
class="a-menu-item"
href="/workbench/{project.id}"
aria-current={project.id === projectId ? 'true' : undefined}
onclick={() => (open = false)}
>
<Icon name="story" size={15} />
<span class="a-menu-label">{project.title}</span>
{#if project.stats}
<span class="a-menu-meta">
{t('shell.scopeStage', { n: project.stats.stage_confirmed })}
</span>
{/if}
</a>
{/each}
{/if}
</ShellPop>
</div>
<style>
.scope {
position: relative;
display: flex;
flex-shrink: 0;
}
.scope-btn {
max-width: 220px;
gap: var(--sp-1);
}
.scope-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.scope-note {
margin: 0;
padding: var(--sp-3) var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
}
.scope-note.err {
color: var(--err);
}
@media (max-width: 760px) {
.scope-btn {
max-width: 132px;
}
}
</style>
@@ -0,0 +1,164 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { workflows, type Job } from '$lib/api';
import { parseDbTs } from '$lib/dbtime';
import { t } from '$lib/i18n.svelte';
import { jobSourceLabel } from '$lib/jobLabels';
import { jobStatusInfo, type JobStatusTone } from '$lib/jobStatus';
import ShellPop from './ShellPop.svelte';
/**
* 顶栏队列胶囊 + 任务托盘 (§二). The dot is cyan only while something is
* actually generating — queued work stays gray, as on the task page.
*/
let { jobs }: { jobs: Job[] } = $props();
const TRAY_LIMIT = 6;
const TAG_CLASS: Record<JobStatusTone, string> = { ok: 'ok', run: 'run', queue: '', fail: 'err' };
let open = $state(false);
let capsule = $state<HTMLButtonElement | null>(null);
const workflowsQuery = createQuery(
toStore(() => ({
queryKey: ['workflows'],
queryFn: workflows.list,
enabled: open,
staleTime: 60_000,
})),
);
const active = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running'));
const running = $derived(active.filter((j) => jobStatusInfo(j).tone === 'run').length);
const queued = $derived(active.length - running);
const recent = $derived([...jobs].sort((a, b) => b.id - a.id).slice(0, TRAY_LIMIT));
const label = $derived.by(() => {
const parts: string[] = [];
if (running > 0) parts.push(t('shell.queueRunning', { n: running }));
if (queued > 0) parts.push(t('shell.queueQueued', { n: queued }));
return parts.length ? parts.join(' · ') : t('shell.queueIdle');
});
// 任务数变化时胶囊轻弹一次(320ms,只播一次).
let lastCount: number | null = null;
$effect(() => {
const count = active.length;
if (lastCount !== null && count !== lastCount && capsule) {
capsule.classList.remove('bump');
void capsule.offsetWidth;
capsule.classList.add('bump');
}
lastCount = count;
});
function workflowName(job: Job): string {
return (
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)?.name ??
t('studio.jobName', { id: job.id })
);
}
function timeOf(job: Job): string {
const ts = parseDbTs(job.created_at);
if (!Number.isFinite(ts)) return '—';
const d = new Date(ts);
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
</script>
<div class="tray">
<button
bind:this={capsule}
type="button"
class="status-chip queue-btn"
aria-haspopup="dialog"
aria-expanded={open}
aria-label={label}
title={label}
onclick={() => (open = !open)}
onanimationend={() => capsule?.classList.remove('bump')}
>
<span class="status-chip-dot {running > 0 ? 'run' : 'idle'}" aria-hidden="true"></span>
<span class="queue-label">{label}</span>
<span class="queue-count mono">{active.length}</span>
</button>
<ShellPop bind:open label={t('shell.trayTitle')} align="end" width="340px">
<div class="a-menu-group">{t('shell.trayRecent')}</div>
{#if recent.length === 0}
<p class="tray-note">{t('shell.trayEmpty')}</p>
{:else}
{#each recent as job (job.id)}
{@const info = jobStatusInfo(job)}
<a class="a-menu-item tray-row" href="/tasks" title={info.detail} onclick={() => (open = false)}>
<span class="tray-copy">
<span class="a-menu-label">{workflowName(job)}</span>
<span class="tray-meta">{jobSourceLabel(job)} · <span class="mono">{timeOf(job)}</span></span>
</span>
<span class="a-tag {TAG_CLASS[info.tone]}">{info.label}</span>
</a>
{/each}
{/if}
<a class="a-menu-item tray-all" href="/tasks" onclick={() => (open = false)}>
<span class="a-menu-label">{t('shell.trayAll')}</span>
</a>
</ShellPop>
</div>
<style>
.tray {
position: relative;
flex-shrink: 0;
}
.queue-btn:global(.bump) {
animation: queue-bump 320ms var(--ease-out);
}
@keyframes queue-bump {
40% {
transform: scale(1.06);
}
}
.queue-count {
display: none;
}
@media (max-width: 1179px) {
.queue-label {
display: none;
}
.queue-count {
display: inline;
}
}
.tray-row {
align-items: center;
}
.tray-copy {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
line-height: 1.4;
}
.tray-meta {
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tray-note {
margin: 0;
padding: var(--sp-3) var(--sp-4) var(--sp-5);
font-size: var(--fs-aux);
color: var(--text-3);
}
.tray-all {
margin-top: var(--sp-1);
border-top: 1px solid var(--line);
border-radius: 0 0 var(--r-md) var(--r-md);
justify-content: center;
}
</style>
@@ -0,0 +1,199 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cubicOut, quintOut } from 'svelte/easing';
/**
* Anchored pop for the shell (§四.3 pop): opens 180ms from its trigger,
* closes on Esc / outside press, Tab cycles inside, ↑↓ move between rows.
* At ≤760px it becomes a bottom sheet. Render it next to its trigger
* inside a `position: relative` wrapper — that wrapper counts as inside.
*/
interface Props {
open?: boolean;
label: string;
align?: 'start' | 'end';
/** `top` opens above the trigger (triggers near the bottom edge, e.g. the composer). */
placement?: 'bottom' | 'top';
width?: string;
/** Set false when the content runs its own ↑↓ roving selection. */
arrowRoving?: boolean;
onclose?: () => void;
children: Snippet;
}
let {
open = $bindable(false),
label,
align = 'start',
placement = 'bottom',
width = '300px',
arrowRoving = true,
onclose,
children,
}: Props = $props();
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const SHEET_QUERY = '(max-width: 760px)';
let panel = $state<HTMLDivElement | null>(null);
let scrim = $state<HTMLDivElement | null>(null);
function focusables(): HTMLElement[] {
if (!panel) return [];
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.getClientRects().length > 0,
);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
open = false;
return;
}
const list = focusables();
if (list.length === 0) return;
const current = document.activeElement as HTMLElement | null;
const index = current ? list.indexOf(current) : -1;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (!arrowRoving || current?.tagName === 'SELECT') return;
event.preventDefault();
const step = event.key === 'ArrowDown' ? 1 : -1;
const next = index < 0 ? (step > 0 ? 0 : list.length - 1) : index + step;
list[(next + list.length) % list.length].focus();
} else if (event.key === 'Tab') {
if (event.shiftKey && index <= 0) {
event.preventDefault();
list[list.length - 1].focus();
} else if (!event.shiftKey && (index < 0 || index === list.length - 1)) {
event.preventDefault();
list[0].focus();
}
}
}
function handlePointerDown(event: PointerEvent) {
const host = panel?.parentElement;
const target = event.target as Node;
if (!host) return;
if (!host.contains(target) || target === scrim) open = false;
}
$effect(() => {
if (!open) return;
const invoker = document.activeElement as HTMLElement | null;
document.addEventListener('keydown', handleKeydown, true);
document.addEventListener('pointerdown', handlePointerDown, true);
requestAnimationFrame(() => focusables()[0]?.focus());
return () => {
document.removeEventListener('keydown', handleKeydown, true);
document.removeEventListener('pointerdown', handlePointerDown, true);
const focusWasInside =
document.activeElement === document.body || !!panel?.contains(document.activeElement);
if (focusWasInside && invoker?.isConnected) invoker.focus();
onclose?.();
};
});
function cssMs(name: string, fallback: number): number {
const raw = getComputedStyle(document.documentElement).getPropertyValue(name);
const ms = parseFloat(raw);
return Number.isFinite(ms) ? ms : fallback;
}
function popIn(_node: Element) {
const reduced = document.documentElement.dataset.reduce === '1';
const sheet = window.matchMedia(SHEET_QUERY).matches;
if (reduced) {
return { duration: cssMs('--d-state', 120), css: (t: number) => `opacity: ${t}` };
}
if (sheet) {
return {
duration: cssMs('--d-drawer', 280),
easing: quintOut,
css: (t: number) => `transform: translateY(${(1 - t) * 100}%)`,
};
}
return {
duration: cssMs('--d-pop', 180),
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: scale(${0.96 + 0.04 * t})`,
};
}
function fadeScrim(_node: Element) {
return { duration: cssMs('--d-pop', 180), css: (t: number) => `opacity: ${t}` };
}
</script>
{#if open}
<div class="pop-scrim" bind:this={scrim} aria-hidden="true" transition:fadeScrim></div>
<div
bind:this={panel}
class="shell-pop a-float align-{align} place-{placement}"
style:--pop-w={width}
role="dialog"
aria-label={label}
transition:popIn
>
{@render children()}
</div>
{/if}
<style>
.shell-pop {
position: absolute;
top: calc(100% + var(--sp-2));
z-index: 60;
width: var(--pop-w);
max-width: calc(100vw - 2 * var(--sp-5));
max-height: min(520px, 70vh);
overflow-y: auto;
padding: var(--sp-2);
box-sizing: border-box;
transform-origin: top left;
}
.shell-pop.align-start {
left: 0;
}
.shell-pop.align-end {
right: 0;
transform-origin: top right;
}
.shell-pop.place-top {
top: auto;
bottom: calc(100% + var(--sp-2));
transform-origin: bottom left;
}
.shell-pop.place-top.align-end {
transform-origin: bottom right;
}
.pop-scrim {
display: none;
}
@media (max-width: 760px) {
.pop-scrim {
display: block;
position: fixed;
inset: 0;
z-index: 59;
background: var(--overlay);
}
.shell-pop,
.shell-pop.place-top {
position: fixed;
top: auto;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
max-width: none;
max-height: 78vh;
border-radius: var(--r-lg) var(--r-lg) 0 0;
padding: var(--sp-3) var(--sp-3) calc(var(--sp-3) + env(safe-area-inset-bottom));
}
}
</style>
@@ -0,0 +1,72 @@
import type { IconName } from '$lib/components/ui/icons';
export type NavId =
| 'projects'
| 'workbench'
| 'image'
| 'video'
| 'tasks'
| 'agents'
| 'connections'
| 'models'
| 'library'
| 'settings';
export interface NavItem {
id: NavId;
labelKey: string;
icon: IconName;
href: string;
}
// Order is fixed by spec-v2 §3: 项目 / 创作工作台 / 生图 / 生视频 / 生成任务 /
// 智能体 / ComfyUI 连接 / 模型设置 / 设置. HEL-649 §二 puts the connection
// and settings entries at the foot of the 60px rail.
export const NAV_MAIN: NavItem[] = [
{ id: 'projects', labelKey: 'shell.navProjects', icon: 'home', href: '/' },
{ id: 'workbench', labelKey: 'shell.navWorkbench', icon: 'story', href: '/workbench' },
{ id: 'image', labelKey: 'shell.navImage', icon: 'image', href: '/image' },
{ id: 'video', labelKey: 'shell.navVideo', icon: 'film', href: '/video' },
{ id: 'tasks', labelKey: 'shell.navTasks', icon: 'queue', href: '/tasks' },
{ id: 'agents', labelKey: 'shell.navAgents', icon: 'sparkle', href: '/agents' },
];
export const NAV_FOOT: NavItem[] = [
{ id: 'connections', labelKey: 'shell.navConnections', icon: 'link', href: '/connections' },
{ id: 'models', labelKey: 'shell.navModels', icon: 'assets', href: '/models' },
{ id: 'library', labelKey: 'shell.navLibrary', icon: 'folder', href: '/library' },
{ id: 'settings', labelKey: 'shell.navSettings', icon: 'settings', href: '/settings' },
];
export const NAV: NavItem[] = [...NAV_MAIN, ...NAV_FOOT];
export interface TabItem {
id: 'projects' | 'workbench' | 'generate';
labelKey: string;
icon: IconName;
href: string;
/** Pages that light this tab up. */
covers: NavId[];
}
/** ≤760px bottom tab bar (§二): 项目 / 创作 / 生成 / 更多. */
export const TABS: TabItem[] = [
{ id: 'projects', labelKey: 'shell.tabProjects', icon: 'home', href: '/', covers: ['projects'] },
{ id: 'workbench', labelKey: 'shell.tabCreate', icon: 'story', href: '/workbench', covers: ['workbench'] },
{
id: 'generate',
labelKey: 'shell.tabGenerate',
icon: 'image',
href: '/image',
covers: ['image', 'video'],
},
];
/**
* Every page a tab does not link to directly lives in the 更多 sheet. 生视频
* is still its own route, so it is listed there too until the two
* generation pages merge.
*/
export const MORE_ITEMS: NavItem[] = NAV.filter(
(item) => !TABS.some((tab) => tab.href === item.href),
);
@@ -1,37 +1,33 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import { assetUrl } from '$lib/api';
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
import { t } from '$lib/i18n.svelte';
/**
* A-style media upload slot (spec-v2 §5 上传槽, ref-upload-spec §2):
* empty = 1.5px dashed border + "click or drop" + format line, drag-hover
* = amber dashed, filled = solid border + thumb + name + size + 替换 + ✕,
* error = red border + red reason.
* Single media input slot as a composer tile (spec-v2 §3.3 输入槽):
* empty = dashed + "+", drag-hover = gold, uploading = ring, filled = thumb
* (click to replace) with a ✕ on hover/focus. `need` = required but empty
* after a blocked 生成 (amber); `error` = rejected file / invalid (red).
*/
interface Props {
label: string;
/** Hint on the right of the label, e.g. 必填 · 1 张 / 可从生图结果送入. */
/** Hint after the label, e.g. 必填 · 1 张. */
hint?: string;
required?: boolean;
/** Current value: a server-side path ('' = empty). */
value: string;
kind: 'image' | 'video' | 'audio';
/** Thumb aspect: wide = 16:9 (first/last frame slots). */
aspect?: 'square' | 'wide';
/** Empty-state main line (defaults to 点击或拖入). */
emptyText?: string;
/** Empty-state mono line, e.g. accepted formats and size limit. */
emptySub?: string;
/** Byte size of the current file when known. */
size?: number;
uploading?: boolean;
uploadName?: string;
need?: boolean;
error?: string;
/** Show the "pick from image results" link (video-page image slots). */
/** Show the 从结果选 action (image slots fed from finished takes). */
allowFromResults?: boolean;
/** Anchor id so 「还缺:…」 can jump focus here. */
anchorId?: string;
onFile: (file: File) => void;
onFromResults?: () => void;
onClear: () => void;
@@ -43,14 +39,13 @@
required = false,
value,
kind,
aspect = 'square',
emptyText,
emptySub,
size,
uploading = false,
uploadName,
need = false,
error,
allowFromResults = false,
anchorId,
onFile,
onFromResults,
onClear,
@@ -66,7 +61,10 @@
dims = '';
});
const meta = $derived([dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '));
const filled = $derived(!!value && !broken);
const meta = $derived(
[uploadBaseName(value), dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '),
);
function accept(): string {
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
@@ -87,17 +85,34 @@
function onDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragDepth = 0;
if (uploading) return;
const file = e.dataTransfer?.files?.[0];
if (file) onFile(file);
}
const dragHandlers = {
ondragenter: (e: DragEvent) => {
e.preventDefault();
dragDepth += 1;
},
ondragleave: () => (dragDepth = Math.max(0, dragDepth - 1)),
ondragover: (e: DragEvent) => e.preventDefault(),
ondrop: onDrop,
};
</script>
<div class="media-slot" class:wide={aspect === 'wide'}>
<div class="slot-label">
<span class="lbl">{label}{#if required}<em>*</em>{/if}</span>
{#if hint}<span class="slot-hint">{hint}</span>{/if}
<div class="a-slot">
<div class="a-slot-label">
<span>{label}</span>
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
{#if hint}<span>{hint}</span>{/if}
{#if allowFromResults && onFromResults}
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
</button>
{/if}
</div>
<input
@@ -113,110 +128,61 @@
}}
/>
{#if value && !broken}
<div class="slot-filled" class:invalid={!!error}>
<div class="slot-thumb">
{#if uploading}
<Spinner size="sm" />
{:else if kind === 'image'}
<img src={assetUrl(value)} alt={label} onload={noteDims} onerror={() => (broken = true)} />
{:else if kind === 'video'}
<video src={assetUrl(value)} preload="metadata" muted></video>
<span class="kind-badge" aria-hidden="true"><Icon name="film" size={12} /></span>
{:else}
<span class="kind-badge" aria-hidden="true"><Icon name="music" size={12} /></span>
{/if}
</div>
<div class="slot-meta">
<span class="name" title={uploading ? uploadName : uploadBaseName(value)}>
{uploading ? uploadName : uploadBaseName(value)}
</span>
{#if meta && !uploading}<span class="mono dims">{meta}</span>{/if}
</div>
<div class="slot-actions">
{#if allowFromResults && onFromResults}
<button type="button" class="mini-link" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
</button>
{/if}
<button type="button" class="mini-link" disabled={uploading} onclick={pick}>
{t('studio.refReplace')}
</button>
<div class="a-slot-row">
{#if filled}
<div class="a-tile" class:drag={dragDepth > 0} class:bad={!!error} {...dragHandlers}>
<button
type="button"
class="slot-clear"
aria-label={t('studio.deleteBtn')}
id={anchorId}
class="a-tile-hit"
title={`${t('studio.refReplace')} · ${meta}`}
aria-label={`${t('studio.refReplace')} ${label}`}
disabled={uploading}
onclick={onClear}
onclick={pick}
>
<Icon name="close" size={12} />
{#if kind === 'image'}
<img src={assetUrl(value)} alt="" onload={noteDims} onerror={() => (broken = true)} />
{:else if kind === 'video'}
<video src={assetUrl(value)} preload="metadata" muted playsinline></video>
{:else}
<Icon name="music" size={18} />
{/if}
</button>
{#if uploading}
<span class="a-tile-ring" title={uploadName}><i></i></span>
{:else}
<button type="button" class="a-tile-x" aria-label={`${t('studio.deleteBtn')} ${label}`} onclick={onClear}>
<Icon name="close" size={10} />
</button>
{/if}
</div>
</div>
{:else}
<button
type="button"
class="slot-empty"
class:drag={dragDepth > 0}
class:invalid={!!error}
class:wide={aspect === 'wide'}
disabled={uploading}
aria-busy={uploading || undefined}
onclick={pick}
ondragenter={(e) => {
e.preventDefault();
dragDepth += 1;
}}
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
ondragover={(e) => e.preventDefault()}
ondrop={onDrop}
>
{#if uploading}
<Spinner size="sm" />
<span class="pick-text">{uploadName ?? t('studio.slotPick')}</span>
{:else}
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'image'} size={16} />
<span class="pick-text">{emptyText ?? t('studio.slotPick')}</span>
{#if emptySub}<span class="pick-sub mono">{emptySub}</span>{/if}
{/if}
</button>
{#if allowFromResults && onFromResults}
<button type="button" class="from-results-link" onclick={onFromResults}>
{t('studio.slotFromResults')}
{:else}
<button
type="button"
id={anchorId}
class="a-tile add"
class:drag={dragDepth > 0}
class:need={need && !error}
class:bad={!!error}
title={t('studio.slotPick')}
aria-label={`${label} · ${t('studio.slotPick')}`}
aria-busy={uploading || undefined}
disabled={uploading}
onclick={pick}
{...dragHandlers}
>
{#if uploading}
<span class="a-tile-ring" title={uploadName}><i></i></span>
{:else}
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'plus'} size={18} />
{/if}
</button>
{/if}
{/if}
{#if error}<span class="slot-error" role="alert">{error}</span>{/if}
</div>
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span>{/if}
</div>
<style>
.media-slot {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.slot-label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lbl {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
}
.lbl em {
color: var(--error);
font-style: normal;
margin-left: 2px;
}
.slot-hint {
font-size: 10.5px;
color: var(--text-3);
white-space: nowrap;
}
.sr-file {
position: absolute;
width: 1px;
@@ -226,164 +192,4 @@
white-space: nowrap;
border: 0;
}
.slot-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 86px;
border: 1.5px dashed var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
color: var(--text-3);
font-family: inherit;
font-size: 11.5px;
cursor: pointer;
transition:
border-color 150ms,
color 150ms;
}
.media-slot.wide .slot-empty {
aspect-ratio: 16 / 9;
min-height: 0;
width: 100%;
}
.slot-empty:hover:not(:disabled) {
color: var(--text-2);
border-color: rgba(255, 255, 255, 0.28);
}
.slot-empty.drag {
border-color: var(--accent);
color: var(--accent);
}
.slot-empty.invalid {
border-color: rgba(229, 72, 77, 0.65);
}
.slot-empty:disabled {
cursor: default;
opacity: 0.7;
}
.pick-text {
font-size: 12.5px;
color: var(--text-2);
}
.pick-sub {
font-size: 11px;
color: var(--text-3);
}
.from-results-link {
align-self: flex-end;
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11px;
color: var(--accent);
cursor: pointer;
}
.from-results-link:hover {
text-decoration: underline;
}
.slot-filled {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
padding: 8px;
}
.slot-filled.invalid {
border-color: var(--error);
}
.slot-thumb {
width: 56px;
height: 56px;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
background: var(--bg);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.media-slot.wide .slot-thumb {
width: 88px;
height: 50px;
}
.slot-thumb img,
.slot-thumb video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.slot-thumb .kind-badge {
color: var(--text-3);
}
.slot-meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.name {
font-size: 12.5px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dims {
font-size: 10.5px;
color: var(--text-3);
white-space: nowrap;
}
.slot-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.mini-link {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11.5px;
color: var(--text-2);
text-decoration: underline;
cursor: pointer;
}
.mini-link:hover:not(:disabled) {
color: var(--accent);
}
.mini-link:disabled {
opacity: 0.5;
cursor: default;
}
.slot-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 6px;
background: var(--surface-3);
color: var(--text-3);
cursor: pointer;
}
.slot-clear:hover {
color: var(--error);
background: var(--error-dim);
}
.slot-error {
font-size: 11.5px;
color: var(--error);
}
</style>
@@ -1,15 +1,20 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import { assetUrl } from '$lib/api';
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
import {
IMAGE_SLOT_ACCEPT,
formatBytes,
mediaSlotAccept,
uploadBaseName,
type MediaSlotKind,
} from '$lib/comfy/imageFile';
import { t } from '$lib/i18n.svelte';
/**
* Reference-image grid (ref-upload-spec §2 `.refgrid`): numbered cells
* 参考图 1…N in the order they reach the workflow, each with replace and
* ✕; a dashed add cell while below `max`; rejected files show as error
* cells that take no place in the list.
* Reference-media list as numbered composer tiles (spec-v2 §3.3 参考图):
* 1…N in the order they reach the workflow — click a tile to replace it,
* ✕ to remove; a dashed add tile while below `max`; rejected files show
* as red tiles that take no place in the list.
*/
interface Reject {
name: string;
@@ -19,6 +24,8 @@
label: string;
paths: string[];
max: number;
/** Media family of the slots — drives thumbnails, accept and copy. */
mediaKind?: MediaSlotKind;
/** Byte sizes of files uploaded in this session, by server path. */
sizes?: Record<string, number>;
/** Name of the file being uploaded (add or replace), if any. */
@@ -26,8 +33,12 @@
/** Index being replaced while uploading (-1 = adding). */
uploadingIndex?: number;
rejects?: Reject[];
/** Below the minimum after a blocked 生成 (amber add tile). */
need?: boolean;
error?: string;
allowFromResults?: boolean;
/** Anchor id on the add tile so 「还缺:…」 can jump focus here. */
anchorId?: string;
onAdd: (files: File[]) => void;
onReplace: (index: number, file: File) => void;
onRemove: (index: number) => void;
@@ -39,12 +50,15 @@
label,
paths,
max,
mediaKind = 'image',
sizes = {},
uploadingName,
uploadingIndex = -1,
rejects = [],
need = false,
error,
allowFromResults = false,
anchorId,
onAdd,
onReplace,
onRemove,
@@ -57,9 +71,39 @@
let replaceIndex = $state(-1);
let dragDepth = $state(0);
let dims = $state<Record<string, string>>({});
let durations = $state<Record<string, number>>({});
const uploading = $derived(!!uploadingName);
const remaining = $derived(Math.max(0, max - paths.length));
const accept = $derived(mediaKind === 'image' ? IMAGE_SLOT_ACCEPT : mediaSlotAccept(mediaKind));
const isMedia = $derived(mediaKind !== 'image');
const hintLabel = $derived(
isMedia
? t('studio.refHintMedia', { n: paths.length, max })
: t('studio.refHint', { n: paths.length, max }),
);
function noteDuration(path: string, e: Event): void {
const el = e.currentTarget as HTMLVideoElement | HTMLAudioElement | null;
if (el && Number.isFinite(el.duration)) durations = { ...durations, [path]: el.duration };
}
function noteDims(path: string, e: Event) {
const img = e.currentTarget as HTMLImageElement;
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
}
function metaOf(path: string): string {
const d = durations[path];
return [
uploadBaseName(path),
dims[path],
d != null && Number.isFinite(d) ? `${d.toFixed(1)}s` : null,
sizes[path] != null ? formatBytes(sizes[path]) : null,
]
.filter(Boolean)
.join(' · ');
}
function pickAdd() {
if (uploading || !addInput) return;
@@ -76,26 +120,24 @@
function onDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragDepth = 0;
if (uploading) return;
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) onAdd(files);
}
function noteDims(path: string, e: Event) {
const img = e.currentTarget as HTMLImageElement;
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
}
function metaOf(path: string): string {
return [dims[path], sizes[path] != null ? formatBytes(sizes[path]) : null].filter(Boolean).join(' · ');
}
</script>
<div class="refgrid-field">
<div class="slot-label">
<span class="lbl">{label}<em>*</em></span>
<span class="slot-hint">{t('studio.refHint', { n: paths.length, max })}</span>
<div class="a-slot">
<div class="a-slot-label">
<span>{label}</span>
<span class="req" aria-hidden="true">*</span>
<span class="mono">{hintLabel}</span>
{#if allowFromResults && onFromResults && remaining > 0}
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
</button>
{/if}
</div>
<input
@@ -104,7 +146,7 @@
class="sr-file"
tabindex="-1"
aria-hidden="true"
accept={IMAGE_SLOT_ACCEPT}
{accept}
multiple
onchange={(e) => {
const files = Array.from(e.currentTarget.files ?? []);
@@ -117,70 +159,86 @@
class="sr-file"
tabindex="-1"
aria-hidden="true"
accept={IMAGE_SLOT_ACCEPT}
{accept}
onchange={(e) => {
const f = e.currentTarget.files?.[0];
if (f && replaceIndex >= 0) onReplace(replaceIndex, f);
}}
/>
<div class="refgrid">
<div class="a-slot-row">
{#each paths as path, i (path + i)}
<div class="ref-cell" class:busy={uploadingIndex === i}>
<div class="a-tile">
<button
type="button"
class="ref-thumb"
title={t('studio.refReplace')}
class="a-tile-hit"
title={`${t('studio.refReplace')} · ${metaOf(path)}`}
aria-label={`${t('studio.refReplace')} ${label} ${i + 1}`}
disabled={uploading}
onclick={() => pickReplace(i)}
>
<img src={assetUrl(path)} alt={t('studio.slotRefN', { n: i + 1 })} onload={(e) => noteDims(path, e)} />
<span class="ref-no mono">{i + 1}</span>
{#if uploadingIndex === i}
<span class="ref-busy"><Spinner size="sm" /></span>
{#if mediaKind === 'image'}
<img src={assetUrl(path)} alt="" onload={(e) => noteDims(path, e)} />
{:else if mediaKind === 'video'}
<video
src={assetUrl(path)}
preload="metadata"
muted
playsinline
onloadedmetadata={(e) => noteDuration(path, e)}
></video>
{:else}
<Icon name="music" size={18} />
<audio src={assetUrl(path)} preload="metadata" onloadedmetadata={(e) => noteDuration(path, e)}
></audio>
{/if}
</button>
<button
type="button"
class="ref-x"
aria-label={t('studio.refRemove', { n: i + 1 })}
onclick={() => onRemove(i)}
>
<Icon name="close" size={11} />
</button>
<span class="ref-name" title={uploadBaseName(path)}>{uploadBaseName(path)}</span>
<span class="ref-meta mono">
{metaOf(path)}
<button type="button" class="mini-link" onclick={() => pickReplace(i)}>{t('studio.refReplace')}</button>
</span>
<span class="a-tile-idx">{i + 1}</span>
{#if uploadingIndex === i}
<span class="a-tile-ring" title={uploadingName}><i></i></span>
{:else}
<button
type="button"
class="a-tile-x"
aria-label={isMedia
? t('studio.refRemoveMedia', { kind: label, n: i + 1 })
: t('studio.refRemove', { n: i + 1 })}
onclick={() => onRemove(i)}
>
<Icon name="close" size={10} />
</button>
{/if}
</div>
{/each}
{#each rejects as rej, i (rej.name + i)}
<div class="ref-cell rejected" role="alert">
<div class="ref-thumb error">
<Icon name="close" size={16} />
<span class="mono">{t('studio.refRejectedShort')}</span>
</div>
<div class="a-tile bad" title={`${rej.name} · ${rej.message}`} role="img" aria-label={rej.name}>
<Icon name="alert" size={16} />
<button
type="button"
class="ref-x"
aria-label={t('studio.deleteBtn')}
class="a-tile-x"
aria-label={`${t('studio.deleteBtn')} ${rej.name}`}
onclick={() => onDismissReject(i)}
>
<Icon name="close" size={11} />
<Icon name="close" size={10} />
</button>
<span class="ref-name err" title={rej.name}>{rej.name}</span>
</div>
{/each}
{#if remaining > 0}
<button
type="button"
class="ref-add"
id={anchorId}
class="a-tile add"
class:drag={dragDepth > 0}
class:invalid={!!error}
disabled={uploading}
class:need={need && !error?.trim()}
class:bad={!!error?.trim()}
title={isMedia
? t('studio.refAddSubMedia', { n: remaining, max })
: t('studio.refAddSub', { n: remaining, max })}
aria-label={isMedia ? t('studio.refAddMedia', { kind: label }) : t('studio.refAdd')}
aria-busy={uploading || undefined}
disabled={uploading}
onclick={pickAdd}
ondragenter={(e) => {
e.preventDefault();
@@ -191,59 +249,24 @@
ondrop={onDrop}
>
{#if uploading && uploadingIndex < 0}
<Spinner size="sm" />
<span class="add-main">{uploadingName}</span>
<span class="a-tile-ring" title={uploadingName}><i></i></span>
{:else}
<Icon name="plus" size={16} />
<span class="add-main">{t('studio.refAdd')}</span>
<span class="add-sub mono">{t('studio.refAddSub', { n: remaining, max })}</span>
<Icon name="plus" size={18} />
{/if}
</button>
{/if}
</div>
{#if allowFromResults && onFromResults && remaining > 0}
<button type="button" class="from-results-link" onclick={onFromResults}>
{t('studio.slotFromResults')}
</button>
{/if}
{#if remaining === 0}
<span class="full-note">{t('studio.refFull', { max })}</span>
<span class="a-slot-hint">{isMedia ? t('studio.refFullMedia', { max }) : t('studio.refFull', { max })}</span>
{/if}
{#each rejects as rej, i (rej.name + i)}
<span class="slot-error">{rej.message}</span>
<span class="a-slot-hint err" role="alert">{rej.message}</span>
{/each}
{#if error?.trim()}<span class="slot-error" role="alert">{error}</span>{/if}
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span>{/if}
</div>
<style>
.refgrid-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.slot-label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lbl {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
}
.lbl em {
color: var(--error);
font-style: normal;
margin-left: 2px;
}
.slot-hint {
font-size: 10.5px;
color: var(--text-3);
white-space: nowrap;
}
.sr-file {
position: absolute;
width: 1px;
@@ -253,194 +276,7 @@
white-space: nowrap;
border: 0;
}
.refgrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ref-cell {
position: relative;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding: 5px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
}
.ref-cell.rejected {
border-color: var(--error);
}
.ref-thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
background: var(--bg);
cursor: pointer;
}
.ref-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ref-thumb.error {
flex-direction: column;
gap: 4px;
cursor: default;
color: var(--error);
font-size: 10px;
}
.ref-no {
position: absolute;
left: 4px;
top: 4px;
min-width: 16px;
padding: 0 4px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.62);
color: var(--text);
font-size: 10px;
line-height: 16px;
text-align: center;
}
.ref-busy {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
}
.ref-x {
position: absolute;
top: 9px;
right: 9px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 5px;
background: rgba(0, 0, 0, 0.62);
color: var(--text);
cursor: pointer;
}
.ref-x:hover {
color: var(--error);
}
.ref-name {
font-size: 11px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ref-name.err {
color: var(--error);
}
.ref-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
font-size: 9.5px;
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
}
.mini-link {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 10.5px;
color: var(--text-2);
text-decoration: underline;
cursor: pointer;
flex-shrink: 0;
}
.mini-link:hover {
color: var(--accent);
}
.ref-add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 110px;
padding: 8px 4px;
border: 1.5px dashed var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
color: var(--text-3);
font-family: inherit;
cursor: pointer;
transition:
border-color 150ms,
color 150ms;
}
.ref-add:hover:not(:disabled) {
color: var(--text-2);
}
.ref-add.drag {
border-color: var(--accent);
color: var(--accent);
}
.ref-add.invalid {
border-color: var(--error);
}
.ref-add:disabled {
cursor: default;
opacity: 0.7;
}
.add-main {
font-size: 12px;
color: var(--text-2);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.add-sub {
font-size: 10px;
text-align: center;
}
.from-results-link {
align-self: flex-end;
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11px;
color: var(--accent);
cursor: pointer;
}
.from-results-link:hover {
text-decoration: underline;
}
.full-note {
font-size: 11px;
color: var(--text-3);
}
.slot-error {
font-size: 11.5px;
color: var(--error);
line-height: 1.5;
}
@media (max-width: 1023px) {
.refgrid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
audio {
display: none;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -3,8 +3,9 @@
import Spinner from './Spinner.svelte';
interface Props {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md';
/** `danger` = outlined (list rows); `danger-solid` = confirm dialogs only. */
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-solid';
size?: 'sm' | 'md' | 'lg';
/** Stretch to the container width (e.g. a panel's main action). */
block?: boolean;
disabled?: boolean;
@@ -32,6 +33,7 @@
{type}
class="a-btn btn {variant}"
class:sm={size === 'sm'}
class:lg={size === 'lg'}
class:block
class:loading
disabled={disabled || loading}
@@ -49,7 +51,7 @@
</button>
<style>
/* Look comes from the shared `.a-btn` rules in app.css (§5). */
/* Look comes from the shared `.a-btn` rules in app.css (§四.1). */
.btn {
position: relative;
}
@@ -72,10 +74,14 @@
justify-content: center;
}
.btn.primary .btn-spinner {
--spinner-track: rgba(22, 19, 10, 0.3);
--spinner-arc: var(--accent-contrast);
--spinner-track: color-mix(in srgb, var(--accent-ink) 30%, transparent);
--spinner-arc: var(--accent-ink);
}
.btn.danger .btn-spinner {
--spinner-arc: var(--error);
--spinner-arc: var(--err);
}
.btn.danger-solid .btn-spinner {
--spinner-track: color-mix(in srgb, var(--on-media) 30%, transparent);
--spinner-arc: var(--on-media);
}
</style>
@@ -46,16 +46,18 @@
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
border-radius: var(--r-lg);
}
.card.hoverable {
transition:
border-color 150ms ease,
transform 150ms ease;
border-color var(--d-state) ease,
transform var(--d-state) var(--ease-out);
}
.card.hoverable:hover {
border-color: #3f3f46;
transform: translateY(-1px);
@media (hover: hover) and (pointer: fine) {
.card.hoverable:hover {
border-color: var(--line-2);
transform: translateY(-1px);
}
}
.card-header {
display: flex;
@@ -47,7 +47,7 @@
{#snippet footer()}
<Button variant="ghost" onclick={() => (open = false)}>{cancelLabel}</Button
>
<Button variant={danger ? 'danger' : 'primary'} onclick={handleConfirm}
<Button variant={danger ? 'danger-solid' : 'primary'} onclick={handleConfirm}
>{confirmLabel}</Button
>
{/snippet}

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