Compare commits

..
Author SHA1 Message Date
leeferandmultica-agent 8e84e4c21f feat(HEL-728): establish approved themes and compatible studio navigation
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 20:13:03 +08:00
leeferandmultica-agent 35fd07af24 HEL-665: implement v5 creation hierarchy and project-bound agent drafts
Co-authored-by: multica-agent <github@multica.ai>
2026-10-15 00:33:26 +08:00
leeferandmultica-agent 012a946f85 fix(prototype): align scene aspect label with sample media
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 23:45:34 +08:00
leeferandmultica-agent 875dfcb41a design(HEL-665): rebuild creation hierarchy and interactive desktop prototype
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 23:42:32 +08:00
leeferandmultica-agent 780ac52ffc fix(agents): preserve copy fallback and guard new session submission
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 22:15:48 +08:00
leeferandmultica-agent d4e199f3c8 HEL-665: implement approved reference creative and agent interfaces
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 22:03:32 +08:00
leeferandmultica-agent ae9e34f6ab HEL-665: prioritize media inputs and keep short-window result actions visible
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 00:20:48 +08:00
leeferandmultica-agent 16933b1dfc HEL-665: switch results without overlapping video players
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 00:14:48 +08:00
leeferandmultica-agent 07f6475b1a HEL-665: rebuild ComfyUI creative journey with visible controls and isolated drafts
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 00:04:19 +08:00
leeferandmultica-agent ac1c0f98a6 HEL-665: preserve creative inputs and isolate cloud catalogue keyboard selection
Co-authored-by: multica-agent <github@multica.ai>
2026-10-12 00:54:27 +08:00
leeferandmultica-agent aaddd3dcaf HEL-665: implement approved Refract Studio across real desktop workbench
Co-authored-by: multica-agent <github@multica.ai>
2026-10-12 00:43:14 +08:00
总工 2b3bbf9d03 HEL-691: deploy S6 visual sweep (feat/hel-685-visual-sweep @ bc24370) 2026-10-11 18:41:38 +08:00
施工员andmultica-agent bc24370233 feat(style): 全站无彩灰阶收尾——清理 legacy 别名与硬编码色漂移 (HEL-685 S6)
- app.css 删除 8 个无引用 legacy 别名,新增 --id-1..5 身份灰阶实色阶
- 画布去紫罗兰二级 palette(tokens.css 删除,改用全站 token)
- 画布/镜头台/智能体/omni/视频组件硬编码色全部归位 token
- agentPalette 彩色身份色改为显影灰阶 --id 阶;修复 3 处相纸白底白字不可读
- WorkflowLibrary 试运行抽屉焦点还原(异步禁用触发键丢焦)修复
- e2e: fpages/import 色值断言对齐现行 token;新增 hel685 走查脚本
  (1440/1024/390 截图 + 控制台错误 + 减动效 + 键盘焦点 + 对比度探针);
  pagination 空库自举;fpages 增加逐页控制台错误检查

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 17:26:12 +08:00
总工 2f267a751f HEL-690: deploy S5 project flowback (feat/hel-684-project-attach @ 0841f5a) 2026-10-11 15:34:07 +08:00
施工员andmultica-agent 0841f5a296 feat(desk): 生成结果回流原项目——可选上下文透传 + 结果卡回流动作 (HEL-684 S5)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 15:05:57 +08:00
总工 35aad34c7c HEL-689: deploy S4 three-band desk console (feat/hel-683-desk-console @ bef28fa) 2026-10-11 13:52:49 +08:00
施工员andmultica-agent bef28facee feat(desk): 三段式图片/视频生成台——任务模式栏+工作流挑选台+摘要带 (HEL-683 S4)
- 新增 components/desk/:StudioDesk 取代 StudioGenerate 成为 /image、/video
  共用页面;TaskModeRail(真实计数、未覆盖置灰、收藏/最近)、WorkflowBay
  (搜索、本机/云端分段、模型家族分组、待确认入口、增量渲染)、
  WorkflowPreview(输入要求预览)、LibraryRow/LibraryFilters/
  InputRequirementBadge
- 挑选时中栏 grid 列宽展开、右侧显示预览;选定后收成摘要带;
  data-reduce 直接跳变;≤1179px 挑选台为全屏浮层,≤760px 任务模式横条
- 挑选侧全部走 view=meta 轻量元数据,完整记录按 id 惰性加载;
  云端来源诚实标注未接入;?wf/?mode 深链;五阶段上下文与返回路径不变
- Composer 工作流芯片改为展开挑选台,删除 WorkflowPicker;
  i18n 29 键 ×7 语言;changelog
- e2e/shoot.mjs 适配挑选台键盘链,并修正 7 处 S1 灰阶化后失效的
  旧色值/文案断言(定影 + theme-1 灰阶信号色)

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 13:19:30 +08:00
总工 316fba97e1 HEL-688: deploy S4 workflow import wizard & meta admin (feat/hel-682-workflow-import-admin @ 1c00587) 2026-10-11 12:35:44 +08:00
施工员andmultica-agent 1c00587a56 feat(workflows): 工作流批量导入向导与元数据管理列表 (HEL-682 S4)
- WorkflowBatchImportWizard:目录扫描/多文件 → dry-run 逐文件报告 → 确认导入;
  warn 可补用途、需确认与 fail 项不导入不标可运行、重复导入全量 conflict 跳过
- WorkflowLibrary:view=meta 轻量列表(搜索/分类/模型/媒介/收藏/最近/启停/
  去重/批量标注),完整 JSON 仅操作时按需加载;试运行复用 WorkflowTestDialog;
  保留用途确认、输出指定、单文件导入等既有能力
- api/types:listMeta、convert、importBatch、importScanDry/Commit、touch、
  dedupe 客户端与 WorkflowMeta/转换报告类型;update 支持 favorite/tags/
  category/model_family
- i18n 130 键 ×7 语言;changelog

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 12:13:52 +08:00
总工 7c49250267 HEL-687: merge S2 workflow data layer (agent/agent/c85587840139) 2026-10-11 11:32:05 +08:00
leeferandmultica-agent c5aa575ac3 feat(workflows): UI→API 转换器 + 批量导入 + 元数据轻量列表 (HEL-681 S2)
- 新增 comfyui/ui_convert.py:离线确定性转换(reroute/bypass/mute 透传、
  子图展开为 packed:inner 限定 id、Set/Get 变量解析、控件三种映射来源,
  无法可靠映射的文件进入需人工确认,绝不静默猜测)
- 新增 convert / import-batch / import-scan 端点:dry-run 逐文件
  ok/warn/fail 报告 + 建议元数据;导入端冲突跳过、重复执行幂等
- workflows 表加列 category/model_family/media_io/tags/favorite/
  last_used_at/source/source_path(纯加列迁移,旧行为零影响)
- GET /api/workflows?view=meta 轻量列表不含全量 JSON;旧接口行为不变
- 元数据建议:分类/模型家族(主加载器加权+目录回退,规避 qwen_image_vae
  误标)/输入输出媒介/MarkdownNote 描述素材
- enqueue 时记录 last_used_at;PATCH 支持 favorite/tags 等字段
- 测试 23 项新增(转换语义单测+146 文件语料+对拍+端点/迁移/幂等)

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

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

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

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

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

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

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 09:26:36 +08:00
总工andmultica-agent 225fcb49ea fix(ui): edited-draft marker uses amber warn per HEL-658 ruling (HEL-657)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 08:49:53 +08:00
施工员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
424 changed files with 51541 additions and 16644 deletions
@@ -0,0 +1,128 @@
"""Regenerate ``src/calliope/comfyui/ui_widget_orders.py`` (HEL-681).
Offline widget-order registry for the UI→API converter. Sources, in priority:
1. ``widgets_values_named`` instances across ``example_ComfyUI_workflows``
(including subgraph inner nodes) — the frontend serializes named widgets
in widget order, so the key order IS the serialization order;
2. the repo's 10 ground-truth API exports (root ``*_API.json``) — scalar
input names in export order (control_after_generate entries are absent
there; the converter's walk skips them positionally).
Run from anywhere inside the repo:
python scripts/build_widget_orders.py
"""
from __future__ import annotations
import collections
import json
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
REPO = BACKEND.parent
EXAMPLES = REPO / "example_ComfyUI_workflows"
OUT = BACKEND / "src" / "calliope" / "comfyui" / "ui_widget_orders.py"
BUTTONS = {"preview", "audioUI", "compare_view", "ToggleableColorPicker", "imagebrowser"}
MAX_SIGNATURES = 3
MAX_ORDER_LEN = 48
# Hand-curated fallbacks for node classes the corpus never serializes with
# names (classic core/custom widgets whose order is stable and public).
CURATED: dict[str, list[list[str]]] = {
"PrimitiveNode": [["value"]],
"Seed (rgthree)": [["seed"]],
}
def _iter_ui_nodes() -> list[dict]:
nodes: list[dict] = []
for path in sorted(EXAMPLES.rglob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
continue
nodes.extend(n for n in data["nodes"] if isinstance(n, dict))
for sub in data.get("definitions", {}).get("subgraphs", []) or []:
if isinstance(sub, dict):
nodes.extend(n for n in sub.get("nodes", []) or [] if isinstance(n, dict))
return nodes
def _iter_api_workflows() -> list[dict]:
out: list[dict] = []
for path in sorted(EXAMPLES.glob("*_API.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
if isinstance(data, dict) and "nodes" not in data:
out.append(data)
return out
def main() -> None:
learned: dict[str, collections.Counter[tuple[str, ...]]] = collections.defaultdict(
collections.Counter
)
for node in _iter_ui_nodes():
named = node.get("widgets_values_named")
if not isinstance(named, dict) or not named:
continue
keys = tuple(
str(k) for k in named.keys() if k and k not in BUTTONS
)
if keys and len(keys) <= MAX_ORDER_LEN:
learned[str(node.get("type") or "")][keys] += 1
for workflow in _iter_api_workflows():
for node in workflow.values():
if not isinstance(node, dict):
continue
names = [
str(k)
for k, v in (node.get("inputs") or {}).items()
if not isinstance(v, list) and k and k not in BUTTONS
]
if len(names) > MAX_ORDER_LEN:
continue
# Display nodes (PreviewAny…) carry zero scalar inputs — that
# empty order is real signal: their positional arrays are pure
# button leftovers and map to {}.
learned[str(node.get("class_type") or "")][tuple(names)] += 1
registry: dict[str, list[list[str]]] = {}
for node_type, counter in sorted(learned.items()):
if not node_type:
continue
orders: list[list[str]] = []
for signature, _count in counter.most_common(MAX_SIGNATURES):
orders.append(list(signature))
if orders:
registry[node_type] = orders
for node_type, orders in CURATED.items():
registry.setdefault(node_type, orders)
body = json.dumps(registry, ensure_ascii=False, indent=1, sort_keys=True)
OUT.write_text(
'"""GENERATED by scripts/build_widget_orders.py — do not edit by hand.\n'
"\n"
"Widget-order registry for the UI→API converter: class_type → candidate\n"
"widget name orders, learned from the repo's named-widget UI files and\n"
"ground-truth API exports (HEL-681).\n"
'"""\n'
"\n"
"from __future__ import annotations\n"
"\n"
f"WIDGET_ORDERS: dict[str, list[list[str]]] = {body}\n",
encoding="utf-8",
)
total = sum(len(o) for orders in registry.values() for o in orders)
print(f"wrote {OUT.name}: {len(registry)} node types, {total} orders total")
if __name__ == "__main__":
main()
@@ -0,0 +1,170 @@
"""Generate the HEL-681 dry-run report over the repo's UI workflow library.
Walks ``example_ComfyUI_workflows`` (or ``--root``), runs every file through
the production converter + lint + parser pipeline (exactly what
``POST /api/workflows/convert`` does, minus HTTP), and writes a JSON detail
file plus a human-readable Markdown summary. Read-only: nothing is imported,
no DB is touched.
Usage:
python scripts/ui_import_dryrun.py [--root DIR] [--out PREFIX]
"""
from __future__ import annotations
import argparse
import collections
import json
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND / "src"))
from calliope.comfyui.lint import workflow_warnings # noqa: E402
from calliope.comfyui.metadata import suggested_metadata # noqa: E402
from calliope.comfyui.parser import classify_usage # noqa: E402
from calliope.comfyui.ui_convert import convert_ui_workflow # noqa: E402
REPO = BACKEND.parent
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--root", default=str(REPO / "example_ComfyUI_workflows"))
parser.add_argument("--out", default=str(REPO / "dryrun_report"))
args = parser.parse_args()
root = Path(args.root)
out_prefix = Path(args.out)
entries = []
for path in sorted(root.rglob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
entries.append(
{
"source_path": str(path.relative_to(root)),
"status": "fail",
"issues": [{"code": "unreadable", "message": str(exc)}],
}
)
continue
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
continue # API-format / non-workflow files are out of scope
rel = str(path.relative_to(root))
result = convert_ui_workflow(data)
entry: dict = {
"source_path": rel,
"status": result.status,
"needs_confirmation": result.needs_confirmation,
"issues": [
{"node_id": i.node_id, "code": i.code, "level": i.level, "message": i.message}
for i in result.issues
],
}
if result.workflow is not None:
entry["node_count"] = len(result.workflow)
entry["suggested"] = suggested_metadata(result.workflow, rel, data)
errors = [w for w in workflow_warnings(result.workflow) if w["level"] == "error"]
entry["lint_errors"] = [e["code"] for e in errors]
entry["importable"] = not result.needs_confirmation and not errors
else:
entry["importable"] = False
entries.append(entry)
counts = collections.Counter(e["status"] for e in entries)
code_counts = collections.Counter(
i["code"] for e in entries for i in e["issues"]
)
category_counts = collections.Counter(
(e.get("suggested") or {}).get("category") or "?" for e in entries
)
family_counts = collections.Counter(
(e.get("suggested") or {}).get("model_family") or "?" for e in entries
)
importable = sum(1 for e in entries if e.get("importable"))
needs_confirmation = sum(1 for e in entries if e["needs_confirmation"])
lint_blocked = [e for e in entries if e.get("lint_errors")]
detail = {
"root": str(root),
"total": len(entries),
"status_counts": dict(counts),
"issue_code_counts": dict(code_counts),
"importable": importable,
"needs_confirmation": needs_confirmation,
"lint_blocked": [e["source_path"] for e in lint_blocked],
"files": entries,
}
out_prefix.parent.mkdir(parents=True, exist_ok=True)
(out_prefix.with_suffix(".json")).write_text(
json.dumps(detail, ensure_ascii=False, indent=1), encoding="utf-8"
)
lines = [
"# HEL-681 工作流 UI→API 转换 dry-run 报告",
"",
f"- 扫描目录:`{root}`(根目录 10 个 API 格式旧文件不在本管线范围)",
f"- UI 格式文件总数:**{len(entries)}**",
f"- 转换结果:ok **{counts.get('ok', 0)}** / warn **{counts.get('warn', 0)}** / fail **{counts.get('fail', 0)}**",
f"- 其中「需人工确认」(控件无法可靠映射,禁止直接导入):**{needs_confirmation}**",
f"- 通过转换+lint 后可直接导入:**{importable}**",
f"- 转换成功但被现有 lint 拦截(导入时按阻止处理):**{len(lint_blocked)}**",
"",
"## 分类分布(自动提取)",
"",
"| 分类 | 数量 |",
"|---|---|",
]
for cat, n in sorted(category_counts.items()):
lines.append(f"| {cat} | {n} |")
lines += ["", "## 模型家族分布(自动提取)", "", "| 家族 | 数量 |", "|---|---|"]
for fam, n in sorted(family_counts.items(), key=lambda kv: -kv[1]):
lines.append(f"| {fam} | {n} |")
lines += ["", "## 问题代码统计", "", "| 代码 | 次数 | 含义 |", "|---|---|---|"]
meaning = {
"muted_input_dropped": "上游节点被静音,输入按未连接处理",
"variable_upstream_dropped": "Set/Get 变量上游被旁路,取值按未连接处理",
"get_without_set": "GetNode 没有同名 SetNode",
"widget_unmapped": "控件值无法可靠映射到输入名(需人工确认)",
"widget_order_mismatch": "控件数量与已知顺序不符(需人工确认)",
"no_output_node": "图中无已知输出节点,全部活跃节点保留待确认",
"cyclic_variable": "Set/Get 循环(FOR 循环流),该连线按未连接处理",
"dangling_link": "连线指向不存在的节点",
}
for code, n in code_counts.most_common():
lines.append(f"| `{code}` | {n} | {meaning.get(code, '')} |")
lines += ["", "## 需人工确认文件清单", ""]
for e in entries:
if e["needs_confirmation"]:
reasons = sorted({i["code"] for i in e["issues"]})[:3]
lines.append(f"- `{e['source_path']}`({', '.join(reasons)})")
lines += ["", "## lint 拦截文件清单", ""]
for e in lint_blocked:
lines.append(f"- `{e['source_path']}`({', '.join(e['lint_errors'])})")
lines += [
"",
"## 逐文件结果",
"",
"| 文件 | 状态 | 节点数 | 分类 | 模型家族 | 用途 | 问题 |",
"|---|---|---|---|---|---|---|",
]
for e in entries:
sug = e.get("suggested") or {}
issues = collections.Counter(i["code"] for i in e["issues"])
issue_text = ", ".join(f"{c}×{n}" for c, n in issues.most_common(2)) or "-"
usage = sug.get("usage") or ("待定" if sug.get("usage_ambiguous") else "-")
lines.append(
f"| `{e['source_path']}` | {e['status']} | {e.get('node_count', '-')} "
f"| {sug.get('category') or '-'} | {sug.get('model_family') or '-'} "
f"| {usage} | {issue_text} |"
)
(out_prefix.with_suffix(".md")).write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"{len(entries)} files: {dict(counts)}, importable={importable}, nc={needs_confirmation}")
print(f"report → {out_prefix}.md / {out_prefix}.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import time
import uuid
from pathlib import Path
from typing import Any
@@ -13,6 +14,22 @@ from calliope.comfyui.registry import IMAGE_CLASSES, AUDIO_CLASSES, VIDEO_CLASSE
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 +93,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")}
@@ -0,0 +1,160 @@
"""Suggested workflow metadata for imports (HEL-681 / S2 data layer).
Everything here derives from two honest sources: the repo directory the file
came from (category) and the converted API graph itself (usage, media in/out,
model family, note text). Per HEL-675's inventory, clean display names and
tags stay human work — the suggestion is a starting point, never a guess that
hides uncertainty.
"""
from __future__ import annotations
import re
from typing import Any
from calliope.comfyui.parser import (
classify_usage,
parse_dynamic_inputs,
parse_dynamic_outputs,
)
# Repo top-level directory → (category key, display label). Directory names
# are mapping sources only — they never surface to users verbatim.
CATEGORY_DIRS: dict[str, tuple[str, str]] = {
"0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大": ("basic", "基础处理"),
"1-语音生成": ("audio_tts", "语音生成"),
"2-音乐生成": ("music", "音乐生成"),
"3-图像生成": ("image", "图像生成"),
"4-视频生成": ("video", "视频生成"),
"5-数字人": ("digital_human", "数字人"),
"6-动作迁移": ("motion_transfer", "动作迁移"),
}
# Evidence patterns → model family. Shared VAE/text-encoder files must NOT
# count (HEL-675: Anima/Z-Image graphs reference qwen_image_vae and get
# mislabeled Qwen — `qwen.?image(?!_vae)` is deliberate).
_FAMILY_PATTERNS: tuple[tuple[str, str], ...] = (
(r"minimax[_\-]?(music|_audio)|MiniMaxMusic", "MiniMax-Music"),
(r"minimax.?h3|minimax_h3|h3[-_]realism|fl2v[aa]?", "MiniMax-H3"),
(r"ltx[-_.]?2", "LTX-2.5"),
(r"ltx", "LTX"),
(r"krea", "Krea2"),
(r"qwen.?image(?!_vae)|qwen_image_2|qwen2vl|qwen3vl|Qwen3?[-.]?ASR|qwen3?[-_.]?tts", "Qwen"),
(r"z[._-]?image", "Z-Image"),
(r"flux", "Flux"),
(r"\bwan", "Wan"),
(r"ace[_-]?step", "ACE-Step"),
(r"index[_-]?tts", "IndexTTS2"),
(r"anima[-_.]?(turbo|klein)", "Anima"),
(r"sdpose", "SDPose"),
(r"gemma", "Gemma"),
(r"instantmesh|instant_mesh", "InstantMesh"),
(r"hidream", "HiDream"),
)
_FAMILY_RE = [(re.compile(pat, re.IGNORECASE), fam) for pat, fam in _FAMILY_PATTERNS]
# Main-model loaders dominate the vote: a UNET/checkpoint reference says far
# more about the workflow's family than a shared VAE or text encoder does.
_PRIMARY_LOADER_RE = re.compile(r"UNETLoader|CheckpointLoader|unet|diffusion_model", re.IGNORECASE)
# Directory names that carry family evidence the files' own widget values
# lack (HEL-675: 14 krea2 files reference no "krea" string at all).
_FAMILY_DIRS = (
(re.compile(r"krea2", re.IGNORECASE), "Krea2"),
(re.compile(r"Minimax-h3|uncensored", re.IGNORECASE), "MiniMax-H3"),
(re.compile(r"Z-Image", re.IGNORECASE), "Z-Image"),
(re.compile(r"Qwen-Image", re.IGNORECASE), "Qwen"),
(re.compile(r"^Anima$", re.IGNORECASE), "Anima"),
(re.compile(r"Flux", re.IGNORECASE), "Flux"),
)
_NOTE_TYPES = frozenset({"Note", "MarkdownNote"})
def category_from_source_path(source_path: str | None) -> tuple[str | None, str | None]:
"""(category key, label) derived from the file's repo-relative path."""
if not source_path:
return None, None
head = source_path.replace("\\", "/").split("/", 1)[0]
return CATEGORY_DIRS.get(head, (None, None))
def model_family_from(workflow: dict[str, Any], source_path: str | None = None) -> str | None:
"""The graph's dominant model family: main-model loader references weigh
triple, other node evidence single, and the repo sub-directory adds a
strong vote for families the widget values don't spell out."""
votes: dict[str, int] = {}
for node in workflow.values():
if not isinstance(node, dict):
continue
class_type = str(node.get("class_type") or "")
blob = " ".join(
[class_type]
+ [str(v) for v in (node.get("inputs") or {}).values() if isinstance(v, str)]
)
weight = 3 if _PRIMARY_LOADER_RE.search(blob) else 1
for pattern, family in _FAMILY_RE:
if pattern.search(blob):
votes[family] = votes.get(family, 0) + weight
break # one vote per node: strongest family that node shows
if source_path:
parts = source_path.replace("\\", "/").split("/")
for part in parts[1:2]:
for pattern, family in _FAMILY_DIRS:
if pattern.search(part):
votes[family] = votes.get(family, 0) + 2
if not votes:
return None
return max(votes.items(), key=lambda kv: kv[1])[0]
def note_description(ui_workflow: dict[str, Any], limit: int = 160) -> str | None:
"""First non-trivial author note as description material (never final copy)."""
for node in ui_workflow.get("nodes") or []:
if not isinstance(node, dict) or str(node.get("type")) not in _NOTE_TYPES:
continue
values = node.get("widgets_values")
text = values[0] if isinstance(values, list) and values else None
if isinstance(values, dict):
text = values.get("text") or next(iter(values.values()), None)
if isinstance(text, str):
text = text.strip()
if len(text) >= 8:
return text[:limit].strip()
return None
def suggested_metadata(
converted: dict[str, Any],
source_path: str | None = None,
ui_workflow: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""The import suggestion bundle for one converted workflow."""
judgment = classify_usage(converted)
media_kinds = ("image", "image_list", "video", "video_list", "audio", "audio_list")
media_in = sorted(
{
entry["kind"].replace("_list", "")
for entry in parse_dynamic_inputs(converted)
if entry.get("kind") in media_kinds
}
)
media_out = sorted(
{
str(entry.get("kind"))
for entry in parse_dynamic_outputs(converted)
if entry.get("kind") in ("image", "video", "audio")
}
)
category, category_label = category_from_source_path(source_path)
return {
"kind": "video" if "video" in media_out and "image" not in media_out else "image",
"usage": judgment["usage"],
"usage_ambiguous": judgment["ambiguous"],
"category": category,
"category_label": category_label,
"model_family": model_family_from(converted, source_path),
"media_in": media_in,
"media_out": media_out,
"description": note_description(ui_workflow or {}),
}
+136 -30
View File
@@ -13,12 +13,14 @@ from calliope.comfyui.registry import (
IMAGE_OUTPUT_CLASSES,
IMAGE_URL_CLASSES,
NUMBER_CLASSES,
OPTIONAL_FRAME_INPUTS,
TEXT_AREA_CLASSES,
VIDEO_CLASSES,
VIDEO_OUTPUT_CLASSES,
ComfyOutputKind,
class_to_input_kind,
class_to_output_kind,
optional_frame_sibling,
)
from calliope.comfyui.roles import (
USAGE_VALUES,
@@ -281,17 +283,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 +335,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 +394,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 +409,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 +417,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 +513,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 +539,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 +568,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 +691,8 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
)
)
media = _with_optional_frames(workflow, media)
results: list[dict[str, Any]] = []
positives = [t for t in texts if not t[3]]
negatives = [t for t in texts if t[3]]
@@ -666,6 +736,42 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
return results
def _with_optional_frames(
workflow: dict[str, Any], media: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Insert an optional slot after each frame slot whose node also accepts
an unwired optional frame (H3 FL2VA: 首帧 → 尾帧)."""
out = list(media)
for nid, node in sorted(
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
key=lambda kv: _node_sort_key(kv[0]),
):
for field in OPTIONAL_FRAME_INPUTS.get(str(node.get("class_type") or ""), {}):
sibling = optional_frame_sibling(node, field)
if sibling is None:
continue
loader_id = str(node["inputs"][sibling][0])
loader = workflow.get(loader_id)
if not isinstance(loader, dict) or loader.get("class_type") not in IMAGE_CLASSES:
continue
at = next(
(
i
for i, e in enumerate(out)
if e["nodeId"] == _key(loader_id, loader, "image")
),
None,
)
if at is None:
continue
role, label = _FRAME_SLOT_ROLES[field]
out.insert(
at + 1,
_entry(f"{nid}{FIELD_KEY_SEP}{field}", label, role, "image", None, False),
)
return out
def _is_negative(
node_id: str, lowered_title: str, consumers: dict[str, list[tuple[str, str]]]
) -> bool:
@@ -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,7 +1,7 @@
"""Unified class_type → kind + patch-field registry (MuseAgent port)."""
from __future__ import annotations
from typing import Literal
from typing import Any, Literal
ComfyInputKind = Literal["text", "textarea", "number", "image", "image_url", "audio", "video"]
ComfyOutputKind = Literal["image", "video", "other"]
@@ -33,6 +33,24 @@ IMAGE_OUTPUT_CLASSES = frozenset(
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket",
"SaveImageAdvanced"}
)
# Frame-conditioned video nodes and the optional frame input an export may
# leave unwired. MiniMax H3's FL2VA node takes `first_frame` plus an
# optional `last_frame`; the studio offers the slot and the patcher adds
# the LoadImage only when the user supplies that frame.
OPTIONAL_FRAME_INPUTS: dict[str, dict[str, str]] = {
"MiniMaxH3ImageToVideo": {"last_frame": "first_frame"},
}
def optional_frame_sibling(node: dict[str, Any], field: str) -> str | None:
"""The wired frame input an unwired optional ``field`` mirrors
(``last_frame`` → ``first_frame``); None when it does not apply."""
inputs = node.get("inputs") or {}
sibling = OPTIONAL_FRAME_INPUTS.get(str(node.get("class_type") or ""), {}).get(field)
if sibling is None or field in inputs:
return None
link = inputs.get(sibling)
return sibling if isinstance(link, list) and len(link) == 2 else None
def class_to_input_kind(class_type: str) -> ComfyInputKind:
@@ -0,0 +1,801 @@
"""UI→API converter for ComfyUI workflow JSON (HEL-681 / S2 data layer).
ComfyUI saves two formats: the editor ("UI") format with ``nodes``/``links``
arrays, and the API ("prompt") format ``{node_id: {class_type, inputs}}`` the
product executes. This module converts UI→API offline and deterministically:
* links are rewritten through ``Reroute`` chains and bypassed (mode 4) nodes;
* packed subgraph instances (frontend ≥1.x ``definitions.subgraphs``) expand
into their inner nodes under qualified ids ``<packed>:<inner>`` — the same
scheme ComfyUI itself exports;
* widget values map through, in priority order: the node's serialized input
list (entries carrying a ``widget`` marker — authoritative, this corpus's
frontend version always writes them), the ``widgets_values_named`` dict,
then a generated registry of widget orders learned from the corpus and the
repo's ground-truth API exports (``ui_widget_orders``).
Anything that cannot be mapped with certainty is reported as an issue and the
file is flagged ``needs_confirmation`` — never silently guessed: a wrong
widget order produces a runnable-looking workflow with swapped seed/steps,
which is worse than an honest「需人工确认」.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from calliope.comfyui.ui_widget_orders import WIDGET_ORDERS
# Values of the frontend-only "control_after_generate" control that serializes
# right after every seed-like INT widget (and is not a real server input).
_CONTROL_MODES = frozenset({"fixed", "increment", "decrement", "randomize"})
# Widget names that are pure UI buttons/previews — never serialized in
# widgets_values (named dicts skip them too) and never API inputs.
_BUTTON_WIDGETS = frozenset(
{"preview", "audioUI", "compare_view", "ToggleableColorPicker", "imagebrowser"}
)
# Leftover positional values that are provably action buttons, not data:
# known labels, self-describing ids, or emoji-prefixed actions. Anything
# else left over keeps the file in the needs-confirmation queue.
_BUTTON_LEFTOVER_LABELS = frozenset({"Edit", "lanpaint_star_button"})
_BUTTON_LEFTOVER_EMOJI = ("🖼", "🎬", "🎵", "➕", "🔁", "🗑", "📂", "🔎", "✨")
def _is_button_leftover(value: Any) -> bool:
if value is None or value == "":
return True
if isinstance(value, str):
return (
value in _BUTTON_LEFTOVER_LABELS
or "button" in value.lower()
or value.startswith(_BUTTON_LEFTOVER_EMOJI)
)
return False
# Virtual editor-only nodes. Links pass *through* reroute-style ones (see
# _resolve_source); the rest are dropped from the graph entirely.
_REROUTE_TYPES = frozenset({"Reroute", "Reroute (rgthree)"})
# Frontend-virtual variable nodes: SetNode captures a value under a name,
# GetNode reads it. Neither reaches the server — links through a Get resolve
# to whatever its same-named Set captured (scope-local).
_SET_TYPES = frozenset({"SetNode"})
_GET_TYPES = frozenset({"GetNode"})
_UI_ONLY_TYPES = frozenset(
{
"Note",
"MarkdownNote",
"Image Comparer (rgthree)",
"GH_ImageVideoComparer",
"Label (rgthree)",
"Fast Groups Bypasser (rgthree)",
"Fast Groups Muter (rgthree)",
"Fast Muter (rgthree)",
"FastGroupsBypassSwitch",
"Mute / Bypass Repeater (rgthree)",
}
)
# Server-side OUTPUT nodes: the closure roots. Learned from the repo's
# ground-truth API exports (their unreferenced classes) + the corpus's
# real save/preview sinks (SaveVideo/SaveImageAdvanced/… custom nodes).
_OUTPUT_CLASSES = frozenset(
{
"SaveImage",
"PreviewImage",
"SaveImageAdvanced",
"SaveImageKJ",
"SaveAnimatedWEBP",
"SaveAnimatedPNG",
"SaveAnimatedGIF",
"SaveVideo",
"SaveAudio",
"SaveAudioMP3",
"SaveAudioAdvanced",
"PreviewAudio",
"MaskPreview",
"MaskPreview+",
"ImageAndMaskPreview",
"VHS_VideoCombine",
"easy saveVideo",
"easy multitrackProjectVideoCombine",
"easy multiTrackInfoOutput",
"easy multiTrackTaskOutput",
"easy compareVideos",
"easy compareImages",
"easy showText",
"easy imageWall",
"CR Prompt Text",
"PreviewAny",
"easy clearGpuUsed",
"easy clearCacheAll",
"easy showSpentTime",
}
)
MODE_ACTIVE = 0
MODE_MUTED = 2
MODE_BYPASSED = 4
_SUBGRAPH_IN = -10 # pseudo-node id of a subgraph's boundary input
_SUBGRAPH_OUT = -20 # pseudo-node id of a subgraph's boundary output
@dataclass
class ConvertIssue:
node_id: str
code: str
message: str
level: str = "warning" # 'error' | 'warning'
@dataclass
class ConvertResult:
workflow: dict[str, Any] | None
status: str # 'ok' | 'warn' | 'fail'
issues: list[ConvertIssue] = field(default_factory=list)
@property
def needs_confirmation(self) -> bool:
"""Widget mapping uncertain — must not be imported as runnable."""
return any(
i.code in ("widget_unmapped", "widget_order_mismatch") for i in self.issues
)
def _norm_link(link: Any) -> tuple[int, int, int, int, int, str] | None:
"""(id, origin_id, origin_slot, target_id, target_slot, type) from either form."""
if isinstance(link, dict):
try:
return (
int(link["id"]),
int(link["origin_id"]),
int(link["origin_slot"]),
int(link["target_id"]),
int(link["target_slot"]),
str(link.get("type") or "*"),
)
except (KeyError, TypeError, ValueError):
return None
if isinstance(link, (list, tuple)) and len(link) >= 5:
try:
return (
int(link[0]),
int(link[1]),
int(link[2]),
int(link[3]),
int(link[4]),
str(link[5]) if len(link) > 5 and link[5] else "*",
)
except (TypeError, ValueError):
return None
return None
def _is_seed_like(name: str) -> bool:
lowered = name.lower()
return lowered in ("seed", "noise_seed", "value", "rand_seed", "random_seed") or (
lowered.endswith("seed")
)
def _is_upload_entry(name: str, type_: str) -> bool:
return type_.upper().endswith("UPLOAD") or name == "upload" or "UPLOAD" in type_.upper()
class _Converter:
"""One conversion run over a single UI-format document."""
def __init__(self, ui: dict[str, Any]) -> None:
self.ui = ui
self.issues: list[ConvertIssue] = []
self.subgraphs: dict[str, dict[str, Any]] = {
str(s.get("id")): s
for s in self.ui.get("definitions", {}).get("subgraphs", []) or []
if isinstance(s, dict)
}
# qualified id → UI node + owning scope
self.nodes: dict[str, dict[str, Any]] = {}
# scope key ('' for top level) → link table / node-id index / owner
self.scope_links: dict[str, dict[int, tuple[int, int, int, int, str]]] = {}
self.scope_ids: dict[str, dict[str, str]] = {}
self.scope_owner: dict[str, str] = {}
self._set_capture_cache: dict[str, dict[str, tuple[str, int]]] = {}
self._set_names: dict[str, set[str]] = {}
self._depth = 0
self._cycle_warned = False
self._set_building: set[str] = set()
# -------------------------------------------------------------- indexing
def _index_scope(self, scope: str, links: list[Any], owner: str | None) -> None:
table = self.scope_links.setdefault(scope, {})
for link in links or []:
norm = _norm_link(link)
if norm is not None:
table[norm[0]] = norm[1:]
self.scope_ids.setdefault(scope, {})
if owner is not None:
self.scope_owner[scope] = owner
def _flatten(
self, node: dict[str, Any], scope: str, prefix: str
) -> None:
qualified = f"{prefix}{node.get('id')}"
self.nodes[qualified] = {"node": node, "scope": scope}
self.scope_ids.setdefault(scope, {})[str(node.get("id"))] = qualified
sub = self.subgraphs.get(str(node.get("type") or ""))
if sub is None:
return
if not isinstance(sub.get("nodes"), list):
self.issues.append(
ConvertIssue(qualified, "subgraph_bad_definition", "子图缺少内部节点定义")
)
return
sub_scope = f"{scope}|{node.get('type')}" if scope else f"{node.get('type')}"
if sub_scope not in self.scope_links:
self._index_scope(sub_scope, sub.get("links") or [], qualified)
for inner in sub["nodes"]:
if isinstance(inner, dict):
self._flatten(inner, sub_scope, f"{qualified}:")
# --------------------------------------------------------------- lookups
def _node(self, qualified: str) -> dict[str, Any] | None:
entry = self.nodes.get(qualified)
return entry["node"] if entry else None
def _scope(self, qualified: str) -> str:
entry = self.nodes.get(qualified)
return entry["scope"] if entry else ""
def _link(self, scope: str, link_id: Any) -> tuple[int, int, int, int, str] | None:
if link_id is None:
return None
try:
key = int(link_id)
except (TypeError, ValueError):
return None
return self.scope_links.get(scope, {}).get(key)
def _boundary_source(self, scope: str, link_id: Any) -> tuple[str, int] | None:
"""(packed qualified id, boundary slot) when this link leaves a
subgraph through its -10 input boundary."""
link = self._link(scope, link_id)
if link is None or link[0] != _SUBGRAPH_IN:
return None
packed_q = self.scope_owner.get(scope)
if packed_q is None:
return None
return packed_q, link[1]
def _subgraph_of(self, qualified: str) -> dict[str, Any] | None:
node = self._node(qualified)
if node is None:
return None
return self.subgraphs.get(str(node.get("type") or ""))
@staticmethod
def _virtual_name(node: dict[str, Any]) -> str:
"""A Set/Get node's captured variable name (its first widget value)."""
named = node.get("widgets_values_named")
if isinstance(named, dict):
for key in ("const_name", "name", "value"):
if key in named:
return str(named[key])
values = node.get("widgets_values")
if isinstance(values, list) and values and isinstance(values[0], str):
return values[0]
return ""
def _set_captures(self, scope: str) -> dict[str, tuple[str, int]]:
"""{variable name → resolved source} for the Set nodes in one scope."""
table = self._set_capture_cache.get(scope)
if table is not None:
return table
table = {}
# Seed before resolving: a Set feeding a same-named Get (FOR-loop
# graphs) would otherwise recurse into this builder forever. A miss
# while building is not a missing Set — never warn mid-construction.
self._set_capture_cache[scope] = table
self._set_building.add(scope)
try:
return self._build_set_captures(scope, table)
finally:
self._set_building.discard(scope)
def _build_set_captures(
self, scope: str, table: dict[str, tuple[str, int]]
) -> dict[str, tuple[str, int]]:
names: set[str] = set()
for qualified, entry in self.nodes.items():
if entry["scope"] != scope:
continue
node = entry["node"]
if str(node.get("type") or "") not in _SET_TYPES:
continue
name = self._virtual_name(node)
if not name:
continue
names.add(name)
ins = self._inputs_of(node)
link = self._link(scope, ins[0].get("link")) if ins else None
resolved = self._resolve_link(scope, link)
if resolved is not None:
table.setdefault(name, resolved)
self._set_names[scope] = names
self._set_capture_cache[scope] = table
return table
@staticmethod
def _inputs_of(node: dict[str, Any]) -> list[dict[str, Any]]:
return [i for i in node.get("inputs") or [] if isinstance(i, dict)]
@staticmethod
def _outputs_of(node: dict[str, Any]) -> list[dict[str, Any]]:
return [o for o in node.get("outputs") or [] if isinstance(o, dict)]
# ------------------------------------------------------------- resolution
def _resolve_link(
self, scope: str, link: tuple[int, int, int, int, str] | None
) -> tuple[str, int] | None:
"""Resolve one link (in scope) to an API source (qualified id, slot)."""
if link is None:
return None
if self._depth > 200: # Set/Get cycles (FOR-loop graphs) never unwind
if not self._cycle_warned:
self._cycle_warned = True
self.issues.append(
ConvertIssue(
"",
"cyclic_variable",
"变量/Set-Get 连线存在循环(常见于 FOR 循环流),该连线按未连接处理",
)
)
return None
self._depth += 1
try:
return self._resolve_link_inner(scope, link)
finally:
self._depth -= 1
def _resolve_link_inner(
self, scope: str, link: tuple[int, int, int, int, str]
) -> tuple[str, int] | None:
origin_id, origin_slot = link[0], link[1]
if origin_id == _SUBGRAPH_IN:
# Boundary: hand off to the packed instance's own input slot.
packed_q = self.scope_owner.get(scope)
if packed_q is None:
return None
node = self._node(packed_q)
if node is None:
return None
entries = self._inputs_of(node)
if origin_slot >= len(entries):
return None
entry = entries[origin_slot]
if entry.get("link") is None:
return None # unlinked boundary → inner widget value applies
outer = self._scope(packed_q)
return self._resolve_link(outer, self._link(outer, entry.get("link")))
qualified = self.scope_ids.get(scope, {}).get(str(origin_id))
if qualified is None:
return None
return self._resolve_source(qualified, origin_slot)
def _resolve_source(self, qualified: str, slot: int) -> tuple[str, int] | None:
"""What an outgoing edge from (node, output slot) really connects to."""
node = self._node(qualified)
if node is None:
return None
node_type = str(node.get("type") or "")
mode = node.get("mode") or 0
scope = self._scope(qualified)
if node_type in _UI_ONLY_TYPES:
return None
if node_type in _REROUTE_TYPES:
ins = self._inputs_of(node)
if not ins:
return None
return self._resolve_link(scope, self._link(scope, ins[0].get("link")))
if node_type in _SET_TYPES:
ins = self._inputs_of(node)
if not ins:
return None
return self._resolve_link(scope, self._link(scope, ins[0].get("link")))
if node_type in _GET_TYPES:
name = self._virtual_name(node)
captured = self._set_captures(scope).get(name)
if captured is None and scope not in self._set_building:
if name in self._set_names.get(scope, set()):
self.issues.append(
ConvertIssue(
qualified,
"variable_upstream_dropped",
f"变量「{name}」的上游被旁路或未连接,取值按未连接处理",
)
)
else:
self.issues.append(
ConvertIssue(
qualified,
"get_without_set",
f"变量节点「{name}」没有对应的 Set 节点",
)
)
return captured
sub = self._subgraph_of(qualified)
if sub is not None:
outputs = [o for o in sub.get("outputs") or [] if isinstance(o, dict)]
if slot >= len(outputs):
return None
sub_scope = f"{scope}|{node_type}" if scope else node_type
for link in self.scope_links.get(sub_scope, {}).values():
if link[2] == _SUBGRAPH_OUT and link[3] == slot:
inner_q = self.scope_ids.get(sub_scope, {}).get(str(link[0]))
return self._resolve_source(inner_q, link[1]) if inner_q else None
return None # boundary output with no inner source
if mode == MODE_MUTED:
return None
if mode == MODE_BYPASSED:
return self._bypass_through(qualified, slot)
return (qualified, slot)
def _bypass_through(self, qualified: str, slot: int) -> tuple[str, int] | None:
"""Pass-through of a bypassed node: same-name input first, then same-type."""
node = self._node(qualified)
if node is None:
return None
outputs = self._outputs_of(node)
out = outputs[slot] if slot < len(outputs) else {}
out_name = str(out.get("name") or "")
out_type = str(out.get("type") or "*")
linkable = [
e for e in self._inputs_of(node) if not e.get("widget") and e.get("link") is not None
]
pick = None
if out_name:
pick = next((e for e in linkable if str(e.get("name") or "") == out_name), None)
if pick is None:
pick = next(
(
e
for e in linkable
if out_type == "*" or str(e.get("type") or "*") in (out_type, "*")
),
None,
)
if pick is None:
return None
scope = self._scope(qualified)
return self._resolve_link(scope, self._link(scope, pick.get("link")))
# --------------------------------------------------------- widget mapping
def _widget_entries(self, node: dict[str, Any]) -> list[tuple[str, str]]:
"""[(name, type)] of entries carrying the ``widget`` marker, in order."""
out: list[tuple[str, str]] = []
for entry in self._inputs_of(node):
marker = entry.get("widget")
if isinstance(marker, dict):
name = str(marker.get("name") or entry.get("name") or "")
if name:
out.append((name, str(entry.get("type") or "")))
return out
def _widgets_from_entries(
self,
node_id: str,
entries: list[tuple[str, str]],
values: Any,
) -> tuple[dict[str, Any], list[ConvertIssue]]:
"""Map widget values using the node's serialized input list."""
inputs: dict[str, Any] = {}
issues: list[ConvertIssue] = []
if isinstance(values, dict):
for name, type_ in entries:
if name in _BUTTON_WIDGETS or self._is_upload(name, type_):
continue
if name in values:
inputs[name] = values[name]
else:
issues.append(
ConvertIssue(node_id, "widget_unmapped", f"控件「{name}」没有序列化值")
)
return inputs, issues
if not isinstance(values, list):
return inputs, issues
if not values:
return inputs, issues # nothing serialized — server defaults apply
i = 0
for name, type_ in entries:
if name in _BUTTON_WIDGETS:
continue # button widgets never serialize
if i >= len(values):
issues.append(
ConvertIssue(node_id, "widget_order_mismatch", f"控件值数量不足({name})")
)
break
value = values[i]
i += 1
if self._is_upload(name, type_):
continue # consumes a slot, never an API input
inputs[name] = value
if (
type_.upper() == "INT"
and _is_seed_like(name)
and i < len(values)
and values[i] in _CONTROL_MODES
):
i += 1 # control_after_generate rides right after its seed
leftover_ok = all(_is_button_leftover(v) for v in values[i:])
if not issues and i != len(values) and not leftover_ok:
issues.append(
ConvertIssue(
node_id,
"widget_order_mismatch",
f"有 {len(values)} 个控件值,输入清单只覆盖 {i} 个",
)
)
return inputs, issues
@staticmethod
def _is_upload(name: str, type_: str) -> bool:
return _is_upload_entry(name, type_)
def _widgets_from_registry(
self, node: dict[str, Any], values: list[Any]
) -> tuple[dict[str, Any], list[ConvertIssue]]:
node_type = str(node.get("type") or "")
if not values:
return {}, [] # nothing serialized — server defaults apply
for order in WIDGET_ORDERS.get(node_type) or []:
inputs: dict[str, Any] = {}
i = 0
for name in order:
if name in _BUTTON_WIDGETS:
continue
if i >= len(values):
break
value = values[i]
i += 1
if _is_upload_entry(name, ""):
continue
inputs[name] = value
if _is_seed_like(name) and i < len(values) and values[i] in _CONTROL_MODES:
i += 1
if i == len(values) or all(_is_button_leftover(v) for v in values[i:]):
return inputs, [] # exact fit, or trailing button leftovers
return {}, [
ConvertIssue(
node.get("id", ""),
"widget_unmapped" if not WIDGET_ORDERS.get(node_type) else "widget_order_mismatch",
f"「{node_type}」的 {len(values)} 个控件值无法可靠对应到输入名",
)
]
def _packed_widget_values(self, qualified: str) -> Any:
"""Exposed widget values of a packed instance as {boundary name: value}."""
node = self._node(qualified)
sub = self._subgraph_of(qualified)
if node is None or sub is None:
return None
boundary = [i for i in sub.get("inputs") or [] if isinstance(i, dict)]
named = node.get("widgets_values_named")
if isinstance(named, dict):
return named
values = node.get("widgets_values")
if not isinstance(values, list):
return None
out: dict[str, Any] = {}
i = 0
for entry in boundary:
name = str(entry.get("name") or "")
type_ = str(entry.get("type") or "")
if name and i < len(values):
out[name] = values[i]
i += 1
if (
type_.upper() == "INT"
and _is_seed_like(name)
and i < len(values)
and values[i] in _CONTROL_MODES
):
i += 1
return out
# -------------------------------------------------------------- serialize
def _serialize_node(self, qualified: str) -> dict[str, Any] | None:
node = self._node(qualified)
if node is None:
return None
node_type = str(node.get("type") or "")
if (
not node_type
or node_type in _UI_ONLY_TYPES
or node_type in _REROUTE_TYPES
or node_type in _SET_TYPES
or node_type in _GET_TYPES
):
return None
scope = self._scope(qualified)
inputs: dict[str, Any] = {}
widget_values = node.get("widgets_values_named")
if not isinstance(widget_values, dict):
widget_values = node.get("widgets_values")
if self._subgraph_of(qualified) is not None:
widget_values = self._packed_widget_values(qualified)
widget_inputs: dict[str, Any] = {}
entries = self._widget_entries(node)
if widget_values is not None:
if entries:
widget_inputs, w_issues = self._widgets_from_entries(
qualified, entries, widget_values
)
self.issues.extend(w_issues)
elif isinstance(widget_values, dict):
widget_inputs = {
k: v for k, v in widget_values.items() if k not in _BUTTON_WIDGETS
}
elif isinstance(widget_values, list):
widget_inputs, w_issues = self._widgets_from_registry(node, widget_values)
self.issues.extend(w_issues)
for entry in self._inputs_of(node):
name = str(entry.get("name") or "")
if not name or name in inputs:
continue
if entry.get("link") is None:
continue # unconnected optional input; widget values applied below
# Widget entries fed from the subgraph boundary (-10): when the
# packed instance left that boundary slot unlinked, the value is
# the packed node's own exposed widget value, not the inner one.
boundary = self._boundary_source(scope, entry.get("link"))
if boundary is not None:
packed_q, boundary_slot = boundary
packed_node = self._node(packed_q)
packed_entries = self._inputs_of(packed_node) if packed_node else []
if boundary_slot < len(packed_entries) and packed_entries[
boundary_slot
].get("link") is None:
values = self._packed_widget_values(packed_q)
boundary_name = str(packed_entries[boundary_slot].get("name") or "")
if isinstance(values, dict) and boundary_name in values:
inputs[name] = values[boundary_name]
continue
# linked boundary falls through to normal resolution
link = self._link(scope, entry.get("link"))
if link is None:
self.issues.append(
ConvertIssue(qualified, "dangling_link", f"输入「{name}」的连线不存在")
)
continue
resolved = self._resolve_link(scope, link)
if resolved is not None:
inputs[name] = [resolved[0], resolved[1]]
elif entry.get("widget"):
pass # muted upstream: the widget value below takes over
else:
self.issues.append(
ConvertIssue(
qualified,
"muted_input_dropped",
f"输入「{name}」上游被静音,已按未连接处理",
)
)
for name, value in widget_inputs.items():
inputs.setdefault(name, value)
api_node: dict[str, Any] = {"class_type": node_type, "inputs": inputs}
title = str(node.get("title") or "").strip()
if title:
api_node["_meta"] = {"title": title}
return api_node
# ------------------------------------------------------------------- main
def run(self) -> ConvertResult:
nodes = self.ui.get("nodes")
if not isinstance(nodes, list) or not nodes:
return ConvertResult(
None,
"fail",
[ConvertIssue("", "no_nodes", "文件不含 nodes 数组,不是 UI 格式工作流", "error")],
)
try:
self._index_scope("", self.ui.get("links") or [], None)
for node in nodes:
if isinstance(node, dict):
self._flatten(node, "", "")
except RecursionError:
return ConvertResult(
None, "fail", [ConvertIssue("", "subgraph_recursion", "子图嵌套过深", "error")]
)
# Closure from known output nodes (ComfyUI's own graphToPrompt roots).
roots = [
q
for q, entry in self.nodes.items()
if str(entry["node"].get("type") or "") in _OUTPUT_CLASSES
and (entry["node"].get("mode") or 0) == MODE_ACTIVE
]
keep: dict[str, None] = {}
stack = list(roots)
while stack:
qualified = stack.pop()
if qualified in keep:
continue
entry = self.nodes.get(qualified)
if entry is None:
continue
keep[qualified] = None
node = entry["node"]
scope = entry["scope"]
for e in self._inputs_of(node):
if e.get("link") is None:
continue
link = self._link(scope, e.get("link"))
resolved = self._resolve_link(scope, link)
if resolved is not None:
stack.append(resolved[0])
if not keep:
# No known output node: keep every active computed node and warn —
# the import lint will report the missing output separately.
for qualified, entry in self.nodes.items():
node = entry["node"]
node_type = str(node.get("type") or "")
if (
node_type in _UI_ONLY_TYPES
or node_type in _REROUTE_TYPES
or (node.get("mode") or 0) != MODE_ACTIVE
or self._subgraph_of(qualified) is not None
):
continue
keep[qualified] = None
self.issues.append(
ConvertIssue(
"",
"no_output_node",
"图中没有已知输出节点(SaveImage / 视频合成等),"
"全部活跃节点已保留,需人工确认",
)
)
workflow: dict[str, Any] = {}
for qualified in keep:
api_node = self._serialize_node(qualified)
if api_node is not None and api_node["class_type"]:
workflow[qualified] = api_node
if not workflow:
return ConvertResult(
None,
"fail",
self.issues or [ConvertIssue("", "empty", "转换后没有任何节点", "error")],
)
status = (
"fail"
if any(i.level == "error" for i in self.issues)
else ("ok" if not self.issues else "warn")
)
return ConvertResult(workflow, status, self.issues)
def convert_ui_workflow(ui: dict[str, Any]) -> ConvertResult:
"""Convert one ComfyUI UI-format workflow dict into API format."""
if not isinstance(ui, dict) or not isinstance(ui.get("nodes"), list):
return ConvertResult(
None,
"fail",
[ConvertIssue("", "not_ui_format", "不是 UI 格式(缺少 nodes 数组)", "error")],
)
return _Converter(ui).run()
File diff suppressed because it is too large Load Diff
@@ -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")
+65
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import re
import sqlite3
from pathlib import Path
from typing import Any
@@ -410,6 +411,43 @@ def _backfill_workflow_output_map(conn: sqlite3.Connection) -> None:
)
# HEL-671: the user's H3 import went in under a first-frame-only name
# (“H3图生视频·首帧…”) although its graph is the FL2VA node that also takes
# a last frame. A row is renamed only when BOTH hold: its graph carries a
# wired MiniMaxH3ImageToVideo node, and its name still reads like a
# first-frame-only workflow — a row the user renamed, or any other graph,
# is left alone. Idempotent: the new name no longer matches.
_FIRST_FRAME_NAME_RE = re.compile(r"图生视频\s*[·\-—~]?\s*首帧")
_FL2VA_DESCRIPTION = "首帧必填,尾帧可选:只传首帧为图生视频,同时传尾帧为首尾帧生视频"
def _rename_workflows(conn: sqlite3.Connection) -> None:
for wid, name, wf_json in conn.execute(
"SELECT id, name, workflow_json FROM workflows"
).fetchall():
if not name or "尾帧" in name or not _FIRST_FRAME_NAME_RE.search(name):
continue
try:
workflow = json.loads(wf_json) if wf_json else None
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(workflow, dict):
continue
fl2va = any(
isinstance(node, dict)
and str(node.get("class_type") or "") == "MiniMaxH3ImageToVideo"
and isinstance((node.get("inputs") or {}).get("first_frame"), list)
for node in workflow.values()
)
if not fl2va:
continue
conn.execute(
"UPDATE workflows SET name = ?,"
" description = COALESCE(NULLIF(description, ''), ?) WHERE id = ?",
(_FIRST_FRAME_NAME_RE.sub("首尾帧生视频", name), _FL2VA_DESCRIPTION, wid),
)
async def migrate_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
@@ -468,10 +506,32 @@ async def migrate_db(db_path: Path) -> None:
conn.execute("ALTER TABLE workflows ADD COLUMN output_map TEXT")
if "output_map_source" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN output_map_source TEXT")
# HEL-681 (S2): library metadata for 150+ scale workflow browsing. Purely
# additive columns — old rows stay NULL/0 and every legacy path ignores
# them; the lightweight list view (?view=meta) is their only reader.
if "category" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN category TEXT")
if "model_family" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN model_family TEXT")
if "media_io" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN media_io TEXT")
if "tags" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN tags TEXT")
if "favorite" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0")
if "last_used_at" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN last_used_at TIMESTAMP")
if "source" not in cols:
# where the row came from: 'ui_convert' (HEL-681 batch import) vs
# NULL = manual/API import as before.
conn.execute("ALTER TABLE workflows ADD COLUMN source TEXT")
if "source_path" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN source_path TEXT")
# Idempotent backfill: pending rows are re-judged on every boot (parser
# 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 +578,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
@@ -243,10 +243,76 @@ class WorkflowUpdate(BaseModel):
# resets the workflow to pending confirmation.
output_map: list[dict[str, Any]] | None = None
output_map_source: Literal["auto", "user"] | None = None
# HEL-681 (S2): library metadata fields.
favorite: bool | None = None
tags: list[str] | None = None
category: str | None = None
model_family: str | None = None
class WorkflowUIFileItem(BaseModel):
"""One UI-format file for the HEL-681 convert/import pipeline."""
name: str | None = None
# repo-relative path (category/model-family evidence); display name
# defaults to the file stem.
source_path: str | None = None
workflow_json: dict[str, Any]
# optional human confirmations from the import wizard (S3): a settled
# usage and a corrected schema let warn files import enabled.
usage: WorkflowUsageKind | None = None
input_schema: list[dict[str, Any]] | None = None
output_map: list[dict[str, Any]] | None = None
description: str | None = None
tags: list[str] | None = None
class WorkflowConvertRequest(BaseModel):
"""Dry-run: per-file ok/warn/fail + suggested metadata, nothing stored."""
files: list[WorkflowUIFileItem]
# Include the converted API JSON per file (default on for small batches;
# the 146-file report leaves it off to keep the payload sane).
include_workflows: bool = False
# L2 preflight against a real ComfyUI (opt-in; needs a connection).
endpoint_id: str | None = None
refresh: bool = False
class WorkflowImportBatchRequest(BaseModel):
"""Commit: convert UI files and insert, skipping conflicts. Idempotent."""
files: list[WorkflowUIFileItem]
class WorkflowImportScanRequest(BaseModel):
"""Scan a server-side directory of UI JSONs (repo example library)."""
root: str = "example_ComfyUI_workflows"
# Set to preview what would import (same shape as /convert) — the safe
# default for a first run against the 146-file library.
dry_run: bool = True
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):
+11 -3
View File
@@ -36,17 +36,25 @@ 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),
)
# HEL-681: every enqueued job counts as a use (最近使用).
if workflow_id is not None:
conn.execute(
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
(workflow_id,),
)
conn.commit()
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
return row_to_dict(row)
+31 -2
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)
@@ -277,7 +300,12 @@ class QueueWorker:
"请到「ComfyUI 连接」页修正该节点的媒体类型。"
)
queue_manager.set_phase(job["id"], "saving")
dest_dir = config.settings.assets_dir / str(project_id) / kind
if is_test:
# Test artifacts live in a scratch dir, isolated from project
# media and never written back to entities (HEL-643).
dest_dir = workflow_test_dir(int(workflow_id))
else:
dest_dir = config.settings.assets_dir / str(project_id) / kind
dest_dir.mkdir(parents=True, exist_ok=True)
paths: list[str] = []
for meta in outputs_meta:
@@ -291,7 +319,8 @@ class QueueWorker:
)
paths.append(str(dest))
self._apply_outputs_to_entities(job, payload, paths)
if not is_test:
self._apply_outputs_to_entities(job, payload, paths)
queue_manager.set_phase(job["id"], "finished")
return paths
finally:
@@ -0,0 +1,180 @@
"""Workflow-test job helpers (HEL-643).
A test job (jobs.origin = 'workflow_test') runs the FULL real pipeline —
queue → ComfyUI → outputs — but its artifacts are isolated from project
entities: files land under ``assets/_workflow_test/{workflow_id}/`` and are
never written back to characters/locations/scenes/clips.
This module owns the artifact directory, the retention policy (keep the
newest N records per workflow) and the purge paths used by "清除测试记录"
and workflow deletion.
"""
from __future__ import annotations
import json
import logging
import shutil
from pathlib import Path
from typing import Any
from calliope import config
from calliope.db import get_db
logger = logging.getLogger("calliope.queue")
TEST_ORIGIN = "workflow_test"
TEST_SOURCE = "workflow_test"
# Records (and artifacts) kept per workflow before older ones are pruned.
TEST_KEEP = 5
def workflow_test_dir(workflow_id: int) -> Path:
return config.settings.assets_dir / "_workflow_test" / str(workflow_id)
def _safe_unlink(path_str: str) -> bool:
"""Delete a file if it resolves under assets_dir (missing = already gone)."""
try:
target = Path(path_str).resolve()
target.relative_to(config.settings.assets_dir.resolve())
except (ValueError, OSError):
return False
if not target.is_file():
return False
try:
target.unlink()
return True
except OSError:
return False
def _unlink_job_outputs(job: dict[str, Any]) -> None:
try:
paths = json.loads(job.get("output_paths_json") or "[]")
except (json.JSONDecodeError, TypeError):
return
if not isinstance(paths, list):
return
for p in paths:
if isinstance(p, str) and p.strip():
_safe_unlink(p)
def _remove_test_dir_if_empty(workflow_id: int) -> None:
d = workflow_test_dir(workflow_id)
try:
if d.is_dir() and not any(d.iterdir()):
d.rmdir()
except OSError:
pass
def _test_job_public(job: dict[str, Any]) -> dict[str, Any]:
out = dict(job)
out["payload"] = json.loads(job.get("payload_json") or "{}")
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
out.pop("payload_json", None)
out.pop("output_paths_json", None)
return out
def list_test_jobs(workflow_id: int, limit: int = 20) -> list[dict[str, Any]]:
conn = get_db(config.settings.db_path)
try:
rows = conn.execute(
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
" ORDER BY id DESC LIMIT ?",
(workflow_id, TEST_ORIGIN, limit),
).fetchall()
return [_test_job_public(dict(r)) for r in rows]
finally:
conn.close()
def find_active_test_job(workflow_id: int, endpoint_id: str | None) -> dict[str, Any] | None:
"""The unfinished test job for (workflow, endpoint), if one exists.
Idempotency guard for the "测试工作流" button: a second click re-uses the
in-flight job instead of enqueuing a duplicate.
"""
conn = get_db(config.settings.db_path)
try:
rows = conn.execute(
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
" AND status IN ('pending', 'running') ORDER BY id DESC",
(workflow_id, TEST_ORIGIN),
).fetchall()
finally:
conn.close()
for r in rows:
job = dict(r)
try:
payload = json.loads(job.get("payload_json") or "{}")
except (json.JSONDecodeError, TypeError):
payload = {}
snap = payload.get("endpoint") or {}
if not isinstance(snap, dict):
snap = {}
if (snap.get("id") or None) == (endpoint_id or None):
return _test_job_public(job)
return None
def prune_workflow_tests(workflow_id: int, keep: int = TEST_KEEP) -> int:
"""Keep only the newest ``keep`` test records (older rows + files go)."""
conn = get_db(config.settings.db_path)
try:
rows = conn.execute(
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
" ORDER BY id DESC LIMIT -1 OFFSET ?",
(workflow_id, TEST_ORIGIN, max(keep, 0)),
).fetchall()
removed = 0
for r in rows:
job = dict(r)
if job.get("status") in ("pending", "running"):
continue
_unlink_job_outputs(job)
conn.execute("DELETE FROM jobs WHERE id = ?", (job["id"],))
removed += 1
conn.commit()
finally:
conn.close()
_remove_test_dir_if_empty(workflow_id)
if removed:
logger.info("Pruned %s old workflow-test records for workflow %s", removed, workflow_id)
return removed
def purge_workflow_tests(workflow_id: int, *, allow_active: bool = False) -> int:
"""Delete EVERY test record and artifact for a workflow.
Returns the removed row count; raises RuntimeError when an unfinished
test job is still in flight (unless ``allow_active``).
"""
conn = get_db(config.settings.db_path)
try:
if not allow_active:
active = conn.execute(
"SELECT COUNT(*) AS n FROM jobs WHERE workflow_id = ? AND origin = ?"
" AND status IN ('pending', 'running')",
(workflow_id, TEST_ORIGIN),
).fetchone()["n"]
if active:
raise RuntimeError(
f"该工作流还有 {active} 个进行中的测试任务,请先等待完成或取消,再清除测试记录"
)
rows = conn.execute(
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?",
(workflow_id, TEST_ORIGIN),
).fetchall()
removed = 0
for r in rows:
_unlink_job_outputs(dict(r))
conn.execute("DELETE FROM jobs WHERE id = ?", (r["id"],))
removed += 1
conn.commit()
finally:
conn.close()
shutil.rmtree(workflow_test_dir(workflow_id), ignore_errors=True)
return removed
@@ -97,7 +97,7 @@ class MemoryCreate(BaseModel):
class MessageCreate(BaseModel):
content: str = ""
mentions: list[MessageMention] = Field(default_factory=list, max_length=1)
mentions: list[MessageMention] = Field(default_factory=list, max_length=5)
attachments: list[MessageAttachment] = Field(default_factory=list, max_length=8)
answer_to: int | None = None # question/asked seq this message answers (card click)
@@ -113,8 +113,10 @@ class MessageCreate(BaseModel):
@field_validator("mentions")
@classmethod
def _one_workflow(cls, v: list[MessageMention]) -> list[MessageMention]:
if len(v) > 1:
if sum(m.type == "workflow" for m in v) > 1:
raise ValueError("Only one @workflow mention per message")
if sum(m.type == "skill" for m in v) > 4:
raise ValueError("At most four skill mentions per message")
return v
@@ -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
@@ -1,4 +1,5 @@
"""Standalone ComfyUI playground — generate freely, attach favorites to projects."""
from __future__ import annotations
import json
@@ -31,6 +32,23 @@ PLAYGROUND_TITLE = "Playground Scratch"
# page can label origins ("独立生图" vs canvas Playground) without guessing.
PlaygroundSource = Literal["playground", "studio-image", "studio-video"]
# HEL-684: a desk visit opened from a five-stage workbench row may carry
# that row as optional context. Only real, server-verified project/stage/
# source info is accepted — the source row must still exist in the stage
# content, and the desk kind must match what that stage renders.
WorkbenchStage = Literal["assets", "shot"]
WORKBENCH_KIND_BY_STAGE: dict[str, str] = {"assets": "image", "shot": "video"}
WORKBENCH_ROW_KEY_BY_STAGE: dict[str, str] = {"assets": "assets", "shot": "shots"}
class PlaygroundWorkbenchContext(BaseModel):
project_id: int
stage: WorkbenchStage
source_kind: Literal["asset", "shot"]
source_id: str = Field(min_length=1)
# Display-only echo of the DeskContext label (e.g. 镜头 S1).
label: str | None = None
class PlaygroundGenerate(BaseModel):
workflow_id: int
@@ -39,6 +57,13 @@ class PlaygroundGenerate(BaseModel):
# then the active one (resolved in queue_manager.enqueue).
endpoint_id: str | None = None
source: PlaygroundSource = "playground"
# Optional real workbench source — validated, then stamped on the job
# payload so a finished take can be flowed back (attach-workbench).
context: PlaygroundWorkbenchContext | None = None
class PlaygroundAttachWorkbench(BaseModel):
job_id: int
class PlaygroundAttach(BaseModel):
@@ -83,6 +108,72 @@ def ensure_playground_project(conn) -> int:
return int(cur.lastrowid)
# ─────────────────────────────────────────────────────────────────────────
# HEL-684: workbench source context — validate against real rows, never
# guess. Both generate-time (record on the job payload) and attach-time
# (flow a finished take back) go through the same checks.
# ─────────────────────────────────────────────────────────────────────────
def _workbench_target_row(conn, ctx: PlaygroundWorkbenchContext) -> dict[str, Any] | None:
"""The real asset/shot row this context points at, or None."""
stage = conn.execute(
"SELECT content_json FROM project_stages WHERE project_id = ? AND stage_key = ?",
(ctx.project_id, ctx.stage),
).fetchone()
if not stage or not stage["content_json"]:
return None
try:
content = json.loads(stage["content_json"])
except (json.JSONDecodeError, TypeError):
return None
rows = content.get(WORKBENCH_ROW_KEY_BY_STAGE[ctx.stage])
if not isinstance(rows, list):
return None
for row in rows:
if isinstance(row, dict) and str(row.get("id") or "") == ctx.source_id:
return row
return None
def _validate_workbench_context(conn, ctx: PlaygroundWorkbenchContext, kind: str) -> dict[str, Any]:
"""Reject anything that is not a real, current project/stage/source.
Returns the payload stamp for the job. Raises on any mismatch so no
guessed or stale source is ever recorded.
"""
expected_kind = WORKBENCH_KIND_BY_STAGE[ctx.stage]
expected_source_kind = "asset" if ctx.stage == "assets" else "shot"
if ctx.source_kind != expected_source_kind or kind != expected_kind:
raise HTTPException(
status_code=422,
detail=(
f"来源上下文与生成类型不符:「{ctx.stage}」阶段需要{expected_kind}生成。"
"可关闭来源提示后独立生成。"
),
)
project = conn.execute(
"SELECT id, status FROM projects WHERE id = ?", (ctx.project_id,)
).fetchone()
if not project:
raise HTTPException(status_code=404, detail="来源项目不存在,无法记录回流上下文")
if project["status"] == PLAYGROUND_STATUS:
raise HTTPException(status_code=400, detail="不能以 playground 隐藏项目作为回流来源")
if _workbench_target_row(conn, ctx) is None:
raise HTTPException(
status_code=409,
detail=(
"来源"
f"{'资产' if ctx.source_kind == 'asset' else '镜头'}行已不存在"
"(阶段内容可能已重新生成)。可关闭来源提示后独立生成。"
),
)
stamp: dict[str, Any] = ctx.model_dump()
if not (stamp.get("label") or "").strip():
stamp["label"] = None
return stamp
def _assert_path_in_assets(path: str) -> Path:
assets_root = config.settings.assets_dir.resolve()
target = Path(path).resolve()
@@ -195,9 +286,36 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
conn = get_db(config.settings.db_path)
try:
pid = ensure_playground_project(conn)
# HEL-684: which takes have already been flowed back to a workbench
# row (per source project) — the desk shows an honest 已回流 state
# that survives reloads instead of guessing from client memory.
links_by_project: dict[int, dict[int, dict[str, Any]]] = {}
rows = conn.execute(
"SELECT DISTINCT project_id FROM jobs"
" WHERE payload_json LIKE '%\"from_playground_job\"%'"
).fetchall()
for r in rows:
proj_id = int(r["project_id"])
links_by_project[proj_id] = _workbench_link_rows(conn, proj_id)
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"
]
out = []
for j in jobs:
pub = _job_public(j)
stamp = (pub.get("payload") or {}).get("workbench")
if isinstance(stamp, dict) and isinstance(stamp.get("project_id"), int):
link = links_by_project.get(int(stamp["project_id"]), {}).get(int(pub["id"]))
if link and link.get("target_id") == stamp.get("source_id"):
pub["workbench_attach"] = link
out.append(pub)
return out
@router.delete("/jobs/{job_id}")
@@ -232,6 +350,21 @@ async def delete_playground_job(job_id: int) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
_clear_entity_refs(conn, [p for p in paths if isinstance(p, str)])
# HEL-684: the output files are about to vanish — drop the workbench
# link rows created from this take so no project row points at
# missing media (mirrors the entity-ref clearing above).
removed_links: list[int] = []
link_rows = conn.execute(
"SELECT id, payload_json FROM jobs WHERE payload_json LIKE '%\"from_playground_job\"%'"
).fetchall()
for r in link_rows:
try:
p = json.loads(r["payload_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
if p.get("from_playground_job") == job_id:
conn.execute("DELETE FROM jobs WHERE id = ?", (int(r["id"]),))
removed_links.append(int(r["id"]))
conn.commit()
finally:
conn.close()
@@ -254,6 +387,7 @@ async def delete_playground_job(job_id: int) -> dict[str, Any]:
"job_id": job_id,
"deleted_files": deleted_files,
"missing_files": missing_files,
"removed_workbench_links": removed_links,
}
@@ -358,6 +492,14 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
schema = effective_input_schema(
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
)
# HEL-684: an optional workbench source is only recorded when it is
# real and current — the job itself still runs on the playground
# scratch project so the desk feed, SSE and tasks stay unchanged.
workbench_stamp = (
_validate_workbench_context(conn, payload.context, kind)
if payload.context is not None
else None
)
finally:
conn.close()
@@ -378,6 +520,7 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
"input_values": input_values,
"source": payload.source,
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
**({"workbench": workbench_stamp} if workbench_stamp else {}),
},
)
await event_bus.publish(
@@ -488,3 +631,167 @@ async def attach_to_project(payload: PlaygroundAttach) -> dict[str, Any]:
return out
finally:
conn.close()
def _workbench_link_rows(conn, project_id: int) -> dict[int, dict[str, Any]]:
"""playground job id → its flow-back link row on this project.
Link rows are done jobs with payload.source='workbench' and a
from_playground_job stamp — the exact shape _workbench_job_outputs
already enriches the workbench rows from, so a flowed-back take shows
up on its source row through the existing display path.
"""
out: dict[int, dict[str, Any]] = {}
rows = conn.execute(
"SELECT id, payload_json FROM jobs WHERE project_id = ? ORDER BY id ASC",
(project_id,),
).fetchall()
for r in rows:
try:
p = json.loads(r["payload_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
if p.get("source") != "workbench":
continue
src = p.get("from_playground_job")
if isinstance(src, int):
out[src] = {
"job_id": int(r["id"]),
"stage": p.get("stage_key"),
"target_id": p.get("target_id"),
}
return out
@router.post("/attach-workbench")
async def attach_workbench(payload: PlaygroundAttachWorkbench) -> dict[str, Any]:
"""Flow a finished desk take back to its workbench source row (HEL-684).
The source is whatever real context the generate request carried —
nothing is guessed here. Re-attaching is idempotent: the existing link
row is returned, no duplicate is created, nothing is overwritten.
"""
conn = get_db(config.settings.db_path)
try:
pid = ensure_playground_project(conn)
job = queue_manager.get_job(payload.job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if int(job["project_id"]) != int(pid):
raise HTTPException(status_code=403, detail="Not a playground job")
try:
job_payload = json.loads(job.get("payload_json") or "{}")
except (json.JSONDecodeError, TypeError):
job_payload = {}
stamp = job_payload.get("workbench")
if not isinstance(stamp, dict) or not stamp.get("source_id"):
raise HTTPException(
status_code=400, detail="该记录没有项目来源上下文,无法回流(独立生成不回流)"
)
if job["status"] != "done":
raise HTTPException(status_code=400, detail="生成完成后才能回流")
outputs = json.loads(job.get("output_paths_json") or "[]")
if not isinstance(outputs, list) or not outputs:
raise HTTPException(status_code=400, detail="该记录没有产物文件,无法回流")
try:
ctx = PlaygroundWorkbenchContext(
project_id=int(stamp["project_id"]),
stage=stamp.get("stage"),
source_kind=stamp.get("source_kind"),
source_id=str(stamp["source_id"]),
label=stamp.get("label"),
)
except Exception as exc: # pydantic ValidationError / missing keys
raise HTTPException(
status_code=400, detail="该记录的项目来源上下文无效,无法回流"
) from exc
# Re-validate against the live stage content — the row may have been
# regenerated since the take was created; refuse rather than mis-write.
if _workbench_target_row(conn, ctx) is None:
raise HTTPException(
status_code=409,
detail=(
"来源"
f"{'资产' if ctx.source_kind == 'asset' else '镜头'}行已不存在"
"(阶段内容可能已重新生成),未回流。"
),
)
links = _workbench_link_rows(conn, ctx.project_id)
existing = links.get(int(job["id"]))
if existing and existing.get("target_id") == ctx.source_id:
return {
"ok": True,
"already_attached": True,
"job_id": int(job["id"]),
"attached_job_id": existing["job_id"],
"project_id": ctx.project_id,
"stage": ctx.stage,
"target_id": ctx.source_id,
}
link_payload = {
"source": "workbench",
"stage_key": ctx.stage,
"target_id": ctx.source_id,
"from_playground_job": int(job["id"]),
"input_values": job_payload.get("input_values") or {},
}
if ctx.label:
link_payload["label"] = ctx.label
cur = conn.execute(
"""
INSERT INTO jobs (project_id, kind, workflow_id, status, payload_json,
output_paths_json, created_at, started_at, completed_at, origin)
VALUES (?, ?, ?, 'done', ?, ?,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'user')
""",
(
ctx.project_id,
job["kind"],
job["workflow_id"],
json.dumps(link_payload, ensure_ascii=False),
json.dumps([str(p) for p in outputs if isinstance(p, str)]),
),
)
conn.execute(
"UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(ctx.project_id,),
)
conn.commit()
link_id = int(cur.lastrowid)
finally:
conn.close()
await event_bus.publish(
"job.completed",
{
"job_id": link_id,
"kind": job["kind"],
"outputs": outputs,
"project_id": ctx.project_id,
"source": "workbench",
"message": "生成台结果已回流到项目",
},
)
await event_bus.publish(
"asset.ready",
{
"job_id": link_id,
"path": outputs[0] if isinstance(outputs[0], str) else str(outputs[0]),
"project_id": ctx.project_id,
"target": "workbench",
"source": "playground_attach",
},
)
return {
"ok": True,
"already_attached": False,
"job_id": int(job["id"]),
"attached_job_id": link_id,
"project_id": ctx.project_id,
"stage": ctx.stage,
"target_id": ctx.source_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
@@ -45,6 +45,10 @@ class StagePatch(BaseModel):
chosen_style_id: str | None = None
class StageNotePatch(BaseModel):
text: str = Field(max_length=200_000)
class StageRunRequest(BaseModel):
instruction: str | None = Field(default=None, max_length=8000)
@@ -292,10 +296,12 @@ def _load_stage(conn, project_id: int, stage_key: str) -> dict[str, Any]:
def _stage_public(row: dict[str, Any]) -> dict[str, Any]:
content = json.loads(row["content_json"]) if row["content_json"] else None
return {
"stage_key": row["stage_key"],
"status": row["status"],
"content": json.loads(row["content_json"]) if row["content_json"] else None,
"content": content,
"assistant_note": (content or {}).get("assistant_note", ""),
"confirmed_content": (
json.loads(row["confirmed_content_json"]) if row["confirmed_content_json"] else None
),
@@ -571,6 +577,10 @@ async def _execute_run(project_id: int, stage_key: str, run_id: int, instruction
conn = get_db(db)
try:
latest_stage = _load_stage(conn, project_id, stage_key)
previous = json.loads(latest_stage["content_json"]) if latest_stage["content_json"] else {}
if previous.get("assistant_note"):
content["assistant_note"] = previous["assistant_note"]
conn.execute(
"UPDATE stage_runs SET status='done', output_json=?, completed_at=CURRENT_TIMESTAMP "
"WHERE id = ?",
@@ -681,6 +691,9 @@ async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> d
content = NORMALIZERS[stage_key](payload.content)
except StageContentError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
old_content = json.loads(stage["content_json"]) if stage["content_json"] else {}
if old_content.get("assistant_note"):
content["assistant_note"] = old_content["assistant_note"]
if content == (json.loads(stage["content_json"]) if stage["content_json"] else None):
return {"ok": True, "stage": _stage_public({**stage, "content_json": json.dumps(content, ensure_ascii=False)})}
# Editing a confirmed stage produces a new draft awaiting re-confirm;
@@ -715,6 +728,32 @@ async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> d
conn.close()
@router.patch("/projects/{project_id}/stages/{stage_key}/note")
async def patch_stage_note(project_id: int, stage_key: str, payload: StageNotePatch) -> dict[str, Any]:
"""Keep free-text advice separate from validated stage artifacts and snapshots."""
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
stage = _stage_or_404(conn, project_id, stage_key)
if stage["status"] == "running":
raise HTTPException(status_code=409, detail="本阶段正在生成中,请稍候再保存建议。")
content = json.loads(stage["content_json"]) if stage["content_json"] else {}
if payload.text.strip():
content["assistant_note"] = payload.text.strip()
else:
content.pop("assistant_note", None)
conn.execute(
"UPDATE project_stages SET content_json=?, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id=? AND stage_key=?",
(json.dumps(content, ensure_ascii=False) if content else None, project_id, stage_key),
)
conn.execute("UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id=?", (project_id,))
conn.commit()
return {"ok": True, "stage": _stage_public(_load_stage(conn, project_id, stage_key))}
finally:
conn.close()
@router.post("/projects/{project_id}/stages/{stage_key}/run")
async def run_stage(project_id: int, stage_key: str, payload: StageRunRequest | None = None) -> dict[str, Any]:
instruction = (payload.instruction if payload else None) or None
@@ -777,7 +816,10 @@ async def confirm_stage(
raise HTTPException(status_code=409, detail="本阶段正在生成中,请等待完成。")
if not stage["content_json"]:
raise HTTPException(status_code=400, detail="本阶段还没有内容,请先生成或手动填写。")
content = json.loads(stage["content_json"])
try:
content = NORMALIZERS[stage_key](json.loads(stage["content_json"]))
except StageContentError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
chosen = payload.chosen_style_id if payload else None
if stage_key == "style":
directions = content.get("directions") or []
@@ -815,11 +857,15 @@ async def restore_stage(project_id: int, stage_key: str) -> dict[str, Any]:
stage = _stage_or_404(conn, project_id, stage_key)
if not stage["confirmed_content_json"]:
raise HTTPException(status_code=400, detail="本阶段还没有已确认版本,无需恢复。")
content = json.loads(stage["confirmed_content_json"])
current = json.loads(stage["content_json"]) if stage["content_json"] else {}
if current.get("assistant_note"):
content["assistant_note"] = current["assistant_note"]
conn.execute(
"UPDATE project_stages SET content_json=confirmed_content_json, "
"UPDATE project_stages SET content_json=?, "
"status='confirmed', draft_edited=0, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ?",
(project_id, stage_key),
(json.dumps(content, ensure_ascii=False), project_id, stage_key),
)
conn.commit()
row = _load_stage(conn, project_id, stage_key)
@@ -5,7 +5,9 @@ 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.metadata import suggested_metadata
from calliope.comfyui.output_map import (
auto_output_map,
load_output_map,
@@ -19,14 +21,43 @@ 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.ui_convert import convert_ui_workflow
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,
WorkflowConvertRequest,
WorkflowCreate,
WorkflowImportBatchRequest,
WorkflowImportScanRequest,
WorkflowTestRequest,
WorkflowUIFileItem,
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()
# HEL-681: metadata columns the lightweight list view exposes (everything
# except the heavy workflow_json / input_schema / output_map payloads).
_META_COLUMNS = (
"id, name, kind, usage, is_enabled, category, model_family, media_io, tags,"
" favorite, last_used_at, description, source, source_path, prompt_profile,"
" created_at, endpoint_id, usage_source, output_map_source"
)
def _serialize_workflow(row: Any) -> dict[str, Any]:
data = row_to_dict(row)
@@ -44,15 +75,68 @@ def _serialize_workflow(row: Any) -> dict[str, Any]:
data["needs_mapping"] = (
mapping_problem(data["workflow_json"], raw_map, data.get("output_map_source")) is not None
)
# HEL-681: structured library metadata (raw JSON columns → values).
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
data["tags"] = _load_json_col(data.get("tags"), [])
data["favorite"] = bool(data.get("favorite"))
return data
def _load_json_col(raw: Any, default: Any) -> Any:
try:
value = json.loads(raw) if raw else None
except (json.JSONDecodeError, TypeError):
value = None
return value if value is not None else default
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 +148,13 @@ async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
"usage_evidence": judgment["evidence"],
"usable_usage": judgment["usable_usage"],
}
# L2 (optional): with a target connection the analyze response also
# reports which nodes/models that ComfyUI actually has (HEL-643).
if payload.endpoint_id:
result["connection_check"] = await _connection_check(
payload.endpoint_id, payload.workflow_json, payload.refresh
)
return result
def _stored_schema(payload_schema: list[dict[str, Any]] | None, workflow: dict[str, Any]) -> str:
@@ -133,16 +224,86 @@ 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]]:
async def list_workflows(view: str | None = None) -> list[dict[str, Any]]:
conn = get_db(settings.db_path)
try:
if view == "meta":
# HEL-681: metadata-only list for 150+ scale browsing — no
# workflow_json, no per-row re-parse. `needs_mapping` here is the
# cheap pending check (usage/output_map not yet settled); rows
# that never generated keep showing as pending exactly like the
# full view reports them.
rows = conn.execute(
f"SELECT {_META_COLUMNS} FROM workflows ORDER BY favorite DESC,"
" last_used_at DESC, id DESC"
).fetchall()
out = []
for row in rows:
data = row_to_dict(row)
data["is_enabled"] = bool(data.get("is_enabled"))
data["favorite"] = bool(data.get("favorite"))
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
data["tags"] = _load_json_col(data.get("tags"), [])
data["needs_mapping"] = data.get("usage") is None or data.get(
"output_map_source"
) is None
out.append(data)
return out
rows = conn.execute("SELECT * FROM workflows ORDER BY id DESC").fetchall()
return [_serialize_workflow(r) for r in rows]
out = [_serialize_workflow(r) for r in rows]
# 上次测试 state is read live from the newest test job per workflow —
# no cached column that could go stale (HEL-643).
latest: dict[int, dict[str, Any]] = {}
for jr in conn.execute(
"SELECT * FROM jobs WHERE origin = 'workflow_test' AND id IN"
" (SELECT MAX(id) FROM jobs WHERE origin = 'workflow_test' GROUP BY workflow_id)"
).fetchall():
latest[int(jr["workflow_id"])] = _last_test_summary(dict(jr))
for data in out:
data["last_test"] = latest.get(int(data["id"]))
return out
finally:
conn.close()
@router.post("/{workflow_id}/check")
async def check_workflow(workflow_id: int, payload: WorkflowCheckRequest) -> dict[str, Any]:
"""L2 re-check of a saved workflow against its (or a chosen) connection.
The user installs missing nodes/models in ComfyUI, comes back here and
re-checks — no re-import needed.
"""
conn = get_db(settings.db_path)
try:
row = conn.execute(
"SELECT workflow_json, endpoint_id FROM workflows WHERE id = ?", (workflow_id,)
).fetchone()
finally:
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
endpoint_id = payload.endpoint_id or row["endpoint_id"]
return await _connection_check(endpoint_id, json.loads(row["workflow_json"]), payload.refresh)
@router.post("/dedupe")
async def dedupe_workflows() -> dict[str, Any]:
"""Delete byte-identical duplicate workflows, keeping the lowest id.
@@ -282,6 +443,12 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
data = payload.model_dump(exclude_unset=True)
if "is_enabled" in data and data["is_enabled"] is not None:
data["is_enabled"] = 1 if data["is_enabled"] else 0
# HEL-681: library metadata patch fields (favorite/tags/category/
# model_family) — booleans to ints, lists to JSON text.
if "favorite" in data and data["favorite"] is not None:
data["favorite"] = 1 if data["favorite"] else 0
if data.get("tags") is not None:
data["tags"] = json.dumps(data["tags"])
workflow_json = json.loads(existing["workflow_json"])
# HEL-638: output_map payload. A non-empty list validates against the
# stored JSON and becomes the user decision; an explicit empty list
@@ -341,14 +508,426 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
conn.close()
def _job_public(job: dict[str, Any]) -> dict[str, Any]:
out = dict(job)
out["payload"] = json.loads(job.get("payload_json") or "{}")
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
out.pop("payload_json", None)
out.pop("output_paths_json", None)
return out
@router.post("/{workflow_id}/test")
async def test_workflow(workflow_id: int, payload: WorkflowTestRequest) -> dict[str, Any]:
"""Launch (or re-attach to) one REAL test run of a workflow (HEL-643 L3).
The job goes through the ordinary queue → ComfyUI → outputs pipeline;
only its artifacts and bookkeeping are isolated (origin='workflow_test').
A dry-run setting is refused outright — placeholder output must never
count as a test pass. Repeat clicks reuse the in-flight job.
"""
from calliope import config
if config.settings.dry_run:
raise HTTPException(
status_code=400,
detail=(
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
"请到「设置」关闭 Dry-run 后再测试"
),
)
conn = get_db(settings.db_path)
try:
wf = conn.execute(
"SELECT id, kind, workflow_json, input_schema, output_map, output_map_source,"
" endpoint_id FROM workflows WHERE id = ?",
(workflow_id,),
).fetchone()
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
workflow_json = json.loads(wf["workflow_json"])
broken = blocking_problem(workflow_json)
if broken:
raise HTTPException(status_code=400, detail=broken)
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
if map_problem:
raise HTTPException(status_code=400, detail=map_problem)
schema = effective_input_schema(
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
)
input_values, problems = validate_input_values(schema, payload.input_values)
if problems:
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
endpoint_id = payload.endpoint_id or wf["endpoint_id"] or None
snapshot = endpoint_snapshot(endpoint_id)
active = find_active_test_job(workflow_id, (snapshot or {}).get("id"))
if active is not None:
return {"ok": True, "job": _job_public(active), "reused": True}
from calliope.routers.playground import ensure_playground_project
pid = ensure_playground_project(conn)
finally:
conn.close()
job = queue_manager.enqueue(
project_id=pid,
kind=wf["kind"],
workflow_id=workflow_id,
scene_id=None,
payload={
"input_values": input_values,
"source": TEST_SOURCE,
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
},
origin="workflow_test",
)
await event_bus.publish(
"job.created",
{"job_id": job["id"], "kind": job["kind"], "source": TEST_SOURCE,
"project_id": job["project_id"], "workflow_test": True},
)
return {"ok": True, "job": _job_public(job), "reused": False}
@router.get("/{workflow_id}/tests")
async def list_workflow_tests(workflow_id: int) -> list[dict[str, Any]]:
conn = get_db(settings.db_path)
try:
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
finally:
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
return list_test_jobs(workflow_id)
@router.post("/{workflow_id}/clear-tests")
async def clear_workflow_tests(workflow_id: int) -> dict[str, Any]:
"""Delete every test record and artifact for this workflow."""
conn = get_db(settings.db_path)
try:
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
finally:
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
try:
removed = purge_workflow_tests(workflow_id)
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
await event_bus.publish("job.deleted", {"workflow_id": workflow_id, "source": TEST_SOURCE})
return {"ok": True, "removed": removed}
@router.delete("/{workflow_id}")
async def delete_workflow(workflow_id: int) -> dict[str, bool]:
conn = get_db(settings.db_path)
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()
# ---------------------------------------------------------------------------
# HEL-681 (S2): UI→API conversion dry-run + batch import + repo scan.
# The convert endpoint never writes; import-batch converts again server-side
# and skips anything uncertain (needs_confirmation / lint-blocked / conflict)
# — repeated runs import nothing new, so the batch is idempotent by design.
# ---------------------------------------------------------------------------
def _convert_one(item: WorkflowUIFileItem) -> tuple[dict[str, Any], dict[str, Any] | None]:
"""(report, converted workflow) for one UI file — shared by every
convert/import path so the analysis can never drift between them."""
result = convert_ui_workflow(item.workflow_json)
stem = (
item.source_path.replace("\\", "/").rsplit("/", 1)[-1].removesuffix(".json")
if item.source_path
else None
)
name = item.name or stem or "未命名工作流"
report: dict[str, Any] = {
"name": name,
"source_path": item.source_path,
"status": result.status,
"needs_confirmation": result.needs_confirmation,
"issues": [
{"node_id": i.node_id, "code": i.code, "level": i.level, "message": i.message}
for i in result.issues
],
}
if result.workflow is None:
report["importable"] = False
report["skip_reason"] = "转换失败:" + (
result.issues[0].message if result.issues else "未知错误"
)
return report, None
suggested = suggested_metadata(result.workflow, item.source_path, item.workflow_json)
report["suggested"] = suggested
# The import gate re-runs the product's own checks — a converted graph
# that the lint would block (missing model link etc.) is reported, not
# silently imported.
broken = blocking_problem(result.workflow)
if broken:
report["importable"] = False
report["skip_reason"] = broken
report["status"] = "warn"
return report, result.workflow
if result.needs_confirmation:
report["importable"] = False
report["skip_reason"] = "存在无法可靠映射的控件,需人工确认后重导"
return report, result.workflow
report["importable"] = True
return report, result.workflow
@router.post("/convert")
async def convert_workflows(payload: WorkflowConvertRequest) -> dict[str, Any]:
"""Dry-run UI→API conversion for a batch of files: per-file ok/warn/fail,
issues, suggested metadata and importability. Nothing is stored."""
files = []
object_info: dict[str, Any] | None = None
client = None
if payload.endpoint_id:
endpoint = settings.resolve_comfyui_endpoint(payload.endpoint_id)
if endpoint is not None:
client = client_for(endpoint)
try:
object_info = await client.object_info(force_refresh=payload.refresh)
except Exception:
object_info = None
try:
for item in payload.files:
report, converted = _convert_one(item)
if object_info is not None and report.get("importable") and converted:
findings = check_workflow_against(object_info, converted)
errors = [f for f in findings if f.get("level") == "error"]
report["preflight"] = {
"endpoint_id": payload.endpoint_id,
"ok": not errors,
"findings": findings,
}
if errors:
report["importable"] = False
report["skip_reason"] = errors[0].get("message") or "preflight 未通过"
if payload.include_workflows:
report["workflow_json"] = converted
files.append(report)
finally:
if client is not None:
await client.close()
counts = {key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")}
return {
"total": len(files),
**counts,
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
"importable": sum(1 for f in files if f.get("importable")),
"files": files,
}
def _insert_converted(
conn: Any, item: WorkflowUIFileItem, report: dict[str, Any], converted: dict[str, Any]
) -> dict[str, Any]:
"""Insert one converted workflow row (import-batch's write path).
Mirrors create_workflow's enable gate: usage must be settled (auto or the
wizard's confirmation) and unknown outputs need a mapping; anything less
lands disabled exactly like a manual import would.
"""
suggested = report["suggested"]
judgment = classify_usage(converted)
usage = item.usage or judgment["usage"]
usage_source = "user" if item.usage else "auto"
kind = usage_to_kind(usage) or suggested["kind"]
profile = detect_prompt_profile(converted)
if item.output_map:
problems = validate_output_map(converted, item.output_map)
if problems:
raise HTTPException(status_code=422, detail=";".join(problems))
output_map = json.dumps(item.output_map)
output_map_source = "user"
else:
auto_map = auto_output_map(converted)
output_map = json.dumps(auto_map) if auto_map else None
output_map_source = "auto" if auto_map else None
mapping_ok = not needs_user_output_map(converted) or bool(item.output_map)
workflow_text = json.dumps(converted)
existing = conn.execute(
"SELECT id FROM workflows WHERE name = ? AND kind = ? AND workflow_json = ?",
(report["name"], kind, workflow_text),
).fetchone()
if existing:
return {
"result": "skipped",
"skip_reason": "conflict",
"existing_id": existing["id"],
}
is_enabled = 1 if (usage and mapping_ok) else 0
cur = conn.execute(
"""
INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema,
description, prompt_profile, endpoint_id, is_enabled,
usage, usage_source, output_map, output_map_source,
category, model_family, media_io, tags, favorite,
source, source_path)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'ui_convert', ?)
""",
(
report["name"],
kind,
workflow_text,
json.dumps(
item.input_schema if item.input_schema else parse_dynamic_inputs(converted)
),
json.dumps(parse_dynamic_outputs(converted)),
item.description if item.description is not None else suggested["description"],
profile,
is_enabled,
usage,
usage_source,
output_map,
output_map_source,
suggested["category"],
suggested["model_family"],
json.dumps({"in": suggested["media_in"], "out": suggested["media_out"]}),
json.dumps(item.tags or []),
item.source_path,
),
)
return {"result": "imported", "id": cur.lastrowid}
async def _run_import(items: list[WorkflowUIFileItem]) -> dict[str, Any]:
reports: list[dict[str, Any]] = []
conn = get_db(settings.db_path)
try:
for item in items:
report, converted = _convert_one(item)
if not report.get("importable"):
report["result"] = "skipped"
reports.append(report)
continue
try:
outcome = _insert_converted(conn, item, report, converted)
except HTTPException as exc:
report["result"] = "failed"
report["skip_reason"] = str(exc.detail)
reports.append(report)
continue
conn.commit()
report.update(outcome)
reports.append(report)
finally:
conn.close()
imported = [r for r in reports if r.get("result") == "imported"]
skipped = [
{"name": r["name"], "source_path": r["source_path"], "reason": r.get("skip_reason")}
for r in reports
if r.get("result") != "imported"
]
return {
"total": len(reports),
"imported": len(imported),
"skipped": len(skipped),
"imported_ids": [r["id"] for r in imported],
"skipped_files": skipped,
"files": reports,
}
@router.post("/import-batch")
async def import_workflows_batch(payload: WorkflowImportBatchRequest) -> dict[str, Any]:
"""Batch-import UI-format workflows: convert server-side, insert what is
cleanly convertible, skip conflicts and anything uncertain. Re-running
with the same files creates zero new rows."""
return await _run_import(payload.files)
@router.post("/import-scan")
async def import_workflows_scan(payload: WorkflowImportScanRequest) -> dict[str, Any]:
"""Scan a server-side directory for UI-format workflow JSONs and run the
convert (dry_run, default) or import pipeline over every file found."""
from pathlib import Path
root = Path(payload.root)
if not root.is_absolute():
candidates = [
Path.cwd() / root,
Path(__file__).resolve().parents[4] / root,
]
root = next((c for c in candidates if c.is_dir()), root)
if not root.is_dir():
raise HTTPException(status_code=404, detail=f"目录不存在:{payload.root}")
items: list[WorkflowUIFileItem] = []
for path in sorted(root.rglob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
continue # API-format or non-workflow files: not this pipeline's job
items.append(
WorkflowUIFileItem(
source_path=str(path.relative_to(root)), workflow_json=data
)
)
if payload.dry_run:
files = [_convert_one(item)[0] for item in items]
counts = {
key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")
}
return {
"total": len(files),
**counts,
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
"importable": sum(1 for f in files if f.get("importable")),
"files": files,
}
return await _run_import(items)
@router.post("/{workflow_id}/touch")
async def touch_workflow(workflow_id: int) -> dict[str, bool]:
"""Record a use (最近使用) — called by the generate pipeline on enqueue."""
conn = get_db(settings.db_path)
try:
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
conn.execute(
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
(workflow_id,),
)
conn.commit()
return {"ok": True}
finally:
conn.close()
@@ -0,0 +1,36 @@
"""Workflow uniqueness is independent of the selected task skills."""
import pytest
from pydantic import ValidationError
from calliope.routers.agent import MessageCreate
def workflow(n=1):
return {"type": "workflow", "id": n, "name": f"workflow-{n}", "kind": "image"}
def skill(n=1):
return {"type": "skill", "name": f"skill-{n}"}
def test_workflow_and_skills_can_be_sent_together():
message = MessageCreate(content="Create an image", mentions=[workflow(), *[skill(i) for i in range(4)]])
assert len(message.mentions) == 5
assert message.mentions[0].id == 1
def test_legacy_single_workflow_is_accepted():
assert MessageCreate(mentions=[workflow()]).mentions[0].type == "workflow"
def test_skills_without_workflow_are_accepted():
assert len(MessageCreate(mentions=[skill(i) for i in range(4)]).mentions) == 4
def test_second_workflow_is_still_rejected():
with pytest.raises(ValidationError, match="Only one @workflow"):
MessageCreate(mentions=[workflow(), skill(), workflow(2)])
def test_fifth_skill_is_rejected():
with pytest.raises(ValidationError, match="At most four skill"):
MessageCreate(mentions=[skill(i) for i in range(5)])
@@ -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:
@@ -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
@@ -0,0 +1,350 @@
"""HEL-684 — playground takes flow back to their workbench source row.
Covers the acceptance list: real-context-only passthrough (no guessing,
stale rows rejected), explicit flow-back with idempotency (repeated
operations create no duplicates), cancelled/independent paths never
writing the project, and playground job deletion clearing the link.
"""
from __future__ import annotations
import tempfile
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from calliope.db import migrate_db
from calliope.main import create_app
SAMPLE_WORKFLOW = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "hello"},
"_meta": {"title": "Prompt (Input:prompt)"},
},
"2": {
"class_type": "SaveImage",
"inputs": {"images": ["1", 0]},
"_meta": {"title": "Save (Output:image)"},
},
}
SAMPLE_VIDEO_WORKFLOW = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "hello"},
"_meta": {"title": "Prompt (Input:prompt)"},
},
"2": {
"class_type": "SaveVideo",
"inputs": {"video": ["1", 0]},
"_meta": {"title": "Save (Output:video)"},
},
}
@pytest.fixture
def client(monkeypatch):
with tempfile.TemporaryDirectory() as tmpdir:
import asyncio
import calliope.config as config_module
s = config_module.settings
prev = {
"data_dir": s.data_dir,
"assets_dir": s.assets_dir,
"dry_run": s.dry_run,
}
s.data_dir = Path(tmpdir)
s.assets_dir = Path(tmpdir) / "assets"
s.assets_dir.mkdir(parents=True, exist_ok=True)
s.dry_run = True
monkeypatch.setattr(config_module.Settings, "save_config_file", lambda self: None)
asyncio.run(migrate_db(s.db_path))
app = create_app()
try:
with TestClient(app) as c:
yield c
finally:
for k, v in prev.items():
setattr(s, k, v)
def _wait_done(client: TestClient, job_id: int) -> dict:
for _ in range(60):
jobs = client.get("/api/playground/jobs").json()
done = next((j for j in jobs if j["id"] == job_id and j["status"] == "done"), None)
if done and done.get("output_paths"):
return done
time.sleep(0.25)
raise AssertionError("playground job did not finish in dry-run")
def _seed_assets_stage(client: TestClient) -> dict:
proj = client.post("/api/projects", json={"title": "Reel"}).json()
# Real stage rows the way the workbench itself creates them.
patch = client.patch(
f"/api/workbench/projects/{proj['id']}/stages/assets",
json={
"content": {
"assets": [
{
"id": "asset-1",
"kind": "character",
"name": "林夏",
"description": "",
"prompt": "a stoic courier",
"negative_prompt": "",
}
]
}
},
)
assert patch.status_code == 200, patch.text
return proj
def _context(proj: dict, **over: object) -> dict:
base = {
"project_id": proj["id"],
"stage": "assets",
"source_kind": "asset",
"source_id": "asset-1",
"label": "角色「林夏」",
}
base.update(over)
return base
def _make_workflow(client: TestClient, name: str, workflow: dict) -> int:
r = client.post(
"/api/workflows",
json={
"name": name,
"kind": "video" if "SaveVideo" in str(workflow) else "image",
"workflow_json": workflow,
},
)
assert r.status_code == 200, r.text
return r.json()["id"]
# ── context passthrough ────────────────────────────────────────────────
def test_generate_with_context_stamps_job_and_stays_on_playground(client):
wf_id = _make_workflow(client, "FB Img", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
r = client.post(
"/api/playground/generate",
json={
"workflow_id": wf_id,
"input_values": {"1": "a cat"},
"source": "studio-image",
"context": _context(proj),
},
)
assert r.status_code == 200, r.text
job = r.json()["job"]
assert job["payload"]["workbench"]["project_id"] == proj["id"]
assert job["payload"]["workbench"]["source_id"] == "asset-1"
assert job["payload"]["workbench"]["label"] == "角色「林夏」"
# The job itself rides the hidden playground project — independent
# desk behavior (feed, SSE, tasks) is untouched.
scratch = client.get("/api/playground/project").json()
assert job["project_id"] == scratch["id"]
def test_generate_without_context_writes_nothing_to_any_project(client):
wf_id = _make_workflow(client, "FB Solo", SAMPLE_WORKFLOW)
_seed_assets_stage(client)
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
)
assert r.status_code == 200
assert "workbench" not in r.json()["job"]["payload"]
def test_generate_rejects_fake_or_stale_context(client):
wf_id = _make_workflow(client, "FB Fake", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
# Unknown project
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "context": _context(proj, project_id=99999)},
)
assert r.status_code == 404
# Source row that does not exist in the stage content
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "context": _context(proj, source_id="asset-404")},
)
assert r.status_code == 409
# Kind mismatch: assets stage wants an image workflow
video_wf = _make_workflow(client, "FB Vid", SAMPLE_VIDEO_WORKFLOW)
r = client.post(
"/api/playground/generate",
json={"workflow_id": video_wf, "context": _context(proj)},
)
assert r.status_code == 422
# The playground scratch project is not a valid source
scratch = client.get("/api/playground/project").json()
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "context": _context(proj, project_id=scratch["id"])},
)
assert r.status_code == 400
# ── flow back ──────────────────────────────────────────────────────────
def test_attach_workbench_links_take_to_source_row(client):
wf_id = _make_workflow(client, "FB Link", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
# Not done yet → refused, nothing written.
early = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert early.status_code == 400
done = _wait_done(client, job["id"])
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert r.status_code == 200, r.text
body = r.json()
assert body["already_attached"] is False
assert body["project_id"] == proj["id"]
assert body["target_id"] == "asset-1"
# The workbench overview now shows the artifact on the source row,
# through the exact enrichment path in-flow generation uses.
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
assert row["job"]["id"] == body["attached_job_id"]
assert row["job"]["status"] == "done"
assert row["job"]["output"] == done["output_paths"][0]
# Repeated flow-back: idempotent, no duplicate row.
again = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert again.status_code == 200
assert again.json()["already_attached"] is True
assert again.json()["attached_job_id"] == body["attached_job_id"]
# The desk jobs list reports the honest 已回流 marker.
jobs = client.get("/api/playground/jobs").json()
listed = next(j for j in jobs if j["id"] == job["id"])
assert listed["workbench_attach"]["job_id"] == body["attached_job_id"]
def test_attach_workbench_requires_real_context(client):
wf_id = _make_workflow(client, "FB NoCtx", SAMPLE_WORKFLOW)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
).json()["job"]
_wait_done(client, job["id"])
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert r.status_code == 400
assert "独立" in r.json()["detail"]
def test_attach_workbench_refuses_regenerated_source(client):
wf_id = _make_workflow(client, "FB Stale", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
_wait_done(client, job["id"])
# Stage content regenerated: the old row id is gone.
client.patch(
f"/api/workbench/projects/{proj['id']}/stages/assets",
json={
"content": {
"assets": [
{
"id": "asset-2",
"kind": "prop",
"name": "Blaster",
"description": "",
"prompt": "a blaster",
"negative_prompt": "",
}
]
}
},
)
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert r.status_code == 409
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
assert all("job" not in a for a in assets_stage["content"]["assets"])
def test_attach_workbench_allows_new_flow_job_to_take_over(client):
"""A later in-flow generation for the same row wins the display, as
the enrichment keeps the newest workbench job per target."""
wf_id = _make_workflow(client, "FB Takeover", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
_wait_done(client, job["id"])
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
flow = client.post(
f"/api/workbench/projects/{proj['id']}/generate",
json={
"kind": "image",
"stage_key": "assets",
"target_id": "asset-1",
"prompt": "in-flow take",
},
)
assert flow.status_code == 200, flow.text
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
assert row["job"]["id"] != link["attached_job_id"]
def test_delete_playground_job_removes_workbench_link(client):
wf_id = _make_workflow(client, "FB Del", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
done = _wait_done(client, job["id"])
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
dead = client.delete(f"/api/playground/jobs/{job['id']}")
assert dead.status_code == 200
assert dead.json()["removed_workbench_links"] == [link["attached_job_id"]]
# The link row is gone: the project row no longer points at deleted files.
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
assert "job" not in row or row["job"]["id"] != link["attached_job_id"]
assert not Path(done["output_paths"][0]).exists()
+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):
@@ -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:
+449
View File
@@ -0,0 +1,449 @@
"""HEL-681: UI→API converter unit tests + ground-truth cross-checks.
The synthetic graphs pin the structural semantics (reroute / bypass / mute /
subgraph expansion / Set-Get variables / control_after_generate pairing);
the corpus section runs the real 146-file library and the repo's API exports.
"""
from __future__ import annotations
import copy
import json
from pathlib import Path
import pytest
from calliope.comfyui.lint import workflow_warnings
from calliope.comfyui.ui_convert import convert_ui_workflow
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
def _node(nid, ntype, *, mode=0, inputs=None, outputs=None, wv=None, wvn=None, title=None):
node = {"id": nid, "type": ntype, "mode": mode, "inputs": inputs or [], "order": 0}
if outputs is not None:
node["outputs"] = outputs
if wv is not None:
node["widgets_values"] = wv
if wvn is not None:
node["widgets_values_named"] = wvn
if title:
node["title"] = title
return node
def _in_slot(name, type_, link=None, widget=None):
entry = {"name": name, "type": type_, "link": link}
if widget:
entry["widget"] = {"name": widget}
return entry
def _out(name, type_, links=None):
return {"name": name, "type": type_, "links": links or [], "slot_index": 0}
def _base_graph():
"""text2image: loader → prompts/latent → KSampler → VAE → SaveImage."""
nodes = [
_node(
1,
"CheckpointLoaderSimple",
outputs=[_out("MODEL", "MODEL"), _out("CLIP", "CLIP"), _out("VAE", "VAE")],
wv=["model.safetensors"],
inputs=[_in_slot("ckpt_name", "COMBO", widget="ckpt_name")],
),
_node(
2,
"CLIPTextEncode",
inputs=[
_in_slot("clip", "CLIP", link=1),
_in_slot("text", "STRING", widget="text"),
],
outputs=[_out("CONDITIONING", "CONDITIONING")],
wv=["a cat"],
),
_node(
3,
"CLIPTextEncode",
inputs=[
_in_slot("clip", "CLIP", link=2),
_in_slot("text", "STRING", widget="text"),
],
outputs=[_out("CONDITIONING", "CONDITIONING")],
wv=["bad quality"],
),
_node(
4,
"EmptyLatentImage",
inputs=[
_in_slot("width", "INT", widget="width"),
_in_slot("height", "INT", widget="height"),
_in_slot("batch_size", "INT", widget="batch_size"),
],
outputs=[_out("LATENT", "LATENT")],
wv=[512, 512, 1],
),
_node(
5,
"KSampler",
inputs=[
_in_slot("model", "MODEL", link=3),
_in_slot("positive", "CONDITIONING", link=4),
_in_slot("negative", "CONDITIONING", link=5),
_in_slot("latent_image", "LATENT", link=6),
_in_slot("seed", "INT", widget="seed"),
_in_slot("steps", "INT", widget="steps"),
_in_slot("cfg", "FLOAT", widget="cfg"),
_in_slot("sampler_name", "COMBO", widget="sampler_name"),
_in_slot("scheduler", "COMBO", widget="scheduler"),
_in_slot("denoise", "FLOAT", widget="denoise"),
],
outputs=[_out("LATENT", "LATENT")],
# seed 42 + control "fixed" rides together, then five widgets
wv=[42, "fixed", 20, 7, "euler", "normal", 1.0],
),
_node(
6,
"VAEDecode",
inputs=[
_in_slot("samples", "LATENT", link=7),
_in_slot("vae", "VAE", link=8),
],
outputs=[_out("IMAGE", "IMAGE")],
),
_node(
7,
"SaveImage",
inputs=[
_in_slot("images", "IMAGE", link=9),
_in_slot("filename_prefix", "STRING", widget="filename_prefix"),
],
wv=["test"],
),
]
links = [
[1, 1, 1, 2, 0, "CLIP"],
[2, 1, 1, 3, 0, "CLIP"],
[3, 1, 0, 5, 0, "MODEL"],
[4, 2, 0, 5, 1, "CONDITIONING"],
[5, 3, 0, 5, 2, "CONDITIONING"],
[6, 4, 0, 5, 3, "LATENT"],
[7, 5, 0, 6, 0, "LATENT"],
[8, 1, 2, 6, 1, "VAE"],
[9, 6, 0, 7, 0, "IMAGE"],
]
return {"nodes": nodes, "links": links, "last_node_id": 7, "last_link_id": 9}
def _convert(graph):
return convert_ui_workflow(graph)
def test_clean_conversion_maps_everything():
result = _convert(_base_graph())
assert result.status == "ok"
assert result.needs_confirmation is False
wf = result.workflow
assert set(wf) == {"1", "2", "3", "4", "5", "6", "7"}
ks = wf["5"]
assert ks["class_type"] == "KSampler"
assert ks["inputs"]["seed"] == 42 # control_after_generate consumed silently
assert ks["inputs"]["steps"] == 20
assert ks["inputs"]["denoise"] == 1.0
assert ks["inputs"]["model"] == ["1", 0]
assert ks["inputs"]["latent_image"] == ["4", 0]
# lint on the converted graph must be error-free
assert not [w for w in workflow_warnings(wf) if w["level"] == "error"]
def test_upload_button_widgets_are_consumed_but_never_emitted():
graph = _base_graph()
load = _node(
8,
"LoadImage",
inputs=[
_in_slot("image", "COMBO", widget="image"),
_in_slot("upload", "IMAGEUPLOAD", widget="upload"),
],
outputs=[_out("IMAGE", "IMAGE")],
wv=["sample.png", "image"],
)
save = _node(
9,
"PreviewImage",
inputs=[
_in_slot("images", "IMAGE", link=20),
_in_slot("filename_prefix", "STRING", widget="filename_prefix"),
],
wv=["preview"],
)
graph["nodes"] += [load, save]
graph["links"].append([20, 8, 0, 9, 0, "IMAGE"])
result = _convert(graph)
assert result.workflow["8"]["inputs"] == {"image": "sample.png"}
def test_reroute_pass_through():
graph = _base_graph()
reroute = _node(8, "Reroute", inputs=[_in_slot("(in)", "*", link=9)])
graph["nodes"].append(reroute)
# SaveImage now fed by the reroute instead of VAEDecode directly
graph["nodes"][6]["inputs"][0]["link"] = 10
graph["links"].append([10, 8, 0, 7, 0, "IMAGE"])
result = _convert(graph)
assert result.workflow["7"]["inputs"]["images"] == ["6", 0]
assert "8" not in result.workflow
def test_bypass_pass_through_and_mute_drop():
graph = _base_graph()
# bypass the KSampler: VAEDecode.samples must fall back to the latent
graph["nodes"][4]["mode"] = 4
result = _convert(graph)
assert result.workflow["6"]["inputs"]["samples"] == ["4", 0]
# mute the negative prompt: the link input is dropped with a warning
graph = _base_graph()
graph["nodes"][2]["mode"] = 2
result = _convert(graph)
assert "negative" not in result.workflow["5"]["inputs"]
assert any(i.code == "muted_input_dropped" for i in result.issues)
def test_widget_entry_with_muted_upstream_falls_back_to_value():
graph = _base_graph()
# feed seed from a muted PrimitiveInt via a converted widget input
prim = _node(
9,
"PrimitiveInt",
mode=2,
inputs=[_in_slot("value", "INT", widget="value"), _in_slot("control", "COMBO", widget="control")],
wv=[7],
)
graph["nodes"].append(prim)
graph["nodes"][4]["inputs"][4]["link"] = 11 # seed ← primitive
graph["links"].append([11, 9, 0, 5, 4, "INT"])
result = _convert(graph)
assert result.workflow["5"]["inputs"]["seed"] == 42
def test_subgraph_expansion_and_boundary_wiring():
graph = _base_graph()
# pack the EmptyLatentImage into a subgraph exposing width/height widgets
del graph["nodes"][3]
graph["nodes"] = [n for n in graph["nodes"] if n["id"] != 4]
sub_id = "aaaa-bbbb-cccc-dddd-eeee"
packed = _node(
20,
sub_id,
inputs=[
_in_slot("width", "INT", widget="width"),
_in_slot("height", "INT", widget="height"),
],
outputs=[_out("LATENT", "LATENT")],
wv=[640, 384],
)
graph["nodes"].append(packed)
# KSampler latent_image ← packed output
graph["links"] = [l for l in graph["links"] if l[0] != 6]
graph["links"].append([6, 20, 0, 5, 3, "LATENT"])
graph["definitions"] = {
"subgraphs": [
{
"id": sub_id,
"name": "Latent",
"inputs": [
{"name": "width", "type": "INT"},
{"name": "height", "type": "INT"},
],
"outputs": [{"name": "LATENT", "type": "LATENT"}],
"inputNode": {"id": -10},
"outputNode": {"id": -20},
"nodes": [
_node(
5,
"EmptyLatentImage",
inputs=[
_in_slot("width", "INT", widget="width", link=101),
_in_slot("height", "INT", widget="height", link=102),
_in_slot("batch_size", "INT", widget="batch_size"),
],
outputs=[_out("LATENT", "LATENT")],
wv=[512, 512, 1],
),
],
"links": [
{"id": 101, "origin_id": -10, "origin_slot": 0, "target_id": 5, "target_slot": 0, "type": "INT"},
{"id": 102, "origin_id": -10, "origin_slot": 1, "target_id": 5, "target_slot": 1, "type": "INT"},
{"id": 103, "origin_id": 5, "origin_slot": 0, "target_id": -20, "target_slot": 0, "type": "LATENT"},
],
}
]
}
result = _convert(graph)
wf = result.workflow
assert "20:5" in wf # qualified packed:inner id, ComfyUI's own scheme
inner = wf["20:5"]
assert inner["class_type"] == "EmptyLatentImage"
# exposed widget values from the packed instance, not the inner defaults
assert inner["inputs"]["width"] == 640
assert inner["inputs"]["height"] == 384
assert inner["inputs"]["batch_size"] == 1
assert wf["5"]["inputs"]["latent_image"] == ["20:5", 0]
# now wire the packed width slot from outside: boundary becomes a link
res_node = _node(
21,
"ResolutionSelector",
inputs=[_in_slot("aspect_ratio", "COMBO", widget="aspect_ratio")],
outputs=[_out("width", "INT"), _out("height", "INT")],
wv=["1:1"],
)
graph["nodes"].append(res_node)
graph["nodes"][-2]["inputs"][0]["link"] = 12 # packed width ← selector
graph["links"].append([12, 21, 0, 20, 0, "INT"])
wf2 = _convert(graph).workflow
assert wf2["20:5"]["inputs"]["width"] == ["21", 0]
assert wf2["20:5"]["inputs"]["height"] == 384
def test_set_get_variables_resolve_to_captured_source():
graph = _base_graph()
setter = _node(8, "SetNode", inputs=[_in_slot("*", "*", link=8)], wv=["the_vae"])
getter = _node(9, "GetNode", wv=["the_vae"], outputs=[_out("VAE", "VAE")])
graph["nodes"] += [setter, getter]
# VAEDecode.vae now fed via the variable
graph["nodes"][5]["inputs"][1]["link"] = 13
graph["links"] = [l for l in graph["links"] if l[0] != 8]
graph["links"] += [
[8, 1, 2, 8, 0, "VAE"],
[13, 9, 0, 6, 1, "VAE"],
]
result = _convert(graph)
assert "8" not in result.workflow and "9" not in result.workflow
assert result.workflow["6"]["inputs"]["vae"] == ["1", 2]
def test_unknown_widget_order_lands_in_needs_confirmation():
graph = _base_graph()
# an old-frontend node: positional widgets, no widget markers, no registry
graph["nodes"].append(
_node(10, "MysteryCustomNode", inputs=[], outputs=[_out("IMAGE", "IMAGE")], wv=[1, 2, 3])
)
save = _node(
11,
"PreviewImage",
inputs=[_in_slot("images", "IMAGE", link=30)],
wv=["preview"],
)
graph["nodes"].append(save)
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
result = _convert(graph)
assert result.needs_confirmation is True
assert any(i.code == "widget_unmapped" for i in result.issues)
# the node is still converted structurally — the report carries the doubt
assert "10" in result.workflow
def test_not_ui_format_fails_fast():
result = convert_ui_workflow({"3": {"class_type": "SaveImage", "inputs": {}}})
assert result.status == "fail"
assert result.workflow is None
# ---------------------------------------------------------------------------
# Real corpus: the 146 UI files + ground-truth API cross-checks (对拍).
# ---------------------------------------------------------------------------
pytestmark_convert = pytest.mark.skipif(
not EXAMPLES.is_dir(), reason="example_ComfyUI_workflows not checked out"
)
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
def test_corpus_146_files_all_convert():
ui_files = [
p
for p in sorted(EXAMPLES.rglob("*.json"))
if isinstance(json.loads(p.read_text(encoding="utf-8")), dict)
and isinstance(json.loads(p.read_text(encoding="utf-8")).get("nodes"), list)
]
assert len(ui_files) == 146
statuses = {"ok": 0, "warn": 0, "fail": 0}
needs_confirmation = 0
for path in ui_files:
data = json.loads(path.read_text(encoding="utf-8"))
result = convert_ui_workflow(data)
statuses[result.status] += 1
needs_confirmation += result.needs_confirmation
assert result.workflow, f"{path.name} produced no workflow"
# structural sanity: no dangling links in any conversion
nodes = result.workflow
for nid, node in nodes.items():
for name, val in node.get("inputs", {}).items():
if isinstance(val, list):
assert str(val[0]) in nodes, f"{path.name}: {nid}.{name} dangles"
assert statuses["fail"] == 0
assert statuses["ok"] + statuses["warn"] == 146
# files whose widget order cannot be trusted are flagged, never hidden
assert 0 < needs_confirmation < 10
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
def test_cross_check_against_ground_truth_exports():
"""The H3 t2v UI export converts to the same node classes the author's
own API export carries; shared classes agree on scalar input names."""
ui = json.loads(
(EXAMPLES / "4-视频生成/Minimax-h3/minimax_h3_t2v_文生视频.json").read_text(encoding="utf-8")
)
api = json.loads(
(EXAMPLES / "minimax_h3-Turbo_Text2Video_20260813_API.json").read_text(encoding="utf-8")
)
result = convert_ui_workflow(ui)
assert result.status in ("ok", "warn")
converted = result.workflow
api_classes = {n["class_type"] for n in api.values()}
# Every scalar name the conversion emits for a shared class must be an
# input name the author's own export knows — wiring may differ between
# versions (a muted branch falls back to its widget value), but the NAME
# mapping must never invent or swap inputs.
for cls in api_classes & {n["class_type"] for n in converted.values()}:
api_names = {
key
for n in api.values()
if n["class_type"] == cls
for key in n.get("inputs", {})
}
conv_names = {
key
for n in converted.values()
if n["class_type"] == cls
for key, v in n.get("inputs", {}).items()
if not isinstance(v, list)
}
assert conv_names <= api_names, f"class {cls} invents inputs: {conv_names - api_names}"
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
def test_packed_krea2_conversion_shape_matches_export_scheme():
"""The krea2 turbo file (packed subgraph) converts ok, expands inner nodes
under `<packed>:<inner>` ids and maps the sampler widgets exactly — the
same scheme the author's own Krea2 API export uses (ids like "171:153")."""
ui = json.loads(
(EXAMPLES / "3-图像生成/krea2/krea2_turbo_文生图.json").read_text(encoding="utf-8")
)
api = json.loads(
(EXAMPLES / "Krea2_t2i_20260818_API.json").read_text(encoding="utf-8")
)
result = convert_ui_workflow(ui)
assert result.status == "ok"
assert any(":" in nid for nid in result.workflow) # expanded subgraph nodes
assert any("171:" in nid for nid in api) # ground truth uses the same scheme
ks = [
n
for n in result.workflow.values()
if n["class_type"] == "KSampler" and not isinstance(n["inputs"].get("seed"), list)
]
assert ks, "inner KSampler with widget-mapped seed expected"
assert {"seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"} <= set(
ks[0]["inputs"]
)
@@ -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"):
+33
View File
@@ -197,6 +197,39 @@ def _confirm(client, pid: int, key: str, **extra):
return client.post(f"/api/workbench/projects/{pid}/stages/{key}/confirm", json=extra or None)
def test_assistant_notes_keep_confirmation_and_survive_artifact_edits(client):
pid = _make_project(client)
note_url = f"/api/workbench/projects/{pid}/stages/style/note"
r = client.patch(note_url, json={"text": "建议先比较冷暖两个方向。"})
assert r.status_code == 200
assert r.json()["stage"]["status"] == "todo"
assert r.json()["stage"]["assistant_note"] == "建议先比较冷暖两个方向。"
assert _confirm(client, pid, "style").status_code == 400
# An ordinary structured save must keep the separate advice.
r = client.patch(f"/api/workbench/projects/{pid}/stages/style", json={"content": json.loads(STYLE_REPLY)})
assert r.status_code == 200
assert r.json()["stage"]["assistant_note"] == "建议先比较冷暖两个方向。"
# Notes on confirmed content don't change its status or confirmed snapshot.
client.post(f"/api/workbench/projects/{pid}/stages/script/import", json={"text": SCRIPT_TEXT})
before = _stage(_overview(client, pid), "script")
r = client.patch(f"/api/workbench/projects/{pid}/stages/script/note", json={"text": "下一版建议加强人物动机。"})
assert r.status_code == 200
after = r.json()["stage"]
assert after["status"] == before["status"] == "confirmed"
assert after["confirmed_content"] == before["confirmed_content"]
assert after["draft_edited"] == before["draft_edited"]
assert _confirm(client, pid, "script").status_code == 200
confirmed = _stage(_overview(client, pid), "script")
assert "assistant_note" not in confirmed["confirmed_content"]
client.patch(f"/api/workbench/projects/{pid}/stages/script/note", json={"text": "保留确认后的新建议。"})
restored = client.post(f"/api/workbench/projects/{pid}/stages/script/restore").json()["stage"]
assert restored["assistant_note"] == "保留确认后的新建议。"
assert restored["confirmed_content"] == confirmed["confirmed_content"]
assert client.patch(note_url, json={"text": ""}).json()["stage"]["assistant_note"] == ""
assert client.patch(note_url, json={"text": "x" * 200_001}).status_code == 422
assert client.patch(f"/api/workbench/projects/{pid}/stages/unknown/note", json={"text": "x"}).status_code == 404
# ─────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,421 @@
"""HEL-681 (S2): batch import endpoints, meta list view, migration safety.
Everything runs on a temp DB via the shared client fixture — the real
146-file library is never touched, matching the card's「不导入生产数据」boundary.
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from calliope.db import migrate_db
import pytest
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
def _ui_graph():
"""Minimal clean t2i UI graph (same shape as test_ui_convert's)."""
return {
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"mode": 0,
"inputs": [{"name": "ckpt_name", "type": "COMBO", "widget": {"name": "ckpt_name"}, "link": None}],
"outputs": [
{"name": "MODEL", "type": "MODEL", "links": [3], "slot_index": 0},
{"name": "CLIP", "type": "CLIP", "links": [1, 2], "slot_index": 1},
{"name": "VAE", "type": "VAE", "links": [8], "slot_index": 2},
],
"widgets_values": ["qwen_image_2.safetensors"],
},
{
"id": 2,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 1},
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None},
],
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [4], "slot_index": 0}],
"widgets_values": ["a cat"],
},
{
"id": 3,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 2},
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None},
],
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5], "slot_index": 0}],
"widgets_values": ["bad"],
},
{
"id": 4,
"type": "EmptyLatentImage",
"mode": 0,
"inputs": [
{"name": "width", "type": "INT", "widget": {"name": "width"}, "link": None},
{"name": "height", "type": "INT", "widget": {"name": "height"}, "link": None},
{"name": "batch_size", "type": "INT", "widget": {"name": "batch_size"}, "link": None},
],
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [6], "slot_index": 0}],
"widgets_values": [512, 512, 1],
},
{
"id": 5,
"type": "KSampler",
"mode": 0,
"inputs": [
{"name": "model", "type": "MODEL", "link": 3},
{"name": "positive", "type": "CONDITIONING", "link": 4},
{"name": "negative", "type": "CONDITIONING", "link": 5},
{"name": "latent_image", "type": "LATENT", "link": 6},
{"name": "seed", "type": "INT", "widget": {"name": "seed"}, "link": None},
{"name": "steps", "type": "INT", "widget": {"name": "steps"}, "link": None},
{"name": "cfg", "type": "FLOAT", "widget": {"name": "cfg"}, "link": None},
{"name": "sampler_name", "type": "COMBO", "widget": {"name": "sampler_name"}, "link": None},
{"name": "scheduler", "type": "COMBO", "widget": {"name": "scheduler"}, "link": None},
{"name": "denoise", "type": "FLOAT", "widget": {"name": "denoise"}, "link": None},
],
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0}],
"widgets_values": [42, "fixed", 20, 7, "euler", "normal", 1.0],
},
{
"id": 6,
"type": "VAEDecode",
"mode": 0,
"inputs": [
{"name": "samples", "type": "LATENT", "link": 7},
{"name": "vae", "type": "VAE", "link": 8},
],
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0}],
},
{
"id": 7,
"type": "SaveImage",
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 9},
{"name": "filename_prefix", "type": "STRING", "widget": {"name": "filename_prefix"}, "link": None},
],
"widgets_values": ["test"],
},
],
"links": [
[1, 1, 1, 2, 0, "CLIP"],
[2, 1, 1, 3, 0, "CLIP"],
[3, 1, 0, 5, 0, "MODEL"],
[4, 2, 0, 5, 1, "CONDITIONING"],
[5, 3, 0, 5, 2, "CONDITIONING"],
[6, 4, 0, 5, 3, "LATENT"],
[7, 5, 0, 6, 0, "LATENT"],
[8, 1, 2, 6, 1, "VAE"],
[9, 6, 0, 7, 0, "IMAGE"],
],
}
def _api_workflow():
return json.loads(
(EXAMPLES / "video_minimax_h3_r2v_2ref_API.json").read_text(encoding="utf-8")
)
def test_convert_dry_run_reports_and_stores_nothing(client):
payload = {
"files": [
{
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
"workflow_json": _ui_graph(),
}
],
"include_workflows": True,
}
resp = client.post("/api/workflows/convert", json=payload)
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1 and body["ok"] == 1 and body["importable"] == 1
entry = body["files"][0]
assert entry["name"] == "test_t2i"
assert entry["workflow_json"]["5"]["inputs"]["seed"] == 42
assert entry["suggested"]["usage"] == "text2image"
assert entry["suggested"]["category"] == "image"
assert entry["suggested"]["model_family"] == "Qwen"
assert entry["suggested"]["media_out"] == ["image"]
# dry-run must not write anything
assert client.get("/api/workflows").json() == []
def test_convert_needs_confirmation_is_flagged_not_runnable(client):
graph = _ui_graph()
graph["nodes"].append(
{
"id": 10,
"type": "MysteryCustomNode",
"mode": 0,
"inputs": [],
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [30], "slot_index": 0}],
"widgets_values": [1, 2, 3],
}
)
graph["nodes"].append(
{
"id": 11,
"type": "PreviewImage",
"mode": 0,
"inputs": [{"name": "images", "type": "IMAGE", "link": 30}],
"widgets_values": ["p"],
}
)
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
resp = client.post(
"/api/workflows/convert",
json={"files": [{"source_path": "x/mystery.json", "workflow_json": graph}]},
)
body = resp.json()
assert body["needs_confirmation"] == 1
assert body["importable"] == 0
assert "需人工确认" in body["files"][0]["skip_reason"]
def test_import_batch_then_rerun_is_idempotent(client):
files = [
{
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
"workflow_json": _ui_graph(),
}
]
first = client.post("/api/workflows/import-batch", json={"files": files})
assert first.status_code == 200
body = first.json()
assert body["imported"] == 1 and body["skipped"] == 0
row_id = body["imported_ids"][0]
# second run with identical file: conflict skip, no duplicates
second = client.post("/api/workflows/import-batch", json={"files": files})
body2 = second.json()
assert body2["imported"] == 0 and body2["skipped"] == 1
assert body2["skipped_files"][0]["reason"] == "conflict"
listing = client.get("/api/workflows").json()
assert len(listing) == 1
detail = client.get(f"/api/workflows/{row_id}").json()
assert detail["usage"] == "text2image"
assert detail["is_enabled"] is True
assert detail["category"] == "image"
assert detail["model_family"] == "Qwen"
assert detail["media_io"] == {"in": [], "out": ["image"]}
assert detail["source"] == "ui_convert"
assert detail["source_path"] == "3-图像生成/Qwen-Image-2.1/test_t2i.json"
def test_import_batch_skips_needs_confirmation_files(client):
graph = _ui_graph()
graph["nodes"].append(
{
"id": 10,
"type": "MysteryCustomNode",
"mode": 0,
"inputs": [],
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [30], "slot_index": 0}],
"widgets_values": [1, 2, 3],
}
)
graph["nodes"].append(
{
"id": 11,
"type": "PreviewImage",
"mode": 0,
"inputs": [{"name": "images", "type": "IMAGE", "link": 30}],
"widgets_values": ["p"],
}
)
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
resp = client.post(
"/api/workflows/import-batch",
json={"files": [{"name": "mystery", "workflow_json": graph}]},
)
body = resp.json()
assert body["imported"] == 0
assert "需人工确认" in body["skipped_files"][0]["reason"]
assert client.get("/api/workflows").json() == []
def test_warn_sample_imports_with_human_confirmation(client):
"""A warn file (muted branch → input dropped) still imports when the
wizard supplies the settled usage — the manual-correction path."""
graph = _ui_graph()
graph["nodes"][2]["mode"] = 2 # mute the negative prompt branch
dry = client.post(
"/api/workflows/convert",
json={"files": [{"name": "warned", "workflow_json": graph}]},
).json()
assert dry["warn"] == 1 and dry["importable"] == 1
resp = client.post(
"/api/workflows/import-batch",
json={"files": [{"name": "warned", "workflow_json": graph, "usage": "text2image"}]},
)
body = resp.json()
assert body["imported"] == 1
detail = client.get(f"/api/workflows/{body['imported_ids'][0]}").json()
assert detail["is_enabled"] is True
assert "negative" not in detail["workflow_json"]["5"]["inputs"]
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
def test_import_scan_dry_run_over_repo_library(client):
resp = client.post(
"/api/workflows/import-scan",
json={"root": str(EXAMPLES), "dry_run": True},
)
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 146
assert body["fail"] == 0
assert body["ok"] + body["warn"] == 146
assert body["needs_confirmation"] > 0 # the 3 uncensored files stay honest
assert body["importable"] < body["total"]
# a dry scan must leave the DB untouched
assert client.get("/api/workflows").json() == []
def test_import_scan_commit_from_tmp_directory(client, tmp_path):
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "demo.json").write_text(json.dumps(_ui_graph()), encoding="utf-8")
(tmp_path / "plain_api.json").write_text(json.dumps(_api_workflow()), encoding="utf-8")
(tmp_path / "not_a_workflow.json").write_text(json.dumps({"hello": 1}), encoding="utf-8")
resp = client.post(
"/api/workflows/import-scan",
json={"root": str(tmp_path), "dry_run": False},
)
body = resp.json()
# only the one UI file went through this pipeline; API-format and junk skipped
assert body["imported"] == 1
listing = client.get("/api/workflows").json()
assert len(listing) == 1
assert listing[0]["source_path"] == "sub/demo.json"
# rerun: idempotent
again = client.post(
"/api/workflows/import-scan",
json={"root": str(tmp_path), "dry_run": False},
).json()
assert again["imported"] == 0
assert len(client.get("/api/workflows").json()) == 1
def test_meta_view_has_metadata_but_no_full_json(client):
client.post(
"/api/workflows/import-batch",
json={
"files": [
{
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
"workflow_json": _ui_graph(),
"tags": ["极速"],
}
]
},
)
meta = client.get("/api/workflows", params={"view": "meta"}).json()
assert len(meta) == 1
row = meta[0]
assert "workflow_json" not in row
assert "input_schema" not in row
assert "output_schema" not in row
assert row["category"] == "image"
assert row["model_family"] == "Qwen"
assert row["media_io"] == {"in": [], "out": ["image"]}
assert row["tags"] == ["极速"]
assert row["favorite"] is False
assert row["needs_mapping"] is False
def test_legacy_list_shape_and_old_records_unchanged(client):
"""Old imports (API format via POST /api/workflows) keep working; the
legacy list still carries workflow_json and the new columns default."""
created = client.post(
"/api/workflows",
json={"name": "旧导入", "workflow_json": _api_workflow()},
)
assert created.status_code == 200
legacy = client.get("/api/workflows").json()
assert len(legacy) == 1
row = legacy[0]
assert isinstance(row["workflow_json"], dict)
assert "usage" in row and "output_map" in row
assert row["favorite"] is False
assert row["tags"] == []
assert row["media_io"] == {"in": [], "out": []}
meta = client.get("/api/workflows", params={"view": "meta"}).json()
assert meta[0]["needs_mapping"] is False or meta[0]["needs_mapping"] is True
assert meta[0]["source"] is None
def test_favorite_tags_patch_and_touch(client):
wid = client.post(
"/api/workflows/import-batch",
json={"files": [{"name": "fav", "workflow_json": _ui_graph()}]},
).json()["imported_ids"][0]
resp = client.patch(f"/api/workflows/{wid}", json={"favorite": True, "tags": ["高质量"]})
assert resp.status_code == 200
assert resp.json()["favorite"] is True
assert resp.json()["tags"] == ["高质量"]
touch = client.post(f"/api/workflows/{wid}/touch")
assert touch.status_code == 200
meta = client.get("/api/workflows", params={"view": "meta"}).json()
assert meta[0]["last_used_at"] is not None
assert meta[0]["favorite"] is True
def test_migration_adds_columns_on_old_database(tmp_path):
"""A pre-S2 database (workflows table without the metadata columns)
migrates in place; existing rows survive with defaults."""
db_path = tmp_path / "old.db"
conn = sqlite3.connect(db_path)
conn.executescript(
"""
CREATE TABLE workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('image', 'video')),
workflow_json TEXT NOT NULL,
description TEXT,
prompt_profile TEXT NOT NULL DEFAULT 'prose',
is_enabled INTEGER NOT NULL DEFAULT 1,
usage TEXT,
usage_source TEXT,
output_map TEXT,
output_map_source TEXT
);
INSERT INTO workflows (name, kind, workflow_json)
VALUES ('legacy', 'video', '{"1": {"class_type": "SaveImage", "inputs": {}}}');
"""
)
conn.commit()
conn.close()
import asyncio
asyncio.run(migrate_db(db_path))
conn = sqlite3.connect(db_path)
cols = {r[1] for r in conn.execute("PRAGMA table_info(workflows)").fetchall()}
assert {
"category",
"model_family",
"media_io",
"tags",
"favorite",
"last_used_at",
"source",
"source_path",
} <= cols
row = conn.execute("SELECT name, favorite, tags, category FROM workflows").fetchone()
assert row == ("legacy", 0, None, None)
conn.close()
@@ -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.");
+29 -18
View File
@@ -1,19 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>影工坊</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<script>
(function () {
var saved;
try {
saved = localStorage.getItem("aigc-theme");
} catch (_) {}
document.documentElement.dataset.theme =
/^(night|day|grade|bluehour)$/.test(saved || "") ? saved : "night";
})();
</script>
<link rel="stylesheet" href="%sveltekit.assets%/themes.css" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>影工坊</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
import type { Action } from 'svelte/action';
/** A local honeycomb highlight follows the pointer, independent of selected state. */
export const spotlight: Action<HTMLElement> = (node) => {
const move = (event: PointerEvent) => {
if (
document.documentElement.dataset.reduce === '1' ||
event.pointerType === 'touch'
)
return;
const rect = node.getBoundingClientRect();
node.style.setProperty('--pointer-x', `${event.clientX - rect.left}px`);
node.style.setProperty('--pointer-y', `${event.clientY - rect.top}px`);
};
node.addEventListener('pointermove', move);
return { destroy: () => node.removeEventListener('pointermove', move) };
};
+133 -2
View File
@@ -138,6 +138,13 @@ import type {
ComfyDynamicInput,
ComfyDynamicOutput,
WorkflowOutputMapEntry,
WorkflowConnectionCheck,
WorkflowLastTest,
WorkflowMeta,
WorkflowUIFileInput,
WorkflowConvertFileReport,
WorkflowConvertReport,
WorkflowImportResult,
} from "./comfy/types";
export type {
Job,
@@ -150,6 +157,13 @@ export type {
ComfyDynamicInput,
ComfyDynamicOutput,
WorkflowOutputMapEntry,
WorkflowConnectionCheck,
WorkflowLastTest,
WorkflowMeta,
WorkflowUIFileInput,
WorkflowConvertFileReport,
WorkflowConvertReport,
WorkflowImportResult,
};
async function api<T>(path: string, init?: RequestInit): Promise<T> {
@@ -458,13 +472,94 @@ export const comfy = {
export const workflows = {
list: () => api<Workflow[]>("/api/workflows"),
analyze: (workflow_json: Record<string, unknown>) =>
/** HEL-682: metadata-only list — 150+ rows without any workflow_json. */
listMeta: () => api<WorkflowMeta[]>("/api/workflows?view=meta"),
/** HEL-682: dry-run UI→API conversion for a batch of files (nothing stored). */
convert: (
files: WorkflowUIFileInput[],
opts?: { endpoint_id?: string | null; refresh?: boolean },
) =>
api<WorkflowConvertReport>("/api/workflows/convert", {
method: "POST",
body: JSON.stringify({
files,
...(opts?.endpoint_id ? { endpoint_id: opts.endpoint_id } : {}),
...(opts?.refresh ? { refresh: opts.refresh } : {}),
}),
}),
/** HEL-682: commit a batch import (converts server-side, conflicts skipped). */
importBatch: (files: WorkflowUIFileInput[]) =>
api<WorkflowImportResult>("/api/workflows/import-batch", {
method: "POST",
body: JSON.stringify({ files }),
}),
/** HEL-682: scan a server-side directory of UI-format JSONs — dry-run report. */
importScanDry: (root: string) =>
api<WorkflowConvertReport>("/api/workflows/import-scan", {
method: "POST",
body: JSON.stringify({ root, dry_run: true }),
}),
/** HEL-682: scan a server-side directory of UI-format JSONs — commit the import. */
importScanCommit: (root: string) =>
api<WorkflowImportResult>("/api/workflows/import-scan", {
method: "POST",
body: JSON.stringify({ root, dry_run: false }),
}),
/** HEL-682: record a use (最近使用). */
touch: (id: number) =>
api<{ ok: boolean }>(`/api/workflows/${id}/touch`, { method: "POST" }),
/** HEL-682: delete byte-identical duplicates, keeping the lowest id. */
dedupe: () =>
api<{
ok: boolean;
removed: number;
kept_ids: number[];
removed_ids: number[];
}>("/api/workflows/dedupe", { method: "POST" }),
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>;
@@ -496,6 +591,10 @@ export const workflows = {
input_schema: ComfyDynamicInput[];
output_map: WorkflowOutputMapEntry[];
output_map_source: "auto" | "user";
favorite: boolean;
tags: string[];
category: string;
model_family: string;
}>,
) =>
api<Workflow>(`/api/workflows/${id}`, {
@@ -597,6 +696,15 @@ export const playgroundApi = {
endpoint_id?: string | null;
/** Origin recorded on the job payload (tasks page labels). */
source?: "playground" | "studio-image" | "studio-video";
/** Optional real workbench source (HEL-684) — validated server-side and
* stamped on the job so finished takes can flow back to their row. */
context?: {
project_id: number;
stage: "assets" | "shot";
source_kind: "asset" | "shot";
source_id: string;
label?: string;
};
}) =>
api<{ ok: boolean; job: Job }>("/api/playground/generate", {
method: "POST",
@@ -614,7 +722,24 @@ export const playgroundApi = {
job_id: number;
deleted_files: string[];
missing_files: string[];
removed_workbench_links?: number[];
}>(`/api/playground/jobs/${jobId}`, { method: "DELETE" }),
/** Flow a finished take back to the workbench row it came from (HEL-684).
* The server derives the target from the job's own recorded context —
* idempotent, never overwrites, refuses stale source rows. */
attachWorkbench: (jobId: number) =>
api<{
ok: boolean;
already_attached: boolean;
job_id: number;
attached_job_id: number;
project_id: number;
stage: "assets" | "shot";
target_id: string;
}>("/api/playground/attach-workbench", {
method: "POST",
body: JSON.stringify({ job_id: jobId }),
}),
attach: (payload: {
path: string;
project_id: number;
@@ -709,6 +834,7 @@ export interface VideoShot {
}
export interface WorkbenchStage {
assistant_note?: string;
stage_key: WorkbenchStageKey;
status: WorkbenchStageStatus;
content: Record<string, unknown> | null;
@@ -732,6 +858,11 @@ export interface WorkbenchOverview {
}
export const workbenchApi = {
saveNote: (projectId: number, stageKey: WorkbenchStageKey, text: string) =>
api<{ ok: boolean; stage: WorkbenchStage }>(
`/api/workbench/projects/${projectId}/stages/${stageKey}/note`,
{ method: "PATCH", body: JSON.stringify({ text }) },
),
overview: (projectId: number) =>
api<WorkbenchOverview>(`/api/workbench/projects/${projectId}`),
patchStage: (
+11 -11
View File
@@ -98,8 +98,8 @@
position: relative;
}
.artifact-node.running {
border-color: rgba(125, 211, 252, 0.6);
box-shadow: 0 0 18px rgba(125, 211, 252, 0.25);
border-color: var(--run);
box-shadow: 0 0 18px var(--run-soft);
}
:global(.svelte-flow__node.selected) .artifact-node {
border-color: var(--accent);
@@ -117,9 +117,9 @@
font-size: 9px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #bae6fd;
background: rgba(12, 74, 110, 0.55);
border: 1px solid rgba(125, 211, 252, 0.35);
color: var(--text-2);
background: var(--bg-4);
border: 1px solid var(--line-2);
border-radius: 999px;
padding: 2px 6px;
flex-shrink: 0;
@@ -141,12 +141,12 @@
flex-shrink: 0;
}
.running-badge {
color: #bae6fd;
background: rgba(12, 74, 110, 0.6);
color: var(--run);
background: var(--run-soft);
}
.failed-badge {
color: #fecaca;
background: rgba(127, 29, 29, 0.5);
color: var(--err);
background: var(--err-soft);
}
.play-badge {
font-size: 9px;
@@ -205,9 +205,9 @@
font-weight: 600;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--text-muted);
color: var(--on-media);
pointer-events: none;
background: rgba(0, 0, 0, 0.55);
background: var(--media-scrim);
padding: 1px 5px;
border-radius: 999px;
}
@@ -108,7 +108,7 @@
transition: border-color 0.15s;
}
.entity-node:hover {
border-color: #3a3a44;
border-color: var(--line-2);
}
/* Svelte Flow marks the selected node wrapper with .selected */
:global(.svelte-flow__node.selected) .entity-node {
@@ -176,9 +176,9 @@
font-weight: 600;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--text-muted);
color: var(--on-media);
pointer-events: none;
background: rgba(0, 0, 0, 0.55);
background: var(--media-scrim);
padding: 1px 5px;
border-radius: 999px;
}
-13
View File
@@ -1,13 +0,0 @@
/* Canvas v2.1 scoped visual tokens (spec v2.0 §2) — canvas surfaces only.
The global --accent stays untouched for v1 pages. */
.canvas-root {
--accent: #4f46a5;
--accent-hover: #6157c9;
--accent-glow: rgba(99, 102, 241, 0.3);
--edge: #7dd3fc;
/* Edge-chip surfaces (LinkEdge label) — fallbacks match the dark theme. */
--panel: #17171f;
--panel-border: #2a2a35;
--fg: #f4f4f5;
--fg-dim: #a1a1aa;
}
+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;
}
+167 -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 {
@@ -130,6 +171,122 @@ 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;
}
/**
* HEL-682: metadata-only row (GET /api/workflows?view=meta) — the whole
* point is browsing 150+ workflows without downloading workflow_json.
*/
export interface WorkflowMediaIO {
in: string[];
out: string[];
}
export interface WorkflowMeta {
id: number;
name: string;
kind: 'image' | 'video';
usage: WorkflowUsage | null;
usage_source: 'auto' | 'user' | null;
is_enabled: boolean;
category: string | null;
model_family: string | null;
media_io: WorkflowMediaIO;
tags: string[];
favorite: boolean;
last_used_at: string | null;
description: string | null;
source: string | null;
source_path: string | null;
prompt_profile: string;
created_at: string;
endpoint_id: string | null;
output_map_source: 'auto' | 'user' | null;
/** Cheap pending check: usage or output mapping not settled yet. */
needs_mapping: boolean;
}
/** One UI-format file submitted to the batch convert/import pipeline. */
export interface WorkflowUIFileInput {
name?: string;
source_path?: string;
workflow_json: Record<string, unknown>;
usage?: WorkflowUsage;
description?: string;
tags?: string[];
}
export interface WorkflowConvertIssue {
node_id: string;
code: string;
level: 'error' | 'warning';
message: string;
}
export interface WorkflowConvertSuggested {
kind: 'image' | 'video';
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
category: string | null;
category_label: string | null;
model_family: string | null;
media_in: string[];
media_out: string[];
description: string | null;
}
export interface WorkflowPreflightFinding {
level: string;
code: string;
node_id?: string;
node_title?: string;
message: string;
hint?: string;
}
export interface WorkflowConvertFileReport {
name: string;
source_path: string | null;
status: 'ok' | 'warn' | 'fail';
needs_confirmation: boolean;
issues: WorkflowConvertIssue[];
importable: boolean;
skip_reason?: string | null;
suggested?: WorkflowConvertSuggested;
preflight?: {
endpoint_id: string | null;
ok: boolean;
findings: WorkflowPreflightFinding[];
};
}
/** Dry-run report (POST /convert, POST /import-scan with dry_run). */
export interface WorkflowConvertReport {
total: number;
ok: number;
warn: number;
fail: number;
needs_confirmation: number;
importable: number;
files: WorkflowConvertFileReport[];
}
export interface WorkflowImportFileReport extends WorkflowConvertFileReport {
result?: 'imported' | 'skipped' | 'failed';
id?: number;
existing_id?: number;
}
/** Commit result (POST /import-batch, POST /import-scan without dry_run). */
export interface WorkflowImportResult {
total: number;
imported: number;
skipped: number;
imported_ids: number[];
skipped_files: { name: string; source_path: string | null; reason: string | null }[];
files: WorkflowImportFileReport[];
}
export interface Job {
@@ -152,6 +309,15 @@ 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;
/** HEL-684: present when this take has been flowed back to its
* workbench source row (link job id on the source project). */
workbench_attach?: {
job_id: number;
stage: string;
target_id: 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}
@@ -491,7 +522,7 @@
}
.role {
font-weight: 600;
color: var(--accent, #a78bfa);
color: var(--accent);
font-size: 11px;
margin-left: 6px;
font-family: ui-monospace, monospace;
@@ -516,15 +547,27 @@
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 {
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25);
box-shadow: 0 0 0 3px var(--err-soft);
}
.error-msg {
font-size: 11px;
@@ -564,7 +607,7 @@
}
.clear:hover {
color: var(--error);
border-color: rgba(239, 68, 68, 0.4);
border-color: var(--err-line);
}
.clear:focus-visible {
outline: 2px solid var(--accent);
@@ -30,7 +30,7 @@
position: absolute;
inset: 0;
z-index: 30;
background: rgba(18, 18, 24, 0.78);
background: var(--overlay);
backdrop-filter: blur(2px);
border-radius: var(--radius-md);
display: flex;
@@ -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} />
@@ -83,13 +96,35 @@
<button
class="tool"
type="button"
aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
title={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
title={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
onclick={() => (zoomed = !zoomed)}
>
<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}
@@ -146,9 +181,8 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
{alt}
kind="video"
label={t('lightbox.videoUnavailable')}
autoplay
controls
preload="auto"
preload="metadata"
/>
</div>
{:else}
@@ -204,7 +238,7 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
}
.counter {
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
color: color-mix(in srgb, var(--on-media) 65%, transparent);
margin-right: 4px;
font-variant-numeric: tabular-nums;
}
@@ -216,13 +250,13 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
height: 40px;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
background: color-mix(in srgb, var(--on-media) 12%, transparent);
color: var(--on-media);
cursor: pointer;
transition: background 0.15s;
}
.tool:hover {
background: rgba(255, 255, 255, 0.22);
background: color-mix(in srgb, var(--on-media) 22%, transparent);
}
.tool:focus-visible {
outline: 2px solid var(--accent);
@@ -239,13 +273,13 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
height: 44px;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
background: color-mix(in srgb, var(--on-media) 10%, transparent);
color: var(--on-media);
cursor: pointer;
transition: background 0.15s;
}
.nav:hover {
background: rgba(255, 255, 255, 0.22);
background: color-mix(in srgb, var(--on-media) 22%, transparent);
}
.nav:focus-visible {
outline: 2px solid var(--accent);
@@ -301,16 +335,16 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
.caption {
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
color: color-mix(in srgb, var(--on-media) 85%, transparent);
max-width: min(96vw, 720px);
text-align: center;
}
.hint {
margin: 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
color: color-mix(in srgb, var(--on-media) 55%, transparent);
}
.hint a {
color: rgba(255, 255, 255, 0.85);
color: color-mix(in srgb, var(--on-media) 85%, transparent);
}
</style>
@@ -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>
@@ -504,9 +504,9 @@
height: 36px;
padding: 0 20px;
border-radius: 9999px;
background: var(--success, #22c55e);
background: var(--ok);
border: none;
color: #000;
color: var(--accent-ink);
font-size: 14px;
font-weight: 700;
font-family: var(--font-body);
@@ -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
@@ -95,10 +95,10 @@
rgba(255, 255, 255, 0.03) 6px,
rgba(255, 255, 255, 0.03) 12px
),
var(--bg-elevated, #16161d);
border: 1px dashed var(--border, #2a2a35);
var(--bg-elevated);
border: 1px dashed var(--border);
border-radius: inherit;
color: var(--text-muted, #8b8b9a);
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
@@ -1,867 +0,0 @@
<script lang="ts">
import { tick } from 'svelte';
import { goto } from '$app/navigation';
import { toStore } from 'svelte/store';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { assetUrl, jobsApi, projects, type Job, type Scene, type Clip } from '$lib/api';
import { estimateTargetSeconds } from '$lib/durationBudget';
import { agentDeepLink } from '$lib/agentTasks';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import Button from '$lib/components/ui/Button.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
interface Props {
projectId: number;
}
let { projectId }: Props = $props();
const client = useQueryClient();
let editing = $state<Scene | null>(null);
let editOpen = $state(false);
let highlightId = $state<number | null>(null);
let adding = $state(false);
let deletingId = $state<number | null>(null);
let sceneToDelete = $state<Scene | null>(null);
let deleteOpen = $state(false);
const scenesQuery = createQuery(
toStore(() => ({
queryKey: ['scenes', projectId],
queryFn: () => projects.getScenes(projectId),
})),
);
// Shared cache key with QueueStage — clip job state shows up on scene cards
// too. Refreshed via SSE invalidation from the project page, not polling.
const jobsQuery = createQuery(
toStore(() => ({
queryKey: ['jobs', projectId],
queryFn: () => jobsApi.list(projectId),
})),
);
// Cache-shared with the parent's story query — only used for the target-length hint.
const storyQuery = createQuery(
toStore(() => ({
queryKey: ['story', projectId],
queryFn: () => projects.getStory(projectId),
})),
);
// Cache-shared with AssetsStage — resolves scene location chips.
const assetsQuery = createQuery(
toStore(() => ({
queryKey: ['assets', projectId],
queryFn: () => projects.getAssets(projectId),
})),
);
const scenes = $derived($scenesQuery.data?.scenes ?? []);
const sceneCount = $derived(scenes.length);
const totalClips = $derived(scenes.reduce((n, s) => n + (s.clips?.length ?? 0), 0));
const totalSec = $derived($scenesQuery.data?.estimated_duration_sec ?? 0);
const targetSec = $derived.by(() => {
const target = $storyQuery.data?.project?.target_duration;
return target ? estimateTargetSeconds(target) : 0;
});
const busy = $derived(adding || deletingId != null);
// ── Break into shots (coverage expansion) ────────────────────────────
let expandingIds = $state<number[]>([]);
let expandingAll = $state(false);
let expandedIds = $state<number[]>([]);
function isExpanded(scene: Scene): boolean {
return (scene.clips?.length ?? 0) > 1 || expandedIds.includes(scene.id);
}
async function breakIntoShots(scene: Scene) {
if (expandingIds.length > 0 || expandingAll) return;
expandingIds = [...expandingIds, scene.id];
try {
const res = await projects.expandClips(projectId, { scene_ids: [scene.id] });
expandedIds = [...new Set([...expandedIds, scene.id])];
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(
t('script.toast.breakIntoShots', {
num: scene.order_index,
clips: res.scenes?.[0]?.clip_count ?? '?',
}),
);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.breakFail'));
} finally {
expandingIds = expandingIds.filter((id) => id !== scene.id);
}
}
async function breakAllIntoShots() {
if (expandingIds.length > 0 || expandingAll) return;
if (
!window.confirm(t('script.toast.breakAllConfirm', { count: sceneCount }))
)
return;
expandingAll = true;
try {
const res = await projects.expandClips(projectId, { all: true });
expandedIds = scenes.map((s) => s.id);
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.boardExpanded', { count: res.total_clips }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.breakAllFail'));
} finally {
expandingAll = false;
}
}
async function deleteClip(clip: Clip, scene: Scene) {
try {
await projects.deleteClip(projectId, clip.id);
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.deletedShot', { num: `${scene.order_index}.${clip.order_index}` }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.deleteClipFail'));
}
}
const saveMutation = createMutation({
mutationFn: () =>
projects.updateScene(projectId, editing!.id, {
heading: editing!.heading,
action: editing!.action,
dialog: editing!.dialog,
duration_sec: editing!.duration_sec,
}),
onSuccess: () => {
editing = null;
editOpen = false;
client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.sceneSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('script.toast.saveSceneFail'));
},
});
// Chain-from-previous toggle — persists on the scene; consumed at render time.
let chainPendingId = $state<number | null>(null);
async function toggleChain(scene: Scene) {
if (chainPendingId != null) return;
chainPendingId = scene.id;
try {
await projects.updateScene(projectId, scene.id, {
chain_from_prev: !scene.chain_from_prev,
});
await client.invalidateQueries({ queryKey: ['scenes'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.updateSceneFail'));
} finally {
chainPendingId = null;
}
}
function jobForScene(sceneId: number): Job | undefined {
const jobs = ($jobsQuery.data ?? []).filter(
(j) => j.scene_id === sceneId && j.kind === 'video',
);
if (jobs.length === 0) return undefined;
return [...jobs].sort((a, b) => b.id - a.id)[0];
}
function jobForClip(clipId: number): Job | undefined {
const jobs = ($jobsQuery.data ?? []).filter(
(j) => (j as Job & { clip_id?: number | null }).clip_id === clipId && j.kind === 'video',
);
if (jobs.length === 0) return undefined;
return [...jobs].sort((a, b) => b.id - a.id)[0];
}
/** Per-scene production status — only uses fields the API actually exposes. */
function sceneStatus(scene: Scene): { status: string; label: string } {
const job = jobForScene(scene.id);
if (job && (job.status === 'pending' || job.status === 'running')) {
return { status: 'generating', label: t('script.status.rendering') };
}
if (job?.status === 'failed') return { status: 'failed', label: t('script.status.renderFailed') };
if (scene.video_path || job?.status === 'done') {
return { status: 'ready', label: t('script.status.clipReady') };
}
return { status: 'idle', label: t('script.status.noClip') };
}
function locationName(scene: Scene): string | null {
if (scene.location_id == null) return null;
const locs = $assetsQuery.data?.locations ?? [];
return locs.find((l) => l.id === scene.location_id)?.name ?? null;
}
function avatarFor(char: { portrait_path: string | null; sheet_path: string | null }) {
return assetUrl(char.portrait_path ?? char.sheet_path);
}
function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
return parts
.slice(0, 2)
.map((p) => p[0].toUpperCase())
.join('');
}
/** Hide a broken avatar image (portrait moved/deleted on disk), revealing initials. */
function hideBrokenAvatar(e: Event) {
const img = e.currentTarget as HTMLImageElement | null;
if (img) img.style.display = 'none';
}
function formatClock(sec: number): string {
const s = Math.max(0, Math.round(sec));
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${r.toString().padStart(2, '0')}`;
}
async function move(sceneId: number, dir: -1 | 1) {
if (busy) return;
const list = $scenesQuery.data?.scenes ?? [];
const idx = list.findIndex((s) => s.id === sceneId);
const swap = idx + dir;
if (idx < 0 || swap < 0 || swap >= list.length) return;
const ids = list.map((s) => s.id);
[ids[idx], ids[swap]] = [ids[swap], ids[idx]];
await projects.reorderScenes(projectId, ids);
client.invalidateQueries({ queryKey: ['scenes'] });
}
/** Hand off script regeneration to a fresh, project-linked agent chat. */
function requestRegenerate() {
if (busy) return;
goto(agentDeepLink(projectId, 'script'));
}
function openEdit(scene: Scene) {
editing = { ...scene };
editOpen = true;
}
async function addScene() {
if (busy) return;
adding = true;
try {
const existing = $scenesQuery.data?.scenes ?? [];
const nextIndex = existing.length + 1;
const created = await projects.createScene(projectId, {
order_index: nextIndex,
heading: `NEW SCENE ${nextIndex}`,
action: '',
dialog: '',
duration_sec: 5,
});
// Keep order_index contiguous 1..n so Regenerate sees the real board size
const ids = [...existing.map((s) => s.id), created.id];
await projects.reorderScenes(projectId, ids);
await client.invalidateQueries({ queryKey: ['scenes'] });
await client.refetchQueries({ queryKey: ['scenes', projectId] });
editing = { ...created, order_index: nextIndex, heading: `NEW SCENE ${nextIndex}` };
editOpen = true;
highlightId = created.id;
await tick();
document
.getElementById(`scene-${created.id}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
toast.success(t('script.toast.sceneAdded', { num: nextIndex }));
window.setTimeout(() => {
if (highlightId === created.id) highlightId = null;
}, 2800);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.addSceneFail'));
} finally {
adding = false;
}
}
function requestDelete(scene: Scene) {
if (busy) return;
sceneToDelete = scene;
deleteOpen = true;
}
async function confirmDeleteScene() {
const scene = sceneToDelete;
sceneToDelete = null;
if (!scene || busy) return;
const label = scene.heading?.trim() || `Scene #${scene.order_index}`;
deletingId = scene.id;
try {
await projects.deleteScene(projectId, scene.id);
if (editing?.id === scene.id) {
editing = null;
editOpen = false;
}
const remaining = ($scenesQuery.data?.scenes ?? [])
.filter((s) => s.id !== scene.id)
.map((s) => s.id);
if (remaining.length > 0) {
await projects.reorderScenes(projectId, remaining);
}
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.deleted', { name: label }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.deleteFailed'));
} finally {
deletingId = null;
}
}
</script>
<header class="stage-header">
<div>
<h2>3. Script</h2>
<p class="muted">
Estimated duration: {formatClock(totalSec)}
{#if targetSec > 0}
/ target ~{formatClock(targetSec)}
{/if}
{#if sceneCount > 0}
· {sceneCount} scenes
{#if totalClips > 0}
/ {totalClips} shot clips
{/if}
{/if}
</p>
</div>
<div class="stage-actions">
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
<Icon name="plus" size={14} /> {t('script.addScene')}
</Button>
<Button
variant="secondary"
disabled={busy || expandingAll || expandingIds.length > 0 || sceneCount === 0}
loading={expandingAll}
onclick={breakAllIntoShots}
title={t('script.breakAllTitle')}
>
<Icon name="film" size={14} /> {t('script.breakAllIntoShots')}
</Button>
<Button variant="primary" disabled={busy} onclick={requestRegenerate}>
<Icon name="sparkle" size={15} /> {t('script.regenerate')}
</Button>
</div>
</header>
<div class="script-panel">
{#if $scenesQuery.isLoading}
<div class="card">{t('script.loadingScenes')}</div>
{:else if sceneCount === 0}
<EmptyState
title={t('script.noScenesTitle')}
body={t('script.noScenesBody')}
>
{#snippet icon()}
<Icon name="script" size={28} />
{/snippet}
{#snippet action()}
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
<Icon name="plus" size={14} /> {t('script.addScene')}
</Button>
{/snippet}
</EmptyState>
{:else}
{#each scenes as scene, i (scene.id)}
{@const st = sceneStatus(scene)}
{@const locName = locationName(scene)}
<article
id="scene-{scene.id}"
class="card scene"
class:fresh={highlightId === scene.id}
class:deleting={deletingId === scene.id}
>
<div class="scene-head">
<div class="scene-title">
<span class="grip" aria-hidden="true"><Icon name="drag" size={14} /></span>
<span class="num">#{scene.order_index}</span>
<strong>{scene.heading || t('script.untitledScene')}</strong>
{#if highlightId === scene.id}
<span class="fresh-tag">{t('script.justAdded')}</span>
{/if}
<StatusChip status={st.status} label={st.label} />
</div>
<div class="actions">
<button
type="button"
class="icon-btn"
aria-label={t('script.moveSceneUp')}
title={t('script.moveSceneUp')}
disabled={busy || i === 0}
onclick={() => move(scene.id, -1)}
>
<Icon name="chevron-up" size={15} />
</button>
<button
type="button"
class="icon-btn"
aria-label={t('script.moveSceneDown')}
title={t('script.moveSceneDown')}
disabled={busy || i === sceneCount - 1}
onclick={() => move(scene.id, 1)}
>
<Icon name="chevron-down" size={15} />
</button>
<Button variant="secondary" size="sm" disabled={busy} onclick={() => openEdit(scene)}>
{t('script.edit')}
</Button>
<Button
variant="danger"
size="sm"
disabled={busy}
loading={deletingId === scene.id}
onclick={() => requestDelete(scene)}
>
{t('script.delete')}
</Button>
</div>
</div>
{#if scene.action}
<p class="muted">{scene.action}</p>
{:else}
<p class="muted empty-line">{t('script.noActionYet')}</p>
{/if}
{#if scene.dialog}
<pre class="dialog">{scene.dialog}</pre>
{/if}
<div class="clip-block">
<div class="clip-head">
<span class="clip-title">
<Icon name="film" size={12} />
{(scene.clips ?? []).length}
{((scene.clips ?? []).length === 1 ? t('script.shotClipSingular') : t('script.shotClipPlural', { n: (scene.clips ?? []).length }))}
{#if (scene.clips ?? []).some((c) => c.clip_path)}
· {t('script.renderedCount', { count: (scene.clips ?? []).filter((c) => c.clip_path).length })}
{/if}
</span>
<Button
variant="secondary"
size="sm"
disabled={busy || expandingIds.length > 0 || expandingAll}
loading={expandingIds.includes(scene.id)}
onclick={() => breakIntoShots(scene)}
title={isExpanded(scene) ? t('script.rebreakTitle') : t('script.breakTitle')}
>
{isExpanded(scene) ? t('script.rebreak') : t('script.break')}
</Button>
</div>
{#if isExpanded(scene) && (scene.clips ?? []).length > 1}
<ul class="clip-list">
{#each scene.clips ?? [] as clip (clip.id)}
{@const clipJob = jobForClip(clip.id)}
<li class="clip-row">
<span class="clip-num" class:ready={Boolean(clip.clip_path)}>
#{scene.order_index}.{clip.order_index}
</span>
<span class="clip-desc">{clip.description || t('script.untitledShot')}</span>
{#if clip.shot_size}
<span class="chip">{clip.shot_size}</span>
{/if}
{#if clipJob && (clipJob.status === 'pending' || clipJob.status === 'running')}
<span class="chip chip-render">{t('script.rendering')}</span>
{:else if clip.clip_path}
<span class="chip chip-ok">✓ {t('script.clipDone')}</span>
{/if}
{#if clip.duration_sec}
<span class="clip-dur">{clip.duration_sec}s</span>
{/if}
<button
type="button"
class="icon-btn"
aria-label={t('script.deleteShotAria', { id: `${scene.order_index}.${clip.order_index}` })}
title={t('script.deleteShotTitle')}
onclick={() => deleteClip(clip, scene)}
>
<Icon name="trash" size={13} />
</button>
</li>
{/each}
</ul>
{/if}
</div>
<div class="chips">
{#each scene.characters ?? [] as c (c.id)}
{@const avatar = avatarFor(c)}
<span class="chip">
<span class="avatar">
<span class="initials" aria-hidden="true">{initialsOf(c.name)}</span>
{#if avatar}
<img src={avatar} alt="" loading="lazy" onerror={hideBrokenAvatar} />
{/if}
</span>
{c.name}
</span>
{/each}
{#if locName}
<span class="chip"><Icon name="folder" size={12} /> {locName}</span>
{/if}
{#if scene.duration_sec}
<span class="chip"><Icon name="clock" size={12} /> {formatClock(scene.duration_sec)}</span>
{/if}
{#if i > 0}
<button
type="button"
class="chip chip-toggle"
class:chip-on={Boolean(scene.chain_from_prev)}
disabled={chainPendingId === scene.id}
title={t('script.chainTitle')}
onclick={() => toggleChain(scene)}
>
<Icon name="film" size={12} />
{Boolean(scene.chain_from_prev) ? t('script.chainOn') : t('script.chainOff')}
</button>
{/if}
</div>
</article>
{/each}
{/if}
</div>
<Modal
bind:open={editOpen}
title={editing ? t('script.editSceneNumbered', { n: editing.order_index }) : t('script.editScene')}
onclose={() => (editing = null)}
>
{#if editing}
<label class="field">
<span class="field-label">{t('script.headingField')}</span>
<input class="field-input" bind:value={editing.heading} placeholder={t('script.headingPlaceholder')} />
</label>
<label class="field">
<span class="field-label">{t('script.actionField')}</span>
<textarea
class="field-textarea"
bind:value={editing.action}
rows="4"
placeholder={t('script.actionPlaceholder')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('script.dialogField')}</span>
<textarea
class="field-textarea"
bind:value={editing.dialog}
rows="4"
placeholder={t('script.dialogPlaceholder')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('script.durationField')}</span>
<input class="field-input" type="number" bind:value={editing.duration_sec} min="1" />
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (editOpen = false)}>{t('common.cancel')}</Button>
<Button
variant="primary"
loading={$saveMutation.isPending}
onclick={() => $saveMutation.mutate()}
>
{t('script.save')}
</Button>
{/snippet}
</Modal>
<ConfirmDialog
bind:open={deleteOpen}
title={t('script.deleteSceneTitle')}
message={sceneToDelete
? t('script.deleteSceneMessage', {
heading: sceneToDelete.heading?.trim() || t('script.sceneNumber', { n: sceneToDelete.order_index }),
})
: ''}
confirmLabel={t('script.delete')}
danger
onconfirm={() => void confirmDeleteScene()}
oncancel={() => (sceneToDelete = null)}
/>
<style>
.stage-header {
/* Pins inside the stage scroll container so Add Scene / Regenerate
stay reachable while scrolling long scripts. */
position: sticky;
top: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-lg);
gap: var(--space-md);
padding: 8px 0 12px;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
}
.stage-header h2 {
margin: 0 0 4px;
font-size: 22px;
font-weight: 700;
}
.stage-actions {
display: flex;
gap: 8px;
}
.muted {
color: var(--text-secondary);
margin: 0;
}
.script-panel {
position: relative;
min-height: 220px;
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-bottom: var(--space-md);
scroll-margin-top: 84px;
scroll-margin-bottom: 24px;
}
.scene {
transition:
border-color 0.25s ease,
box-shadow 0.25s ease,
opacity 0.2s ease;
}
.scene.fresh {
border-color: var(--accent);
box-shadow:
0 0 0 1px var(--accent),
0 0 24px var(--accent-glow);
animation: scene-in 0.45s ease-out;
}
.scene.deleting {
opacity: 0.45;
}
@keyframes scene-in {
from {
opacity: 0.35;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.scene.fresh {
animation: none;
}
}
.scene-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 8px;
}
.scene-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.grip {
display: inline-flex;
color: var(--text-muted);
cursor: grab;
opacity: 0.6;
}
.num {
color: var(--accent);
font-weight: 700;
}
.fresh-tag {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-glow);
border-radius: 999px;
padding: 2px 8px;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.icon-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
border-color: var(--border);
}
.icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.icon-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.empty-line {
font-style: italic;
color: var(--text-muted);
}
.dialog {
background: var(--bg-elevated);
padding: 12px;
border-radius: var(--radius-sm);
white-space: pre-wrap;
font-family: var(--font-mono);
font-size: 13px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
color: var(--text-secondary);
}
.chip-toggle {
cursor: pointer;
font-family: inherit;
}
.chip-toggle:hover {
color: var(--text-primary);
border-color: #52525b;
}
.chip-toggle:disabled {
opacity: 0.6;
cursor: wait;
}
.chip-on {
color: var(--accent);
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--bg-elevated));
}
.clip-block {
margin-top: 10px;
border-top: 1px dashed var(--border);
padding-top: 8px;
}
.clip-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.clip-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.clip-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.clip-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 4px 8px;
font-size: 12px;
}
.clip-num {
font-family: var(--font-mono);
font-weight: 700;
color: var(--text-secondary);
flex-shrink: 0;
}
.clip-num.ready {
color: var(--accent);
}
.clip-desc {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
.clip-dur {
color: var(--text-muted);
flex-shrink: 0;
}
.chip-ok {
color: var(--accent);
border-color: var(--accent);
}
.chip-render {
color: var(--warning, #eab308);
border-color: var(--warning, #eab308);
}
.avatar {
position: relative;
width: 20px;
height: 20px;
border-radius: 50%;
overflow: hidden;
background: var(--bg-surface);
border: 1px solid var(--border);
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.avatar img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.initials {
font-size: 9px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.02em;
}
</style>
@@ -1,947 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import {
projects,
type Beat,
type Character,
type Item,
type Location,
type StoryData,
} from '$lib/api';
import {
buildStoryDraftPromptPreview,
estimateTargetSeconds,
recommendBeatCount,
recommendSceneCount,
} from '$lib/durationBudget';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { agentDeepLink } from '$lib/agentTasks';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
interface Props {
projectId: number;
story: StoryData;
configured: boolean;
onGoAssets?: () => void;
}
let { projectId, story, configured, onGoAssets }: Props = $props();
const client = useQueryClient();
const GENRES = [
'Adventure / Mystery',
'Drama',
'Sci-Fi',
'Fantasy',
'Horror',
'Romance',
'Thriller',
];
const LENGTHS_HINT = $derived(t('story.lengthsHint'));
const TONES = [
'Cinematic, atmospheric',
'Dark, tense',
'Whimsical, warm',
'Gritty, realistic',
'Epic, sweeping',
];
const GENRE_KEYS: Record<string, string> = {
'Adventure / Mystery': 'genres.adventure',
Drama: 'genres.drama',
'Sci-Fi': 'genres.scifi',
Fantasy: 'genres.fantasy',
Horror: 'genres.horror',
Romance: 'genres.romance',
Thriller: 'genres.thriller',
};
const TONE_KEYS: Record<string, string> = {
'Cinematic, atmospheric': 'tones.cinematic',
'Dark, tense': 'tones.dark',
'Whimsical, warm': 'tones.whimsical',
'Gritty, realistic': 'tones.gritty',
'Epic, sweeping': 'tones.epic',
};
function genreLabel(v: string): string {
return t(GENRE_KEYS[v] ?? v);
}
function toneLabel(v: string): string {
return t(TONE_KEYS[v] ?? v);
}
const EXAMPLE = {
idea: 'A lone cartographer discovers a map that rewrites itself every midnight, leading her into a forgotten city beneath the desert.',
genre: 'Adventure / Mystery',
tone: 'Cinematic, atmospheric',
target_duration: '2 minutes',
};
let editingBeat = $state<Beat | null>(null);
let editingChar = $state<Character | null>(null);
let editingLoc = $state<Location | null>(null);
let editingItem = $state<Item | null>(null);
let beatModalOpen = $state(false);
let charModalOpen = $state(false);
let locModalOpen = $state(false);
let itemModalOpen = $state(false);
let confirmExampleOpen = $state(false);
let deletingBeat = $state<Beat | null>(null);
let beatDeleteOpen = $state(false);
let addingBeat = $state(false);
// Static defaults — the $effect below syncs all drafts from the story prop.
let ideaDraft = $state('');
let genreDraft = $state(GENRES[0]);
let toneDraft = $state(TONES[0]);
let lengthDraft = $state('2 minutes');
let showDraftPrompt = $state(false);
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
let saveState = $state<SaveState>('idle');
let savedTimer: ReturnType<typeof setTimeout> | null = null;
const beatBudget = $derived(recommendBeatCount(lengthDraft));
const sceneBudget = $derived(recommendSceneCount(lengthDraft));
const secsBudget = $derived(estimateTargetSeconds(lengthDraft));
const draftPromptPreview = $derived(
buildStoryDraftPromptPreview({
title: story.project.title,
idea: ideaDraft,
genre: genreDraft,
tone: toneDraft,
targetDuration: lengthDraft,
}),
);
$effect(() => {
ideaDraft = story.project.idea ?? '';
genreDraft = story.project.genre || GENRES[0];
toneDraft = story.project.tone || TONES[0];
lengthDraft = story.project.target_duration || '2 minutes';
});
$effect(() => {
return () => {
if (savedTimer) clearTimeout(savedTimer);
};
});
function armSavedTimer() {
if (savedTimer) clearTimeout(savedTimer);
savedTimer = setTimeout(() => {
if (saveState === 'saved') saveState = 'idle';
}, 2000);
}
const saveProject = createMutation({
mutationFn: (payload: {
idea?: string;
genre?: string;
tone?: string;
target_duration?: string;
}) => projects.update(projectId, payload),
onMutate: () => {
saveState = 'saving';
},
onSuccess: () => {
client.invalidateQueries({ queryKey: ['story'] });
saveState = 'saved';
armSavedTimer();
},
onError: (err) => {
saveState = 'error';
toast.error(err instanceof Error ? err.message : t('story.saveSettingsFailed'));
},
});
/** Autosave path — returns false on failure (mutation onError already toasted). */
async function persistSettings(): Promise<boolean> {
try {
await $saveProject.mutateAsync({
idea: ideaDraft,
genre: genreDraft || undefined,
tone: toneDraft || undefined,
target_duration: lengthDraft || undefined,
});
return true;
} catch {
return false;
}
}
async function loadExample() {
ideaDraft = EXAMPLE.idea;
genreDraft = EXAMPLE.genre;
toneDraft = EXAMPLE.tone;
lengthDraft = EXAMPLE.target_duration;
const saved = await persistSettings();
if (saved) toast.success(t('story.exampleLoaded'));
}
function requestLoadExample() {
if (ideaDraft.trim()) confirmExampleOpen = true;
else void loadExample();
}
/** Hand off to a fresh, project-linked agent chat with the composer pre-filled. */
async function draftStoryline() {
if (!configured) return;
const saved = await persistSettings();
if (!saved) return; // persistSettings already toasted the failure
goto(agentDeepLink(projectId, 'story'));
}
function openBeat(beat: Beat) {
editingBeat = { ...beat };
beatModalOpen = true;
}
async function addBeat() {
if (addingBeat) return;
addingBeat = true;
try {
const nextIndex =
story.beats.reduce((max, b) => Math.max(max, b.order_index), 0) + 1;
// No api.ts binding exists yet for POST /beats — call the endpoint directly.
const res = await fetch(`/api/projects/${projectId}/beats`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_index: nextIndex,
title: t('story.newBeat', { n: nextIndex }),
description: '',
}),
});
if (!res.ok) {
const body = await res.text().catch(() => 'unknown error');
throw new Error(`${res.status}: ${body}`);
}
const created = (await res.json()) as Beat;
await client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.beatAdded', { index: nextIndex }));
openBeat(created);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('story.addBeatFailed'));
} finally {
addingBeat = false;
}
}
function requestDeleteBeat(beat: Beat) {
deletingBeat = beat;
beatDeleteOpen = true;
}
const deleteBeatMutation = createMutation({
mutationFn: (beatId: number) => projects.deleteBeat(projectId, beatId),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.beatDeleted'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.deleteBeatFailed'));
},
});
const saveBeat = createMutation({
mutationFn: () =>
projects.updateBeat(projectId, editingBeat!.id, {
title: editingBeat!.title,
description: editingBeat!.description,
order_index: editingBeat!.order_index,
}),
onSuccess: () => {
editingBeat = null;
beatModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.beatSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveBeatFailed'));
},
});
const saveChar = createMutation({
mutationFn: () =>
projects.updateCharacter(projectId, editingChar!.id, {
name: editingChar!.name,
role: editingChar!.role,
age: editingChar!.age,
appearance: editingChar!.appearance,
personality: editingChar!.personality,
consistency_prompt: editingChar!.consistency_prompt,
}),
onSuccess: () => {
editingChar = null;
charModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.characterSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveCharacterFailed'));
},
});
const saveLoc = createMutation({
mutationFn: () =>
projects.updateLocation(projectId, editingLoc!.id, {
name: editingLoc!.name,
description: editingLoc!.description,
consistency_prompt: editingLoc!.consistency_prompt,
}),
onSuccess: () => {
editingLoc = null;
locModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.locationSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveLocationFailed'));
},
});
const saveItem = createMutation({
mutationFn: () =>
projects.updateItem(projectId, editingItem!.id, {
name: editingItem!.name,
description: editingItem!.description,
consistency_prompt: editingItem!.consistency_prompt,
}),
onSuccess: () => {
editingItem = null;
itemModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.itemSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveItemFailed'));
},
});
function goToAssets() {
goto('?stage=assets', { keepFocus: true, noScroll: true });
}
function beatCountLabel(n: number): string {
return t('story.beats', { n });
}
</script>
{#snippet saveIndicator()}
{#if saveState === 'saving'}
<span class="save-ind"><Spinner size="sm" /> {t('common.saving')}</span>
{:else if saveState === 'saved'}
<span class="save-ind saved"><Icon name="check" size={13} /> {t('common.saved')}</span>
{:else if saveState === 'error'}
<span class="save-ind err">
<Icon name="alert" size={13} /> {t('common.saveFailed')}
<Button variant="ghost" size="sm" onclick={() => void persistSettings()}>{t('common.retry')}</Button>
</span>
{/if}
{/snippet}
<header class="stage-header">
<h2>{t('story.stageTitle')}</h2>
<div class="stage-actions">
<Button
variant="secondary"
disabled={$saveProject.isPending}
onclick={requestLoadExample}
>
{t('story.loadExample')}
</Button>
<Button variant="primary" disabled={!configured} onclick={() => void draftStoryline()}>
<Icon name="sparkle" size={15} /> {t('story.draftStoryline')}
</Button>
</div>
</header>
{#if !configured}
<div class="banner">{t('story.llmBanner')}</div>
{/if}
<div class="stack-col">
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.ideaTitle')}</h3>
{@render saveIndicator()}
{/snippet}
<textarea
class="field-textarea"
bind:value={ideaDraft}
rows="5"
placeholder={t('story.ideaPlaceholder')}
onblur={() => void persistSettings()}
></textarea>
</Card>
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.settingsTitle')}</h3>
{@render saveIndicator()}
{/snippet}
<div class="grid-3">
<label class="field">
<span class="field-label">{t('projects.genre')}</span>
<select
class="field-select"
bind:value={genreDraft}
onchange={() => void persistSettings()}
>
{#each GENRES as g}
<option value={g}>{genreLabel(g)}</option>
{/each}
{#if genreDraft && !GENRES.includes(genreDraft)}
<option value={genreDraft}>{genreLabel(genreDraft)}</option>
{/if}
</select>
</label>
<label class="field">
<span class="field-label">{t('story.targetLength')}</span>
<input
class="field-input"
type="text"
bind:value={lengthDraft}
placeholder={LENGTHS_HINT}
onblur={() => void persistSettings()}
/>
<span class="field-hint beat-hint">
{t('story.lengthHint', { secs: secsBudget, beats: beatBudget, scenes: sceneBudget })}
</span>
</label>
<label class="field">
<span class="field-label">{t('projects.tone')}</span>
<select
class="field-select"
bind:value={toneDraft}
onchange={() => void persistSettings()}
>
{#each TONES as tone}
<option value={tone}>{toneLabel(tone)}</option>
{/each}
{#if toneDraft && !TONES.includes(toneDraft)}
<option value={toneDraft}>{toneLabel(toneDraft)}</option>
{/if}
</select>
</label>
</div>
</Card>
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.draftPromptTitle')}</h3>
<Button variant="ghost" size="sm" onclick={() => (showDraftPrompt = !showDraftPrompt)}>
{showDraftPrompt ? t('story.hidePrompt') : t('story.showPrompt')}
</Button>
{/snippet}
{#if showDraftPrompt}
<pre class="prompt-preview">{draftPromptPreview}</pre>
<p class="field-hint">
{t('story.promptExplain')}
</p>
{:else}
<p class="field-hint prompt-collapsed">
{t('story.promptCollapsedHint')}
</p>
{/if}
</Card>
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.beatsTitle')}</h3>
<div class="head-actions">
{#if story.beats.length}
<StatusChip status="ready" label={beatCountLabel(story.beats.length)} />
{/if}
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
<Icon name="plus" size={14} /> {t('story.addBeat')}
</Button>
</div>
{/snippet}
{#if story.beats.length === 0}
<EmptyState
title={t('story.noBeats')}
body={t('story.noBeatsBody')}
>
{#snippet icon()}
<Icon name="story" size={28} />
{/snippet}
{#snippet action()}
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
<Icon name="plus" size={14} /> {t('story.addBeatManual')}
</Button>
{/snippet}
</EmptyState>
{:else}
<div class="beat-list">
{#each story.beats as beat (beat.id)}
<div class="beat-row">
<button type="button" class="beat" onclick={() => openBeat(beat)}>
<div class="beat-number">{beat.order_index}</div>
<div class="beat-body">
<h4>{beat.title}</h4>
<p>{beat.description}</p>
</div>
</button>
<div class="beat-actions">
<Button
variant="ghost"
size="sm"
title={t('story.deleteBeat', { index: beat.order_index })}
onclick={() => requestDeleteBeat(beat)}
>
<Icon name="trash" size={14} /><span class="sr-only">{t('story.deleteBeat', { index: beat.order_index })}</span>
</Button>
</div>
</div>
{/each}
</div>
<div class="beats-footer">
<Button variant="primary" onclick={goToAssets}>
{t('story.continueAssets')} <Icon name="chevron-right" size={15} />
</Button>
</div>
{/if}
</Card>
{#if story.characters.length || story.locations.length || story.items.length}
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.extracted')}</h3>
<Button variant="ghost" size="sm" onclick={goToAssets}>
{t('story.openAssets')} <Icon name="chevron-right" size={14} />
</Button>
{/snippet}
<div class="chips">
{#each story.characters as char (char.id)}
<button
type="button"
class="chip"
onclick={() => {
editingChar = { ...char };
charModalOpen = true;
}}
title={char.appearance ?? ''}
>
<span class="chip-tag">{t('story.charTag')}</span>
{char.name}
</button>
{/each}
{#each story.locations as loc (loc.id)}
<button
type="button"
class="chip"
onclick={() => {
editingLoc = { ...loc };
locModalOpen = true;
}}
title={loc.description ?? ''}
>
<span class="chip-tag">{t('story.envTag')}</span>
{loc.name}
</button>
{/each}
{#each story.items as item (item.id)}
<button
type="button"
class="chip"
onclick={() => {
editingItem = { ...item };
itemModalOpen = true;
}}
title={item.description ?? ''}
>
<span class="chip-tag">{t('story.itemTag')}</span>
{item.name}
</button>
{/each}
</div>
</Card>
{/if}
</div>
<Modal bind:open={beatModalOpen} title={t('story.editBeatTitle')} onclose={() => (editingBeat = null)}>
{#if editingBeat}
<label class="field">
<span class="field-label">{t('story.fieldTitle')}</span>
<input class="field-input" bind:value={editingBeat.title} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldDescription')}</span>
<textarea class="field-textarea" rows="4" bind:value={editingBeat.description}></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (beatModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveBeat.isPending} onclick={() => $saveBeat.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<Modal bind:open={charModalOpen} title={t('story.editCharTitle')} onclose={() => (editingChar = null)}>
{#if editingChar}
<label class="field">
<span class="field-label">{t('story.fieldName')}</span>
<input class="field-input" bind:value={editingChar.name} placeholder={t('story.fieldName')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldRole')}</span>
<input class="field-input" bind:value={editingChar.role} placeholder={t('story.fieldRole')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldAge')}</span>
<input class="field-input" bind:value={editingChar.age} placeholder={t('story.fieldAge')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldAppearance')}</span>
<textarea
class="field-textarea"
rows="3"
bind:value={editingChar.appearance}
placeholder={t('story.fieldAppearance')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldPersonality')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingChar.personality}
placeholder={t('story.fieldPersonality')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldConsistency')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingChar.consistency_prompt}
placeholder={t('story.fieldConsistency')}
></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (charModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveChar.isPending} onclick={() => $saveChar.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<Modal bind:open={locModalOpen} title={t('story.editLocTitle')} onclose={() => (editingLoc = null)}>
{#if editingLoc}
<label class="field">
<span class="field-label">{t('story.fieldName')}</span>
<input class="field-input" bind:value={editingLoc.name} placeholder={t('story.fieldName')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldDescription')}</span>
<textarea
class="field-textarea"
rows="3"
bind:value={editingLoc.description}
placeholder={t('story.fieldDescription')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldConsistency')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingLoc.consistency_prompt}
placeholder={t('story.fieldConsistency')}
></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (locModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveLoc.isPending} onclick={() => $saveLoc.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<Modal bind:open={itemModalOpen} title={t('story.editItemTitle')} onclose={() => (editingItem = null)}>
{#if editingItem}
<label class="field">
<span class="field-label">{t('story.fieldName')}</span>
<input class="field-input" bind:value={editingItem.name} placeholder={t('story.fieldName')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldDescription')}</span>
<textarea
class="field-textarea"
rows="3"
bind:value={editingItem.description}
placeholder={t('story.fieldDescription')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldConsistency')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingItem.consistency_prompt}
placeholder={t('story.fieldConsistency')}
></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (itemModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveItem.isPending} onclick={() => $saveItem.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<ConfirmDialog
bind:open={confirmExampleOpen}
title={t('story.confirmExampleTitle')}
message={t('story.confirmExampleMsg')}
confirmLabel={t('story.loadExample')}
onconfirm={() => void loadExample()}
/>
<ConfirmDialog
bind:open={beatDeleteOpen}
title={t('story.confirmDeleteBeatTitle')}
message={deletingBeat
? t('story.confirmDeleteBeatMsg', { index: deletingBeat.order_index, title: deletingBeat.title })
: ''}
confirmLabel={t('common.delete')}
danger
onconfirm={() => {
const id = deletingBeat?.id;
deletingBeat = null;
if (id != null) $deleteBeatMutation.mutate(id);
}}
oncancel={() => (deletingBeat = null)}
/>
<style>
.stage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
gap: var(--space-md);
}
.stage-header h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
}
.stage-actions {
display: flex;
gap: var(--space-sm);
}
.banner {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
color: var(--warning);
padding: var(--space-md);
border-radius: var(--radius-md);
margin-bottom: var(--space-lg);
font-size: 13px;
}
.stack-col {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.card-h {
margin: 0;
font-size: 16px;
color: var(--text-secondary);
font-weight: 600;
}
.head-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.save-ind {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.save-ind.saved {
color: var(--success);
animation: saved-fade 2s ease forwards;
}
.save-ind.err {
color: var(--error);
}
@keyframes saved-fade {
0% {
opacity: 0;
}
12% {
opacity: 1;
}
78% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-md);
}
@media (max-width: 900px) {
.grid-3 {
grid-template-columns: 1fr;
}
}
.grid-3 .field {
margin-bottom: 0;
}
.beat-hint {
display: block;
}
.beat-hint strong {
color: var(--accent);
font-weight: 600;
}
.prompt-preview {
margin: 0 0 var(--space-sm);
padding: 12px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
color: var(--text-secondary);
max-height: 280px;
overflow: auto;
}
.prompt-collapsed {
margin: 0;
}
.beat-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.beat-row {
display: flex;
align-items: flex-start;
gap: var(--space-xs);
}
.beat {
display: flex;
flex: 1;
min-width: 0;
gap: var(--space-md);
align-items: flex-start;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
padding: 8px;
cursor: pointer;
text-align: left;
color: inherit;
font: inherit;
}
.beat:hover {
border-color: var(--border);
background: var(--bg-elevated);
}
.beat:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.beat-actions {
flex-shrink: 0;
padding-top: 8px;
}
.beat-number {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: var(--text-secondary);
flex-shrink: 0;
}
.beat-body h4 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
}
.beat-body p {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.45;
}
.beats-footer {
display: flex;
justify-content: flex-end;
margin-top: var(--space-md);
padding-top: var(--space-md);
border-top: 1px solid var(--border);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
}
.chip:hover {
border-color: var(--accent);
}
.chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.chip-tag {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent);
font-weight: 700;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
@@ -37,8 +37,13 @@
<span class="dot"></span>
<Icon name={kindIcon[toast.kind]} size={15} />
</span>
<span class="msg">{toast.message}</span>
<button
<span class="msg">{toast.message}</span>
{#if toast.action}
<button type="button" class="action" onclick={() => (toast.action?.run(), dismissToast(toast.id))}>
{toast.action.label}
</button>
{/if}
<button
type="button"
class="dismiss"
aria-label={t('toast.dismiss')}
@@ -52,15 +57,17 @@
{/if}
<style>
/* §四.5 Toast:右下角堆叠,220ms 上移入场;成功绿点 / 失败红点。 */
.toast-host {
position: fixed;
top: 72px;
right: 20px;
z-index: 1000;
right: 24px;
bottom: 24px;
/* Above every overlay, incl. the 10000 lightbox, so feedback stays visible. */
z-index: 10001;
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(360px, calc(100vw - 32px));
max-width: min(400px, calc(100vw - 32px));
pointer-events: none;
}
.toast {
@@ -69,23 +76,10 @@
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg-surface);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
animation: toast-in 0.22s ease-out;
}
.toast.success {
border-color: rgba(34, 197, 94, 0.45);
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(34, 197, 94, 0.12);
}
.toast.error {
border-color: rgba(239, 68, 68, 0.5);
}
.toast.info {
border-color: rgba(59, 130, 246, 0.45);
border-radius: var(--r-lg);
background: var(--float-bg);
box-shadow: var(--shadow-float);
animation: toast-in var(--d-enter) var(--ease-out);
}
.badge {
display: inline-flex;
@@ -101,21 +95,44 @@
background: currentColor;
}
.toast.success .badge {
color: var(--success);
color: var(--ok);
}
.toast.error .badge {
color: var(--error);
color: var(--err);
}
.toast.info .badge {
color: var(--info);
color: var(--text-2);
}
.msg {
flex: 1;
font-size: 13px;
font-size: var(--fs-body);
line-height: 1.4;
color: var(--text-primary);
font-weight: 500;
}
/* §四.5: Toast 动作(如撤销)为金色文字 */
.action {
flex-shrink: 0;
align-self: center;
padding: var(--sp-1) var(--sp-2);
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--accent);
font: inherit;
font-size: var(--fs-aux);
font-weight: 600;
cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
.action:hover {
background: var(--accent-soft);
}
}
.action:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.dismiss {
display: inline-flex;
align-items: center;
@@ -127,10 +144,12 @@
padding: 0 2px;
min-width: 28px;
min-height: 28px;
border-radius: 4px;
border-radius: var(--r-sm);
}
.dismiss:hover {
color: var(--text-primary);
@media (hover: hover) and (pointer: fine) {
.dismiss:hover {
color: var(--text-primary);
}
}
.dismiss:focus-visible {
outline: 2px solid var(--accent);
@@ -139,7 +158,7 @@
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(-8px);
transform: translateY(8px);
}
to {
opacity: 1;
@@ -151,10 +170,12 @@
animation: none;
}
}
@media (max-width: 640px) {
:global(:root[data-reduce='1']) .toast {
animation: none;
}
@media (max-width: 760px) {
.toast-host {
top: auto;
bottom: 20px;
bottom: calc(var(--tabbar-h) + var(--sp-5) + env(safe-area-inset-bottom));
right: 16px;
left: 16px;
max-width: none;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,827 @@
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import {
apiErrorMessage,
assetUrl,
playgroundApi,
jobsApi,
workflows,
type ComfyEndpoint,
type Job,
type Workflow,
} from '$lib/api';
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
import Button from '$lib/components/ui/Button.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Drawer from '$lib/components/ui/Drawer.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import ProgressBar from '$lib/components/ui/ProgressBar.svelte';
import SafeMedia from '$lib/components/SafeMedia.svelte';
import { mediaFileProblem, type MediaSlotKind } from '$lib/comfy/imageFile';
import { jobStatusInfo } from '$lib/jobStatus';
import { progressFor } from '$lib/jobProgress';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
/**
* 测试工作流 (HEL-643 L3, E 批抽屉化): fill the workflow's REAL input
* slots, submit one REAL run to the chosen ComfyUI through the ordinary
* job queue, and call it a pass only when at least one downloadable
* output of the right media kind came back. Test artifacts are isolated
* from projects. Presentation follows HEL-649 §3.8: right-side drawer,
* cyan progress + mono stage log while running, result thumbnails in a
* horizontal row.
*/
interface Props {
open?: boolean;
workflow: Workflow | null;
endpoints: ComfyEndpoint[];
onchanged?: () => void;
}
let {
open = $bindable(false),
workflow,
endpoints,
onchanged,
}: Props = $props();
const client = useQueryClient();
let endpointId = $state('');
let values = $state<Record<string, string | number>>({});
let listValues = $state<Record<string, string[]>>({});
let sizes = $state<Record<string, number>>({});
let uploadingName = $state<Record<string, string>>({});
let rejects = $state<Record<string, { name: string; message: string }[]>>({});
let submitError = $state('');
let confirmOpen = $state(false);
let submitting = $state(false);
let testJobId = $state<number | null>(null);
let errorExpanded = $state(false);
let logBox = $state<HTMLDivElement | null>(null);
const wf = $derived(workflow);
const schema = $derived(wf?.input_schema ?? []);
const listEntries = $derived(
schema.filter(
(i) =>
i.kind === 'image_list' ||
i.kind === 'video_list' ||
i.kind === 'audio_list',
),
);
const formEntries = $derived(
schema.filter(
(i) =>
i.kind !== 'image_list' &&
i.kind !== 'video_list' &&
i.kind !== 'audio_list',
),
);
const hasH3Hint = $derived(
listEntries.some((e) => e.media_hint === 'minimax_h3_ref2va'),
);
const outputKind = $derived<'image' | 'video'>(
wf?.kind === 'video' ? 'video' : 'image',
);
const testsQuery = createQuery(
toStore(() => ({
queryKey: ['workflow-tests', wf?.id ?? 0],
queryFn: () => workflows.tests(wf!.id),
enabled: !!wf && open,
refetchInterval: 2000,
})),
);
const jobsQuery = createQuery(
toStore(() => ({
queryKey: ['jobs'],
queryFn: () => jobsApi.list(),
enabled: !!wf && open,
refetchInterval: 5000,
})),
);
const queuedCount = $derived(
($jobsQuery.data ?? []).filter(
(j) => j.status === 'pending' || j.status === 'running',
).length,
);
const history = $derived(($testsQuery.data ?? []) as Job[]);
const activeJob = $derived.by(() => {
if (testJobId != null) {
const mine = history.find((j) => j.id === testJobId);
if (mine && (mine.status === 'pending' || mine.status === 'running'))
return mine;
}
return (
history.find((j) => j.status === 'pending' || j.status === 'running') ??
null
);
});
const finishedJob = $derived.by(() => {
if (testJobId != null)
return history.find((j) => j.id === testJobId) ?? null;
return null;
});
const queueBusy = $derived(!!activeJob || submitting);
const liveProgress = $derived(
activeJob ? progressFor(activeJob.id) : undefined,
);
const activeInfo = $derived(activeJob ? jobStatusInfo(activeJob) : null);
// Rolling mono stage log (§3.8): one line per distinct worker message.
let stageLog = $state<string[]>([]);
$effect(() => {
if (!activeJob || !activeInfo) {
stageLog = [];
return;
}
const line = liveProgress?.message || activeInfo.detail;
if (!line) return;
if (stageLog[stageLog.length - 1] !== line) stageLog = [...stageLog, line];
});
$effect(() => {
stageLog.length;
if (logBox) logBox.scrollTop = logBox.scrollHeight;
});
$effect(() => {
if (!open || !wf) return;
endpointId = wf.endpoint_id ?? '';
values = Object.fromEntries(
schema
.filter((i) => i.defaultValue !== undefined && i.defaultValue !== null)
.map((i) => [i.nodeId, i.defaultValue as string | number]),
);
listValues = {};
sizes = {};
rejects = {};
uploadingName = {};
submitError = '';
errorExpanded = false;
stageLog = [];
testJobId =
wf.last_test &&
(wf.last_test.status === 'pending' || wf.last_test.status === 'running')
? wf.last_test.id
: null;
});
function kindOf(entry: { kind: string }): MediaSlotKind {
if (entry.kind === 'video_list') return 'video';
if (entry.kind === 'audio_list') return 'audio';
return 'image';
}
function unitOf(kind: MediaSlotKind): string {
return kind === 'image' ? '张' : '段';
}
function setRejects(key: string, next: { name: string; message: string }[]) {
rejects = { ...rejects, [key]: next };
}
async function uploadOne(file: File): Promise<string | null> {
const res = await playgroundApi.upload(file);
sizes = { ...sizes, [res.path]: file.size };
return res.path;
}
async function mediaAdd(entry: (typeof listEntries)[number], files: File[]) {
const kind = kindOf(entry);
const key = entry.nodeId;
const max = entry.max ?? 9;
const current = listValues[key] ?? [];
const ok: string[] = [];
const bad: { name: string; message: string }[] = [];
for (const f of files) {
const problem = mediaFileProblem(kind, f);
if (problem) bad.push({ name: f.name, message: problem });
else ok.push(f.name);
}
if (current.length + ok.length > max) {
toast.error(
t('studio.errRefMax', { max, n: current.length, k: ok.length }),
);
if (bad.length) setRejects(key, [...(rejects[key] ?? []), ...bad]);
return;
}
setRejects(key, [...(rejects[key] ?? []), ...bad]);
for (const f of files) {
if (!ok.includes(f.name)) continue;
uploadingName = { ...uploadingName, [key]: f.name };
try {
const path = await uploadOne(f);
if (path)
listValues = {
...listValues,
[key]: [...(listValues[key] ?? []), path],
};
} catch (err) {
toast.error(apiErrorMessage(err));
} finally {
uploadingName = { ...uploadingName, [key]: '' };
}
}
}
async function mediaReplace(
entry: (typeof listEntries)[number],
index: number,
file: File,
) {
const kind = kindOf(entry);
const key = entry.nodeId;
const problem = mediaFileProblem(kind, file);
if (problem) {
setRejects(key, [
...(rejects[key] ?? []),
{ name: file.name, message: problem },
]);
return;
}
uploadingName = { ...uploadingName, [key]: file.name };
try {
const path = await uploadOne(file);
if (path) {
const next = [...(listValues[key] ?? [])];
next[index] = path;
listValues = { ...listValues, [key]: next };
}
} catch (err) {
toast.error(apiErrorMessage(err));
} finally {
uploadingName = { ...uploadingName, [key]: '' };
}
}
function mediaRemove(entry: (typeof listEntries)[number], index: number) {
const key = entry.nodeId;
listValues = {
...listValues,
[key]: (listValues[key] ?? []).filter((_, i) => i !== index),
};
}
/** Mirrors the backend's validate_input_values for immediate feedback. */
function validate(): string[] {
const errs: string[] = [];
for (const e of schema) {
const label = e.label;
if (
e.kind === 'image_list' ||
e.kind === 'video_list' ||
e.kind === 'audio_list'
) {
const n = (listValues[e.nodeId] ?? []).length;
const unit = unitOf(kindOf(e));
if (e.required && n < (e.min ?? 1))
errs.push(t('wft.errListMin', { n: e.min ?? 1, unit, label }));
} else if (e.auto && e.required && e.role === 'prompt') {
const v = values[e.nodeId];
if (
!(typeof v === 'string' && v.trim()) &&
!String(e.defaultValue ?? '').trim()
) {
errs.push(t('wft.errRequired', { label }));
}
} else if (
e.required &&
(e.kind === 'image' || e.kind === 'video' || e.kind === 'audio') &&
e.role !== 'mask'
) {
if (!values[e.nodeId]) errs.push(t('wft.errRequired', { label }));
}
}
return errs;
}
async function submitTest() {
if (!wf) return;
const errs = validate();
if (errs.length) {
submitError = errs.join(';');
return;
}
submitError = '';
confirmOpen = true;
}
async function reallyRunTest() {
if (!wf) return;
submitting = true;
try {
const inputValues: Record<string, unknown> = { ...values };
for (const [k, v] of Object.entries(listValues)) {
if (v.length) inputValues[k] = v;
}
const res = await workflows.test(wf.id, {
...(endpointId ? { endpoint_id: endpointId } : {}),
input_values: inputValues as Record<string, string | number | string[]>,
});
testJobId = res.job.id;
if (res.reused) toast.info(t('wft.reusedToast'));
void $testsQuery.refetch();
} catch (err) {
submitError = apiErrorMessage(err);
} finally {
submitting = false;
confirmOpen = false;
}
}
async function cancelTest() {
if (!activeJob) return;
try {
await jobsApi.cancel(activeJob.id);
void $testsQuery.refetch();
} catch (err) {
toast.error(apiErrorMessage(err));
}
}
async function clearRecords() {
if (!wf) return;
try {
await workflows.clearTests(wf.id);
testJobId = null;
await $testsQuery.refetch();
onchanged?.();
toast.success(t('wft.cleared'));
} catch (err) {
toast.error(apiErrorMessage(err));
}
}
function resultLine(job: Job): string {
if (job.status === 'done' && (job.output_paths?.length ?? 0) > 0) {
return t('wft.passedShort', { n: job.output_paths.length });
}
if (job.error === 'cancelled') return t('wft.cancelledShort');
return job.error ? job.error.slice(0, 120) : t('wft.failedShort');
}
function timeOf(at: string | null): string {
if (!at) return '—';
const ts = Date.parse(at.replace(' ', 'T') + 'Z');
const d = new Date(Number.isFinite(ts) ? ts : at);
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
const endpointLabel = $derived(
endpoints.find((e) => e.id === endpointId)?.name ??
t('conn.wfConnectionActive'),
);
</script>
<Drawer bind:open title={t('wft.title')} width={420}>
{#if wf}
<div class="wft">
<p class="wft-sub">
{wf.name}
<span class="mono wft-id">#{wf.id}</span>
</p>
<label class="field">
<span class="field-label">{t('wft.connection')}</span>
<select
class="a-select full"
bind:value={endpointId}
disabled={queueBusy}
>
<option value="">{t('conn.wfConnectionActive')}</option>
{#each endpoints as ep (ep.id)}
<option value={ep.id}>{ep.name}</option>
{/each}
</select>
<span class="field-hint">{t('wft.costNote')}</span>
</label>
{#if hasH3Hint}
<div class="wft-note" role="note">
<Icon name="info" size={14} />
<p>{t('wfMedia.h3Limits')}</p>
</div>
{/if}
{#if activeJob && activeInfo}
<div class="wft-active">
<div class="wft-active-head">
<span class="chip run">
<span class="chip-dot" aria-hidden="true"></span>
{t('wft.running')}
</span>
<span class="wft-active-detail">{activeInfo.detail}</span>
<Button variant="secondary" size="sm" onclick={cancelTest}
>{t('wft.cancel')}</Button
>
</div>
<ProgressBar
value={liveProgress?.progress ?? 0}
indeterminate={activeJob.status === 'pending' && !liveProgress}
label={activeInfo.label}
/>
{#if stageLog.length}
<div class="wft-log mono" bind:this={logBox} aria-live="polite">
{#each stageLog as line, i (i)}
<p>{line}</p>
{/each}
</div>
{/if}
</div>
{:else if finishedJob && finishedJob.status === 'done' && (finishedJob.output_paths?.length ?? 0) > 0}
<div class="wft-result ok">
<p class="wft-verdict">
{t('wft.passed', { n: finishedJob.output_paths.length })}
</p>
<div class="wft-outputs">
{#each finishedJob.output_paths as path (path)}
<a
class="wft-out"
href={assetUrl(path)}
download
target="_blank"
rel="noreferrer"
title={t('wft.download')}
>
<SafeMedia
kind={outputKind}
src={assetUrl(path)}
controls={outputKind === 'video'}
/>
</a>
{/each}
</div>
</div>
{:else if finishedJob && finishedJob.status !== 'pending' && finishedJob.status !== 'running'}
<div class="wft-result bad">
<p class="wft-verdict">{t('wft.failed')}</p>
<p class="wft-error">
{finishedJob.error === 'cancelled'
? t('wft.cancelledShort')
: errorExpanded || (finishedJob.error ?? '').length <= 160
? finishedJob.error
: (finishedJob.error ?? '').slice(0, 160) + '…'}
</p>
{#if finishedJob.error && finishedJob.error !== 'cancelled' && finishedJob.error.length > 160}
<button
type="button"
class="wft-expand"
onclick={() => (errorExpanded = !errorExpanded)}
>
{errorExpanded ? t('wfd.hideDetails') : t('wfd.showDetails')}
</button>
{/if}
</div>
{/if}
{#if !queueBusy}
<div class="wft-form">
{#each listEntries as entry (entry.nodeId)}
{@const kind = kindOf(entry)}
<RefImageGrid
label={entry.label}
mediaKind={kind}
paths={listValues[entry.nodeId] ?? []}
max={entry.max ?? 9}
{sizes}
uploadingName={uploadingName[entry.nodeId] || undefined}
rejects={rejects[entry.nodeId] ?? []}
onAdd={(files) => mediaAdd(entry, files)}
onReplace={(i, file) => mediaReplace(entry, i, file)}
onRemove={(i) => mediaRemove(entry, i)}
onDismissReject={(i) =>
setRejects(
entry.nodeId,
(rejects[entry.nodeId] ?? []).filter((_, j) => j !== i),
)}
/>
{/each}
{#if formEntries.length}
<ComfyDynamicForm
inputs={formEntries}
bind:values
allowUpload
showErrors={!!submitError}
/>
{/if}
</div>
{#if submitError}<span class="field-error" role="alert"
>{submitError}</span
>{/if}
<div class="wft-actions">
<Button variant="primary" loading={submitting} onclick={submitTest}>
{t('wft.submit')}
</Button>
</div>
{/if}
<div class="wft-history">
<div class="wft-history-head">
<span class="field-label"
>{t('wft.history', { n: history.length })}</span
>
{#if history.length}
<button type="button" class="link-danger" onclick={clearRecords}>
{t('wft.clear')}
</button>
{/if}
</div>
{#if history.length === 0}
<p class="wft-empty">{t('wft.never')}</p>
{:else}
<ul class="wft-rows">
{#each history as job (job.id)}
<li class:active={job.id === activeJob?.id}>
<span class="mono wft-row-id">#{job.id}</span>
<span class="wft-row-time">{timeOf(job.created_at)}</span>
<span
class="wft-row-result"
class:ok={job.status === 'done' &&
(job.output_paths?.length ?? 0) > 0}
>
{resultLine(job)}
</span>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{/if}
</Drawer>
<ConfirmDialog
bind:open={confirmOpen}
title={t('wft.confirmTitle')}
message={t('wft.confirmBody', { endpoint: endpointLabel, n: queuedCount })}
confirmLabel={t('wft.submit')}
onconfirm={reallyRunTest}
/>
<style>
.wft {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.wft-sub {
margin: 0;
font-size: 13px;
color: var(--text-2);
}
.wft-id {
color: var(--text-3);
font-size: 11px;
margin-left: 6px;
}
.a-select.full {
width: 100%;
}
.wft-note {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 8px 10px;
border: 1px solid var(--run-soft);
border-radius: var(--r-sm);
background: var(--run-soft);
color: var(--text-2);
font-size: 12px;
line-height: 1.6;
}
.wft-note :global(.icon) {
color: var(--run);
flex-shrink: 0;
margin-top: 2px;
}
.wft-note p {
margin: 0;
}
.wft-active,
.wft-result {
border: 1px solid var(--line-2);
border-radius: var(--r-md);
padding: 10px 12px;
background: var(--bg-2);
display: flex;
flex-direction: column;
gap: 8px;
}
.wft-active {
border-color: var(--run-soft);
}
.wft-active-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 20px;
padding: 0 8px;
border-radius: var(--r-sm);
font-size: 12px;
font-weight: 600;
}
.chip.run {
color: var(--run);
background: var(--run-soft);
}
.chip-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--run);
animation: wft-breathe 1.6s ease-in-out infinite;
}
@keyframes wft-breathe {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
:global([data-reduce='1']) .chip-dot {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.chip-dot {
animation: none;
}
}
.wft-active-detail {
flex: 1;
min-width: 120px;
font-size: 12px;
color: var(--text-2);
}
.wft-log {
max-height: 110px;
overflow-y: auto;
padding: 8px 10px;
border-radius: var(--r-sm);
background: var(--bg);
border: 1px solid var(--line);
font-size: 12px;
line-height: 1.6;
color: var(--text-2);
}
.wft-log p {
margin: 0;
}
.wft-result.ok {
border-color: var(--ok);
}
.wft-result.bad {
border-color: var(--err);
}
.wft-verdict {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.wft-result.ok .wft-verdict {
color: var(--ok);
}
.wft-result.bad .wft-verdict {
color: var(--err);
}
.wft-error {
margin: 0;
font-size: 12px;
color: var(--err);
line-height: 1.6;
word-break: break-all;
}
.wft-expand {
align-self: flex-start;
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11px;
color: var(--text-2);
text-decoration: underline;
cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
.wft-expand:hover {
color: var(--text);
}
}
.wft-outputs {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.wft-out {
display: block;
flex: 0 0 auto;
width: 148px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
overflow: hidden;
background: var(--bg);
}
.wft-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.wft-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.wft-history {
border-top: 1px solid var(--line);
padding-top: 10px;
}
.wft-history-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wft-history-head .field-label {
margin-bottom: 0;
}
.link-danger {
background: none;
border: none;
padding: 4px 8px;
font-family: inherit;
font-size: 12px;
color: var(--err);
cursor: pointer;
border-radius: var(--r-sm);
opacity: 0.85;
display: inline-flex;
align-items: center;
}
@media (hover: hover) and (pointer: fine) {
.link-danger:hover {
opacity: 1;
background: var(--err-soft);
}
}
.wft-empty {
margin: 6px 0 0;
font-size: 12px;
color: var(--text-3);
}
.wft-rows {
list-style: none;
margin: 6px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.wft-rows li {
display: flex;
gap: 10px;
align-items: baseline;
font-size: 12px;
color: var(--text-2);
padding: 4px 6px;
border-radius: var(--r-sm);
}
.wft-rows li.active {
background: var(--bg-3);
}
.wft-row-id {
color: var(--text-3);
font-size: 11px;
}
.wft-row-time {
color: var(--text-3);
font-size: 11px;
white-space: nowrap;
}
.wft-row-result {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wft-row-result.ok {
color: var(--ok);
}
</style>
@@ -132,7 +132,7 @@
border-color: color-mix(in srgb, var(--success) 30%, var(--border));
}
.artifact.failed {
border-color: rgba(239, 68, 68, 0.4);
border-color: var(--err-line);
}
.meta {
display: flex;
@@ -249,12 +249,12 @@
width: 44px;
height: 44px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.55);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
background: var(--media-scrim);
box-shadow: 0 0 0 1px var(--on-media-line);
}
.play-badge :global(svg) {
position: absolute;
color: #fff;
color: var(--on-media);
fill: currentColor;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6));
margin-left: 2px;
@@ -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 {
@@ -668,8 +668,8 @@ let {
justify-content: center;
border: none;
border-radius: 999px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
background: var(--media-scrim);
color: var(--on-media);
cursor: pointer;
padding: 0;
}
@@ -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;
}
@@ -772,7 +772,7 @@ let {
border-radius: var(--radius-md);
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
color: var(--accent-ink);
font-size: 13px;
font-weight: 600;
cursor: pointer;
@@ -785,7 +785,7 @@ let {
.send.stop {
background: transparent;
color: var(--error);
border-color: rgba(239, 68, 68, 0.5);
border-color: var(--err-line);
}
.send.steer {
color: var(--accent);
@@ -431,6 +431,6 @@
}
.del:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.12);
background: var(--err-soft);
}
</style>
@@ -60,7 +60,7 @@
overflow: hidden;
}
.tool-card.error {
border-color: rgba(239, 68, 68, 0.45);
border-color: var(--err-line);
}
.head {
display: flex;
@@ -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,43 +1,41 @@
/**
* Agent identity palette: a stable display name + accent color per agent.
* Agent identity palette: a stable display name + identity step 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 answers "who is speaking" only (avatar dot / name / bubble tint)
* — it never expresses task status. HEL-685 无彩化:全部改为 app.css 的
* 显影灰阶实色阶 --id-1..5(与全站 --avatar-1..5 同一身份语言),
* 不再使用彩色身份色;默认主循环智能体保持中性 --id-4。
*/
export const DEFAULT_AGENT_NAME = 'Calliope';
// Fixed identity map (keyed by the raw agent_name the backend emits).
// Step numbers mirror the avatar five-set used across the app
// (models/agents/home project cards).
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: 'var(--id-2)' },
'story-agent': { label: 'Story Agent', color: 'var(--id-3)' },
'script-agent': { label: 'Script Agent', color: 'var(--id-1)' },
'assets-agent': { label: 'Assets Agent', color: 'var(--id-4)' },
'video-agent': { label: 'Video Agent', color: 'var(--id-5)' },
'research-agent': { label: 'Research Agent', color: 'var(--id-4)' },
// 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' },
视觉设计: { label: '视觉设计', color: '#5b8def' },
分镜设计: { label: '分镜设计', color: '#3fb97f' },
素材资产: { label: '素材资产', color: '#b98be0' },
镜头脚本: { label: '镜头脚本', color: '#d98e6a' },
// named-session replies; steps mirror the avatar five-set.
剧本创作: { label: '剧本创作', color: 'var(--id-1)' },
视觉设计: { label: '视觉设计', color: 'var(--id-2)' },
分镜设计: { label: '分镜设计', color: 'var(--id-3)' },
素材资产: { label: '素材资产', color: 'var(--id-4)' },
镜头脚本: { label: '镜头脚本', color: 'var(--id-5)' },
};
// Fallback palette for unknown names (stable hash → deterministic color).
// Fallback palette for unknown names (stable hash → deterministic step),
// spanning the same five grayscale identity steps.
const FALLBACK_COLORS = [
'#22d3ee',
'#22c55e',
'#f59e0b',
'#ec4899',
'#fb923c',
'#a78bfa',
'#38bdf8',
'#4ade80',
'#f472b6',
'#facc15',
'var(--id-1)',
'var(--id-2)',
'var(--id-3)',
'var(--id-4)',
'var(--id-5)',
];
function hashString(s: string): number {
@@ -48,8 +46,8 @@ function hashString(s: string): number {
return h;
}
/** The default "Calliope" accent (matches --accent). */
export const DEFAULT_AGENT_COLOR = '#8b5cf6';
/** Neutral step (--id-4) — the default agent borrows no signal color. */
export const DEFAULT_AGENT_COLOR = 'var(--id-4)';
export function agentDisplayName(name: string | null | undefined): string {
const key = (name || '').trim().toLowerCase();
@@ -0,0 +1,661 @@
<script lang="ts">
import { untrack, type Snippet } from 'svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ImageLightbox from '$lib/components/ImageLightbox.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 '../generate/schema';
import {
elapsedSec,
fmtClock,
fmtDur,
inputsOf,
isVideoOutput,
promptOf,
specOf,
} from '../generate/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 即时无动画), video playback is always explicit,
* failed takes get 重试, and a done take whose files no longer load
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
*/
interface Props {
main?: boolean;
comparePath?: string | null;
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;
/** The stage <video> element, for explicit playback controls. */
videoEl?: HTMLVideoElement | null;
params: Snippet;
onClose: () => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onCancel: (job: Job) => void;
onSendToSlot: (path: string) => void;
onDelete: (job: Job) => void;
}
let {
main = false,
comparePath = null,
tab = $bindable(),
job,
wf,
nowMs,
open,
inactive,
canSendToSlot,
crossfade,
videoEl = $bindable(null),
params,
onClose,
onReuse,
onRetry,
onCancel,
onSendToSlot,
onDelete,
}: Props = $props();
let detailsOpen = $state(false);
let outIndex = $state(0);
let compare = $state(false),
compareAt = $state(50),
lightbox = $state(false);
$effect(() => {
void job?.id;
detailsOpen = false;
compare = false;
lightbox = false;
});
$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 compareSource = $derived(
inputs.find((i) => i.kind === 'image')?.path ?? comparePath,
);
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}`;
}
let paused = $state(true);
$effect.pre(() => {
void output;
untrack(() => {
videoEl?.pause();
paused = true;
});
});
function togglePlayback() {
if (!videoEl) return;
if (videoEl.paused) videoEl.play().catch(() => {});
else videoEl.pause();
}
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>
<div class="artboard" aria-label="生成结果">
<header class="artboard-toolbar">
<div class="result-title">
<span>第 {job?.id} 次生成</span><small>{wf?.name ?? '历史作品'}</small>
</div>
<div class="canvas-actions">
{#if output && !video}<button
class="canvas-btn"
onclick={() => (lightbox = true)}
aria-label="放大查看">⤢</button
>
{#if compareSource && compareSource !== output}<button
class="canvas-btn"
aria-pressed={compare}
onclick={() => (compare = !compare)}
>◫ {compare ? '结束对比' : '对比'}</button
>{/if}
{/if}
{#if output}<a
class="canvas-btn"
href={assetUrl(output)}
download
aria-label="下载生成结果">↓ 下载</a
>{/if}
<button class="canvas-btn" onclick={() => (detailsOpen = true)}
>详情与操作</button
>
</div>
</header>
<div class="stage" class:comparing={compare}>
{#if job && st}{#key mediaKey}
<div class="stage-media">
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<div class="print a-photo">
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
<video
bind:this={videoEl}
src={assetUrl(output)}
controls
aria-label="生成的视频,使用播放按钮开始播放"
onplay={() => (paused = false)}
onpause={() => (paused = true)}
onended={() => (paused = true)}
playsinline
preload="metadata"
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
</div>
{:else if output}
<div class="print a-photo">
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
<img
src={assetUrl(output)}
alt={prompt || t('desk.tabResult')}
onload={(e) => {
clearOutputMissing(output);
noteImg(e);
}}
onerror={() => output && markOutputMissing(output)}
/>
{#if main && compare && compareSource}<div
class="comparison-layer"
style:clip-path={`inset(0 ${100 - compareAt}% 0 0)`}
>
<img src={assetUrl(compareSource)} alt="对比参考画面" />
</div>
<div
class="comparison-divider"
style:left={`${compareAt}%`}
></div>{/if}
</div>
{:else if fileGone}
<div class="stage-empty gone">
<span>{t('desk.missingTitle')}</span>
<span class="sub">{t('desk.missingSub')}</span>
</div>
{:else}
<div class="stage-empty">
<span>{st.label}</span>
<span class="sub"
>{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span
>
{#if progress > 0}
<div class="a-progress stage-bar" style:--p={progress / 100}>
<i></i>
</div>
{/if}
</div>
{/if}
</div>
{/key}{/if}
{#if outputs.length > 1}<div
class="output-picker"
role="group"
aria-label="本次生成的作品"
>
{#each outputs as p, i}<button
aria-pressed={output === p}
onclick={() => (outIndex = i)}
aria-label={'查看第 ' + (i + 1) + ' 件作品'}>{i + 1}</button
>{/each}
</div>{/if}
</div>
<footer class="artboard-footer">
{#if video}<button
class="canvas-btn play-button"
onclick={togglePlayback}
aria-label={paused ? '播放视频' : '暂停视频'}
>{paused ? '▶ 播放' : 'Ⅱ 暂停'}</button
><button
class="canvas-btn"
onclick={() => videoEl?.requestFullscreen().catch(() => {})}
>全屏</button
>{/if}
<span class="result-spec"
>{dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''}</span
>
{#if compare && output && !video}<label class="compare-slider"
>参考<input
type="range"
min="0"
max="100"
bind:value={compareAt}
aria-label="拖动对比画面"
/>结果</label
>{/if}
{#if job?.status === 'pending' || job?.status === 'running'}<button
class="canvas-btn"
onclick={() => job && onCancel(job)}>取消生成</button
>{/if}
{#if st?.tone === 'fail'}<button
class="canvas-btn"
onclick={() => job && onRetry(job)}>重试</button
>{/if}
</footer>
</div>
<Modal bind:open={detailsOpen} size="lg" title="作品详情与操作">
{#if job && st}
<div class="detail-actions">
<button
class="a-btn secondary sm"
onclick={() => {
onReuse(job);
detailsOpen = false;
}}>复用到输入区</button
>
{#if output && !video && canSendToSlot}<button
class="a-btn secondary sm"
onclick={() => {
onSendToSlot(output);
detailsOpen = false;
}}>送入当前图片槽</button
>{/if}
{#if video && output}<button
class="a-btn secondary sm"
disabled={frameBusy}
onclick={exportTail}>{frameBusy ? '正在导出…' : '导出尾帧'}</button
>{/if}
{#if st.tone === 'fail' || fileGone}<button
class="a-btn secondary sm"
onclick={() => onRetry(job)}>用相同参数重做</button
>{/if}
{#if job.status !== 'pending' && job.status !== 'running'}<button
class="a-btn danger sm"
onclick={() => {
onDelete(job);
detailsOpen = false;
}}>移除记录</button
>{/if}
</div>
{#if st.tone === 'fail' || fileGone}<p class="error-note" role="alert">
{fileGone ? t('jobst.missingDetail') : st.detail}
</p>{/if}
<div class="detail-meta">
{#each spec as s}<span>{s.label} · {s.value}</span
>{/each}{#if endpoint}<span>{endpoint}</span>{/if}
</div>
{#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}
</Modal>
{#if lightbox && output && !video}<ImageLightbox
src={assetUrl(output) ?? ''}
alt={prompt || '创作结果'}
onClose={() => (lightbox = false)}
/>{/if}
<style>
.artboard {
display: grid;
grid-template-rows: 38px minmax(0, 1fr) 32px;
height: 100%;
min-height: 0;
border: 1px solid #ffffff0d;
border-radius: 12px;
background: radial-gradient(ellipse at 50% 20%, #202028, #131316 70%);
overflow: hidden;
}
.artboard-toolbar,
.artboard-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 12px;
min-width: 0;
}
.result-title {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
font-size: 11px;
color: var(--text-2);
}
.result-title small {
color: var(--text-3);
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.canvas-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.canvas-btn {
background: #ffffff04;
color: var(--text-2);
border: 1px solid #ffffff0a;
border-radius: 6px;
padding: 5px 8px;
font-size: 10px;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.canvas-btn:hover {
color: var(--text);
background: #ffffff0a;
}
.canvas-btn[aria-pressed='true'],
.play-button {
color: var(--accent);
border-color: var(--accent-line);
}
.stage {
min-height: 0;
min-width: 0;
position: relative;
overflow: hidden;
}
.stage-media {
position: absolute;
inset: 0;
padding: 8px 20px;
display: grid;
place-items: center;
min-height: 0;
}
.print {
position: relative;
display: grid;
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
border: 0;
padding: 0;
background: none;
box-shadow: none;
}
.print img,
.print video {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
min-height: 0;
min-width: 0;
object-fit: contain;
display: block;
}
.print :global(.a-fix) {
display: none;
}
.stage-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
font-size: 14px;
color: var(--text-2);
}
.stage-empty .sub {
color: var(--text-3);
font-size: 11px;
text-align: center;
max-width: 480px;
overflow-wrap: anywhere;
}
.stage-bar {
width: 200px;
}
.comparison-layer {
position: absolute;
inset: 0;
background: #17171d;
pointer-events: none;
}
.comparison-divider {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--accent);
pointer-events: none;
}
.artboard-footer {
justify-content: flex-start;
font-size: 10px;
color: var(--text-3);
}
.result-spec {
margin-left: auto;
}
.compare-slider {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
}
.compare-slider input {
width: 120px;
accent-color: var(--accent);
}
.output-picker {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 4px;
max-width: 90%;
overflow: auto;
background: #101015d9;
border: 1px solid var(--line);
padding: 4px;
border-radius: 8px;
}
.output-picker button {
background: none;
border: 0;
color: var(--text-3);
font-size: 10px;
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
}
.output-picker button[aria-pressed='true'] {
background: var(--accent-soft);
color: var(--accent);
}
.detail-actions,
.detail-meta,
.inputs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.detail-meta {
color: var(--text-3);
font-size: 11px;
}
.section-title {
font-size: 12px;
color: var(--text-3);
margin: 20px 0 10px;
}
.mini {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 8px;
background: var(--surface);
}
.mini.glyph {
display: grid;
place-items: center;
}
.prompt {
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 16px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg-2);
max-height: 260px;
overflow: auto;
}
.kv {
display: grid;
grid-template-columns: 90px minmax(0, 1fr);
font-size: 12px;
gap: 8px;
}
.kv dt {
color: var(--text-3);
}
.kv dd {
margin: 0;
overflow-wrap: anywhere;
}
.error-note {
color: var(--warn);
}
@media (max-width: 1100px) {
.result-title small {
display: none;
}
.stage-media {
padding: 8px 12px;
}
}
</style>
@@ -0,0 +1,440 @@
<script lang="ts">
import { onMount } from 'svelte';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import { workflows, apiErrorMessage } from '$lib/api';
import type { WorkflowMeta } from '$lib/comfy/types';
import { usageLabel } from '$lib/components/generate/schema';
import { spotlight } from '$lib/actions/spotlight';
import { toast } from '$lib/toast';
let {
kind,
metas,
selectedId,
pendingCount,
onPick,
onKind,
onDetails,
disabled = false,
}: {
disabled?: boolean;
kind: 'image' | 'video';
metas: WorkflowMeta[];
selectedId: number | null;
pendingCount: number;
onPick: (id: number) => void;
onKind: (kind: 'image' | 'video') => void;
onDetails: () => void;
} = $props();
const client = useQueryClient();
let search = $state(''),
usage = $state('all'),
family = $state('all'),
favOnly = $state(false),
limit = $state(48),
pins = $state<number[]>([]);
onMount(() => {
try {
pins = JSON.parse(
localStorage.getItem('calliope.reference.workflowPins') || '[]',
);
} catch {
pins = [];
}
});
const families = $derived([
...new Set(metas.map((m) => m.model_family).filter(Boolean)),
]);
const usages = $derived([
...new Set(metas.map((m) => m.usage).filter(Boolean)),
]);
const filtered = $derived(
metas
.filter(
(m) =>
(!favOnly || m.favorite) &&
(usage === 'all' || m.usage === usage) &&
(family === 'all' || m.model_family === family) &&
[m.name, m.description, m.model_family, ...(m.tags ?? [])]
.join(' ')
.toLowerCase()
.includes(search.trim().toLowerCase()),
)
.sort(
(a, b) =>
Number(pins.includes(b.id)) - Number(pins.includes(a.id)) ||
Number(b.favorite) - Number(a.favorite) ||
a.name.localeCompare(b.name, 'zh'),
),
);
$effect(() => {
void search;
void usage;
void family;
void favOnly;
limit = 48;
});
$effect(() => {
const index = filtered.findIndex((m) => m.id === selectedId);
if (index >= limit) limit = index + 24;
});
function pin(id: number) {
pins = pins.includes(id) ? pins.filter((p) => p !== id) : [...pins, id];
localStorage.setItem(
'calliope.reference.workflowPins',
JSON.stringify(pins),
);
}
const favorite = createMutation({
mutationFn: (m: WorkflowMeta) =>
workflows.update(m.id, { favorite: !m.favorite }),
onSuccess: () => client.invalidateQueries({ queryKey: ['workflows-meta'] }),
onError: (e) => toast.error(apiErrorMessage(e)),
});
</script>
<aside class="workflow-rail" aria-label="创作工作流列表">
<header>
<div><strong>创作台</strong><small>选择工作流,开始创作</small></div>
</header>
<div class="rail-tools">
<div class="kind-tabs" role="tablist" aria-label="创作类型">
<button
role="tab"
{disabled}
aria-selected={kind === 'image'}
onclick={() => onKind('image')}>图片</button
><button
role="tab"
{disabled}
aria-selected={kind === 'video'}
onclick={() => onKind('video')}>视频</button
>
</div>
<input
type="search"
bind:value={search}
aria-label="搜索工作流"
placeholder="搜索工作流或用途…"
/>
<div class="filters">
<select aria-label="筛选工作流用途" bind:value={usage}
><option value="all">所有用途</option>{#each usages as u}<option
value={u!}>{usageLabel(u!)}</option
>{/each}</select
><select aria-label="筛选模型家族" bind:value={family}
><option value="all">全部模型</option>{#each families as f}<option
value={f!}>{f}</option
>{/each}</select
>
</div>
</div>
<div class="list-meta">
<span>{filtered.length} 个可用工作流</span><button
aria-pressed={favOnly}
onclick={() => (favOnly = !favOnly)}>{favOnly ? '★' : '☆'} 收藏</button
>
</div>
<div class="rail-list">
{#each filtered.slice(0, limit) as m (m.id)}<div
class="workflow-row reference-option"
use:spotlight
class:selected={selectedId === m.id}
>
<button
class="workflow-pick"
{disabled}
onclick={() => onPick(m.id)}
aria-pressed={selectedId === m.id}
title={m.name}
><i class:video={kind === 'video'}>{kind === 'image' ? '◈' : '▷'}</i
><span
><b>{m.name}</b><small
>{m.description ||
(m.usage ? usageLabel(m.usage) : '本地 ComfyUI')}</small
></span
></button
>
<div class="row-actions">
<button
class:pressed={pins.includes(m.id)}
aria-pressed={pins.includes(m.id)}
aria-label={(pins.includes(m.id) ? '取消置顶' : '置顶') + m.name}
onclick={() => pin(m.id)}>⌖</button
><button
aria-pressed={!!m.favorite}
aria-label={(m.favorite ? '取消收藏' : '收藏') + m.name}
disabled={$favorite.isPending}
onclick={() => $favorite.mutate(m)}>{m.favorite ? '★' : '☆'}</button
>
</div>
</div>{/each}
{#if !filtered.length}<p class="empty">
没有符合条件的工作流。可以清空搜索或更改筛选。
</p>{/if}
{#if filtered.length > limit}<button
class="more"
onclick={() => (limit += 48)}
>再显示 {Math.min(filtered.length - limit, 48)} 个</button
>{/if}
</div>
<footer>
{#if pendingCount}<a href="/workflows?manage=1"
>{pendingCount} 个工作流待配置 →</a
>{/if}<button class="manage" onclick={onDetails}
><span>+</span>
<div>工作流说明与管理<small>输入要求 / 导入 / 分类</small></div>
<b>›</b></button
><a href="/models">云端模型与接口 ↗</a>
</footer>
</aside>
<style>
.workflow-rail {
width: 232px;
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
background: #121216;
border-right: 1px solid var(--line);
}
.workflow-rail > header {
padding: 21px 18px 16px;
border-bottom: 1px solid var(--line);
}
header strong {
font-size: 18px;
font-weight: 600;
display: block;
}
header small {
display: block;
font-size: 11px;
color: var(--text-3);
margin-top: 6px;
}
.rail-tools {
padding: 14px 12px 7px;
}
.kind-tabs {
display: flex;
gap: 3px;
background: #09090c;
border: 1px solid var(--line);
border-radius: 9px;
padding: 3px;
}
.kind-tabs button {
flex: 1;
border: 0;
padding: 7px 3px;
background: none;
color: var(--text-3);
font-size: 12px;
border-radius: 6px;
cursor: pointer;
}
.kind-tabs [aria-selected='true'] {
color: #062f35;
background: var(--accent);
font-weight: 600;
}
.rail-tools input {
width: 100%;
margin-top: 10px;
font-size: 11px;
padding: 9px 10px;
background: #1d1b16;
border: 1px solid #f5b04228;
border-radius: 8px;
color: var(--text);
}
.filters {
display: flex;
gap: 5px;
margin-top: 8px;
}
.filters select {
width: 50%;
min-width: 0;
border: 1px solid var(--line);
background: var(--bg-3);
border-radius: 5px;
padding: 5px;
font-size: 10px;
color: var(--text-3);
}
.list-meta {
display: flex;
justify-content: space-between;
padding: 8px 16px;
font-size: 10px;
color: var(--text-3);
}
.list-meta button {
border: 0;
background: none;
color: var(--text-3);
font-size: 10px;
cursor: pointer;
}
.list-meta button[aria-pressed='true'] {
color: var(--warn);
}
.rail-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 0 8px 8px;
scrollbar-gutter: stable;
}
.workflow-row {
border: 1px solid transparent;
position: relative;
display: flex;
align-items: center;
border-radius: 10px;
margin: 2px 0;
}
.workflow-row.selected {
border-color: #00cae039;
background: linear-gradient(120deg, #00cae016, #00cae003);
}
.workflow-row.selected::before {
content: '';
width: 3px;
height: 22px;
position: absolute;
left: 0;
background: var(--accent);
border-radius: 3px;
}
.workflow-pick {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 8px;
min-height: 55px;
width: 100%;
min-width: 0;
text-align: left;
border: 0;
background: none;
color: var(--text);
cursor: pointer;
}
.workflow-pick i {
display: grid;
place-items: center;
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 9px;
border: 1px solid #00cae01f;
color: var(--accent);
background: #00cae008;
font-size: 16px;
font-style: normal;
}
.workflow-pick > span {
min-width: 0;
flex: 1;
}
.workflow-pick b {
display: block;
font-size: 12px;
font-weight: 550;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.workflow-pick small {
display: block;
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-3);
}
.row-actions {
display: flex;
gap: 2px;
padding-right: 4px;
flex-shrink: 0;
}
.row-actions button {
opacity: 0;
padding: 2px;
height: 24px;
width: 20px;
border: 0;
color: var(--text-3);
background: none;
font-size: 12px;
cursor: pointer;
}
.row-actions button[aria-pressed='true'] {
color: var(--warn);
opacity: 1;
}
.workflow-row:hover .row-actions button,
.workflow-row:focus-within .row-actions button {
opacity: 1;
}
.workflow-row:hover {
background-color: #ffffff05;
}
.empty {
color: var(--text-3);
font-size: 11px;
padding: 12px;
}
.more {
width: 100%;
padding: 8px;
background: none;
border: 1px solid var(--line);
color: var(--text-3);
border-radius: 6px;
font-size: 11px;
cursor: pointer;
}
footer {
padding: 12px;
display: grid;
gap: 6px;
border-top: 1px solid var(--line);
}
footer a,
.manage {
font-size: 11px;
color: var(--text-3);
text-decoration: none;
}
.manage {
text-align: left;
display: flex;
align-items: center;
gap: 7px;
background: none;
border: 0;
padding: 7px 8px;
cursor: pointer;
}
.manage small {
display: none;
}
.manage b {
margin-left: auto;
}
button:disabled {
opacity: 0.5;
cursor: wait;
}
@media (max-width: 1100px) {
.workflow-rail {
width: 210px;
}
}
</style>
@@ -0,0 +1,79 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import type { WorkflowMediaIO } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
/**
* 输入要求徽标 (HEL-683):一个工作流真实声明 的进出媒介,
* 来自 S2 元数据——图标+文字双编码,只报真实接线,不虚构输入。
*/
interface Props {
io: WorkflowMediaIO | null | undefined;
/** Compact form for row summaries (no out side when empty-in). */
compact?: boolean;
}
let { io, compact = false }: Props = $props();
type Medium = 'image' | 'video' | 'audio';
const LABELS: Record<Medium, string> = {
image: 'wl.mediaImage',
video: 'wl.mediaVideo',
audio: 'wl.mediaAudio',
};
const inList = $derived(((io?.in ?? []) as Medium[]).filter((m) => m in LABELS));
const outList = $derived(((io?.out ?? []) as Medium[]).filter((m) => m in LABELS));
</script>
{#if inList.length || outList.length}
<span class="req" aria-label={t('desk.bayReqAria')}>
{#each inList as m (m)}
<span class="pill in" title="{t(LABELS[m])} · {t('wl.mediaIn')}">
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
</span>
{/each}
{#if inList.length && outList.length}<span class="arrow">→</span>{/if}
{#each outList as m (m)}
<span class="pill out" title="{t(LABELS[m])} · {t('wl.mediaOut')}">
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
</span>
{/each}
</span>
{:else if !compact}
<span class="pill none" title={t('desk.promptOnly')}><span class="lbl">{t('desk.promptOnly')}</span></span>
{/if}
<style>
.req {
display: inline-flex;
align-items: center;
gap: 3px;
flex-wrap: wrap;
}
.pill {
display: inline-flex;
align-items: center;
gap: 3px;
height: 18px;
padding: 0 5px;
border-radius: var(--r-sm);
background: var(--bg-4);
color: var(--text-2);
font-size: var(--fs-mini);
white-space: nowrap;
}
.pill.in {
color: var(--text);
}
.pill.none .lbl {
color: var(--text-3);
}
.arrow {
color: var(--text-3);
font-size: var(--fs-mini);
}
</style>
@@ -0,0 +1,124 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
/**
* 挑选台筛选行 (HEL-683):搜索框 + 本机/云端来源分段 + 仅收藏开关。
* 云端分段是诚实的占位——云端生成能力尚未接入,切过去只看到说明。
*/
interface Props {
search: string;
source: 'local' | 'cloud';
favOnly: boolean;
resultHint: string;
searchEl?: HTMLInputElement | null;
onSetSearch: (v: string) => void;
onSetSource: (s: 'local' | 'cloud') => void;
onToggleFav: () => void;
}
let {
search,
source,
favOnly,
resultHint,
searchEl = $bindable(null),
onSetSearch,
onSetSource,
onToggleFav,
}: Props = $props();
</script>
<div class="filters">
<div class="search">
<Icon name="search" size={14} />
<input
bind:this={searchEl}
class="search-input"
type="search"
value={search}
placeholder={t('desk.baySearchPh')}
aria-label={t('desk.baySearchPh')}
aria-keyshortcuts="/"
oninput={(e) => onSetSearch(e.currentTarget.value)}
/>
</div>
<div class="a-seg source" role="group" aria-label={t('desk.baySourceLabel')}>
<button type="button" aria-pressed={source === 'local'} onclick={() => onSetSource('local')}>
{t('desk.baySourceLocal')}
</button>
<button type="button" aria-pressed={source === 'cloud'} onclick={() => onSetSource('cloud')}>
{t('desk.baySourceCloud')}
</button>
</div>
<button
type="button"
class="a-chip fav"
class:on={favOnly}
aria-pressed={favOnly}
title={t('desk.bayFavOnly')}
onclick={onToggleFav}
>
<Icon name="star" size={13} />
<span class="lbl">{t('desk.bayFavOnly')}</span>
</button>
<span class="hint">{resultHint}</span>
</div>
<style>
.filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
.search {
display: flex;
align-items: center;
gap: var(--sp-3);
flex: 1 1 180px;
min-width: 0;
height: 32px;
box-sizing: border-box;
padding: 0 var(--sp-3);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-2);
color: var(--text-3);
transition: border-color var(--d-state) ease;
}
.search:focus-within {
border-color: var(--accent-line);
}
.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);
}
.source {
flex-shrink: 0;
}
.fav.on {
color: var(--accent);
background: var(--accent-soft);
}
.hint {
margin-left: auto;
color: var(--text-3);
font-size: var(--fs-mini);
white-space: nowrap;
}
@media (max-width: 760px) {
.hint {
display: none;
}
}
</style>
@@ -0,0 +1,170 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import type { WorkflowMeta } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from '$lib/components/generate/schema';
import InputRequirementBadge from './InputRequirementBadge.svelte';
/**
* 挑选台一行 (HEL-683):显示名 + 输入要求徽标 + 用途标签 + 端点/收藏
* 标注。当前工作流行带取景框角线;键盘/指针悬停行只是背景高亮。
*/
interface Props {
meta: WorkflowMeta;
current: boolean;
active: boolean;
endpoints: Array<{ id: string; name: string }>;
onPick: () => void;
onActive: () => void;
}
let { meta, current, active, endpoints, onPick, onActive }: Props = $props();
const endpointName = $derived(
meta.endpoint_id
? (endpoints.find((e) => e.id === meta.endpoint_id)?.name ?? t('desk.wfDefaultConn'))
: t('desk.wfDefaultConn'),
);
</script>
<button
type="button"
class="row"
class:active
class:current
role="option"
aria-selected={active ? 'true' : undefined}
aria-current={current ? 'true' : undefined}
onclick={onPick}
onpointermove={onActive}
onfocus={onActive}
>
<span class="main">
<span class="name">
{#if meta.favorite}<span class="fav"><Icon name="star" size={11} /></span>{/if}
<span class="name-txt">{meta.name}</span>
{#if current}<span class="cur-mark">{t('desk.wfCurrent')}</span>{/if}
</span>
<span class="sub">
<span class="a-tag">{usageLabel(meta.usage!)}</span>
<InputRequirementBadge io={meta.media_io} compact />
</span>
</span>
<span class="side">
<span class="conn" title="{t('studio.connLabel')}:{endpointName}">{endpointName}</span>
</span>
</button>
<style>
.row {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
box-sizing: border-box;
padding: var(--sp-3) var(--sp-4);
border: 0;
border-radius: var(--r-md);
background: none;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
/* 长列表性能:视口外的行不参与布局/绘制。 */
content-visibility: auto;
contain-intrinsic-size: auto 52px;
transition: background-color var(--d-state) ease;
}
.row:hover,
.row.active {
background: var(--bg-3);
}
.row:focus-visible {
outline: none;
background: var(--bg-3);
box-shadow: 0 0 0 1px var(--accent-line);
}
.main {
display: grid;
gap: 3px;
flex: 1;
min-width: 0;
}
.name {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
}
.name-txt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.fav {
display: inline-flex;
flex-shrink: 0;
color: var(--accent);
}
.cur-mark {
flex-shrink: 0;
color: var(--text-3);
font-size: var(--fs-mini);
}
.sub {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-1);
}
.side {
flex-shrink: 0;
}
.conn {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-3);
font-size: var(--fs-mini);
}
/* 当前工作流:取景框角线(HEL-680 的内容级选中语言)。 */
.row.current {
position: relative;
}
.row.current::after {
content: '';
position: absolute;
inset: 2px;
pointer-events: none;
background-image:
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0);
background-position:
left top,
left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
background-size:
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px);
background-repeat: no-repeat;
}
</style>
@@ -0,0 +1,150 @@
<script lang="ts">
import { assetUrl } from '$lib/api';
import type { Job } from '$lib/comfy/types';
import { jobStatusInfo } from '$lib/jobStatus';
import { parseDbTs } from '$lib/dbtime';
import { missingOutputsOf, markOutputMissing } from '$lib/missingOutputs.svelte';
let {
jobs,
selectedId,
onSelect,
onHistory,
nameOf,
}: {
jobs: Job[];
selectedId: number | null;
onSelect: (job: Job) => void;
onHistory: () => void;
nameOf: (job: Job) => string;
} = $props();
const sorted = $derived(
[...jobs].sort((a, b) => parseDbTs(b.created_at) - parseDbTs(a.created_at)),
);
</script>
<section class="result-rail" aria-label="生成结果记录">
<header>
<strong>生成记录 <small>{jobs.length}</small></strong><button
class="a-btn ghost sm"
onclick={onHistory}>全部记录与任务 ↗</button
>
</header>
<div class="rail-items">
{#each sorted as job (job.id)}
{@const path = job.output_paths.find((p) => !missingOutputsOf(job).includes(p))}
{@const status = jobStatusInfo(job)}
<button
class="result-item"
class:selected={job.id === selectedId}
aria-pressed={job.id === selectedId}
aria-label="查看第 {job.id} 次生成,{status.label}"
title="{nameOf(job)} · {job.created_at} · {status.label}"
onclick={() => onSelect(job)}
>
<span class="thumb"
>{#if path && job.kind === 'image'}<img
src={assetUrl(path)}
alt=""
loading="lazy"
onerror={() => markOutputMissing(path)}
/>{:else}<span>{job.kind === 'video' ? '▶' : '✳'}</span>{/if}</span
>
<span class="record-label">{nameOf(job)}</span>
<span class="record-label"
>第 {job.id} 次 · {path
? status.label
: job.status === 'done'
? '文件缺失'
: status.label}</span
>
</button>
{:else}<p>生成后,结果会保存在这里。</p>{/each}
</div>
</section>
<style>
.result-rail {
border-top: 1px solid var(--line);
padding-top: 8px;
min-width: 0;
flex-shrink: 0;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
gap: 10px;
}
strong {
font-size: 12px;
}
small {
color: var(--text-3);
margin-left: 5px;
font-weight: 400;
}
.rail-items {
display: flex;
gap: 8px;
overflow-x: auto;
overscroll-behavior: contain;
padding-bottom: 5px;
}
.result-item {
width: 122px;
flex-shrink: 0;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
padding: 3px;
background: var(--surface);
cursor: pointer;
color: var(--text);
}
.result-item.selected {
border: 2px solid var(--accent);
padding: 2px;
background: var(--accent-soft);
}
.thumb {
display: grid;
place-items: center;
height: 48px;
background: var(--bg);
border-radius: 5px;
color: var(--accent);
font-size: 20px;
}
img {
width: 100%;
height: 48px;
object-fit: cover;
border-radius: 5px;
}
.record-label {
display: block;
font-size: 10px;
padding: 5px 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
p {
font-size: 12px;
color: var(--text-3);
margin: 4px 0;
}
@media (max-height: 650px) {
.rail-items {
display: none;
}
.thumb,
img {
height: 32px;
}
.result-item {
width: 110px;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,310 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { USAGE_BY_KIND, type WorkflowMeta, type WorkflowUsage } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from '$lib/components/generate/schema';
/**
* 左栏任务模式 (HEL-683):该入口可用的任务模式(按真实工作流统计),
* 未覆盖的模式置灰;底部为收藏与最近使用。窄屏退化为横向模式条。
*/
interface Props {
kind: 'image' | 'video';
metas: WorkflowMeta[];
active: WorkflowUsage | 'all';
onSelect: (mode: WorkflowUsage | 'all') => void;
onPickWorkflow: (id: number) => void;
}
let { kind, metas, active, onSelect, onPickWorkflow }: Props = $props();
interface ModeEntry {
usage: WorkflowUsage;
count: number;
}
const modes = $derived.by(() => {
const counts = new Map<WorkflowUsage, number>();
for (const m of metas) {
if (!m.usage) continue;
counts.set(m.usage, (counts.get(m.usage) ?? 0) + 1);
}
return USAGE_BY_KIND[kind].map((u) => ({ usage: u, count: counts.get(u) ?? 0 }));
});
const favorites = $derived(
metas
.filter((m) => m.favorite)
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
.slice(0, 5),
);
const recents = $derived(
metas
.filter((m) => m.last_used_at && !m.favorite)
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
.slice(0, 5),
);
</script>
<nav class="rail" aria-label={t('desk.modeRailAria')}>
<p class="rail-label">{t('desk.modeRailTitle')}</p>
<div class="modes" role="group" aria-label={t('desk.modeRailTitle')}>
<button
type="button"
class="mode"
class:on={active === 'all'}
aria-pressed={active === 'all'}
onclick={() => onSelect('all')}
>
<span class="mode-lbl">{t('desk.modeAll')}</span>
<span class="mode-n">{metas.length}</span>
</button>
{#each modes as m (m.usage)}
<button
type="button"
class="mode"
class:on={active === m.usage}
class:off={m.count === 0}
disabled={m.count === 0}
aria-pressed={active === m.usage}
title={m.count === 0 ? t('desk.modeEmpty') : usageLabel(m.usage)}
onclick={() => onSelect(m.usage)}
>
<span class="mode-lbl">{usageLabel(m.usage)}</span>
<span class="mode-n">{m.count}</span>
</button>
{/each}
</div>
<div class="lists">
{#if recents.length}
<section class="rail-sec">
<p class="rail-label">
<Icon name="clock" size={12} />
{t('desk.railRecent')}
</p>
<ul class="pick-list">
{#each recents as m (m.id)}
<li>
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
<span class="pick-name">{m.name}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
{#if favorites.length}
<section class="rail-sec">
<p class="rail-label">
<Icon name="star" size={12} />
{t('desk.railFav')}
</p>
<ul class="pick-list">
{#each favorites as m (m.id)}
<li>
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
<span class="pick-name">{m.name}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
</nav>
<style>
.rail {
display: flex;
flex-direction: column;
min-height: 0;
box-sizing: border-box;
padding: var(--sp-5) var(--sp-3);
border-right: 1px solid var(--line);
overflow-y: auto;
}
.rail-label {
display: flex;
align-items: center;
gap: var(--sp-1);
margin: 0 0 var(--sp-2);
padding: 0 var(--sp-3);
color: var(--text-3);
font-size: var(--fs-mini);
letter-spacing: 0.05em;
white-space: nowrap;
}
.modes {
display: flex;
flex-direction: column;
gap: 2px;
}
.mode {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
box-sizing: border-box;
width: 100%;
padding: var(--sp-3) var(--sp-4);
border: 0;
border-radius: var(--r-md);
background: none;
color: var(--text-2);
font: inherit;
font-size: var(--fs-body);
text-align: left;
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.mode:hover:not(:disabled) {
background: var(--bg-3);
color: var(--text);
}
.mode:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-line);
}
.mode.on {
background: var(--bg-3);
color: var(--text);
}
/* 取景框角线只标当前模式(HEL-680 内容级选中语言)。 */
.mode.on {
position: relative;
}
.mode.on::after {
content: '';
position: absolute;
inset: 2px;
pointer-events: none;
background-image:
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0);
background-position:
left top,
left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
background-size:
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px);
background-repeat: no-repeat;
}
.mode.off {
color: var(--text-3);
opacity: 0.55;
cursor: not-allowed;
}
.mode-n {
color: var(--text-3);
font-size: var(--fs-mini);
font-variant-numeric: tabular-nums;
}
.lists {
display: flex;
flex-direction: column;
gap: var(--sp-4);
margin-top: var(--sp-5);
padding-top: var(--sp-4);
border-top: 1px solid var(--line);
}
.rail-sec {
min-width: 0;
}
.pick-list {
display: flex;
flex-direction: column;
gap: 1px;
margin: 0;
padding: 0;
list-style: none;
}
.pick {
display: block;
width: 100%;
box-sizing: border-box;
padding: var(--sp-2) var(--sp-4);
border: 0;
border-radius: var(--r-sm);
background: none;
color: var(--text-2);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.pick:hover {
background: var(--bg-3);
color: var(--text);
}
.pick:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-line);
}
.pick-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 窄屏:任务模式退化为横向条,收藏/最近隐藏(挑选台里有同样入口)。 */
@media (max-width: 760px) {
.rail {
flex-direction: row;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-4);
border-right: 0;
border-bottom: 1px solid var(--line);
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
}
.rail-label {
margin: 0;
padding: 0;
flex-shrink: 0;
}
.modes {
flex-direction: row;
gap: var(--sp-1);
}
.mode {
width: auto;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--line);
font-size: var(--fs-aux);
white-space: nowrap;
}
.mode.on::after {
inset: 1px;
}
.lists {
display: none;
}
}
</style>
@@ -0,0 +1,549 @@
<script lang="ts">
import { tick } from 'svelte';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { workflows, settings, apiErrorMessage } from '$lib/api';
import type { WorkflowMeta } from '$lib/comfy/types';
import { usageLabel } from '$lib/components/generate/schema';
import InputRequirementBadge from './InputRequirementBadge.svelte';
import { toast } from '$lib/toast';
interface Props {
open: boolean;
metas: WorkflowMeta[];
pendingCount: number;
selectedId: number | null;
activeId: number | null;
endpoints: Array<{ id: string; name: string }>;
onPick: (id: number) => void;
onClose: () => void;
catalogue?: boolean;
localOnly?: boolean;
onPreview?: (id: number) => void;
}
let {
open,
metas,
pendingCount,
selectedId,
activeId = $bindable(null),
endpoints,
onPick,
onClose,
catalogue = false,
localOnly = false,
onPreview,
}: Props = $props();
const client = useQueryClient();
const modelsQuery = createQuery({
queryKey: ['settings'],
queryFn: settings.get,
});
let search = $state(''),
category = $state('all'),
family = $state('all'),
source = $state('local'),
usage = $state('all'),
favOnly = $state(false),
recentOnly = $state(false),
visibleCount = $state(48);
let searchEl: HTMLInputElement;
let listEl: HTMLDivElement;
const labels: Record<string, string> = {
basic: '基础工具',
audio_tts: '语音',
music: '音乐',
image: '图片',
video: '视频',
digital_human: '数字人',
motion_transfer: '动作',
};
const categories = $derived([
...new Set(metas.map((m) => m.category).filter((k): k is string => !!k)),
]);
const families = $derived(
[...new Set(metas.map((m) => m.model_family).filter((k): k is string => !!k))].sort(),
);
const filtered = $derived(
metas
.filter(
(m) =>
(category === 'all' || m.category === category) &&
(usage === 'all' || m.usage === usage) &&
(family === 'all' || m.model_family === family) &&
(!favOnly || m.favorite) &&
(!recentOnly || m.last_used_at) &&
(!search.trim() ||
[
m.name,
m.description,
...(m.tags ?? []),
m.model_family,
m.usage ? usageLabel(m.usage) : '',
]
.join(' ')
.toLowerCase()
.includes(search.toLowerCase().trim())),
)
.sort((a, b) =>
recentOnly
? (b.last_used_at ?? '').localeCompare(a.last_used_at ?? '')
: Number(b.favorite) - Number(a.favorite) || a.name.localeCompare(b.name, 'zh'),
),
);
const visible = $derived(filtered.slice(0, visibleCount));
const cloudModels = $derived(
($modelsQuery.data?.llm_profiles ?? []).filter(
(p) => !search || [p.name, p.model].join(' ').toLowerCase().includes(search.toLowerCase()),
),
);
$effect(() => {
void search;
void category;
void family;
void usage;
void favOnly;
void recentOnly;
visibleCount = 48;
listEl?.scrollTo({ top: 0 });
});
$effect(() => {
if (!open) return;
const idx = filtered.findIndex((m) => m.id === selectedId);
if (idx >= visibleCount) visibleCount = idx + 48;
void tick().then(() => searchEl?.focus());
});
$effect(() => {
if (!filtered.some((m) => m.id === activeId)) activeId = filtered[0]?.id ?? null;
});
export function focusSearch() {
searchEl?.focus();
}
async function favorite(m: WorkflowMeta) {
try {
await workflows.update(m.id, { favorite: !m.favorite });
await client.invalidateQueries({ queryKey: ['workflows-meta'] });
} catch (e) {
toast.error(apiErrorMessage(e));
}
}
function pick(m: WorkflowMeta) {
if (onPreview) onPreview(m.id);
else if (m.is_enabled && !m.needs_mapping && m.usage) onPick(m.id);
}
function move(delta: number) {
const i = Math.max(
0,
Math.min(filtered.length - 1, filtered.findIndex((m) => m.id === activeId) + delta),
);
activeId = filtered[i]?.id ?? null;
if (i >= visibleCount) visibleCount = i + 24;
void tick().then(() =>
listEl?.querySelector(`[data-wf-row="${activeId}"]`)?.scrollIntoView({ block: 'nearest' }),
);
}
function key(e: KeyboardEvent) {
if (e.defaultPrevented) return;
if (e.key === 'Escape' && !catalogue) {
e.preventDefault();
onClose();
} else if (source !== 'local') {
return;
} else if (e.key === 'ArrowDown') {
e.preventDefault();
move(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
move(-1);
} else if (e.key === 'Enter' && e.target === searchEl) {
e.preventDefault();
const m = filtered.find((m) => m.id === activeId);
if (m) pick(m);
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="catalogue" inert={!open} onkeydown={key}>
<aside class="categories">
<p>按创作任务寻找</p>
<button
class:active={category === 'all' && !favOnly && !recentOnly}
onclick={() => {
category = 'all';
favOnly = false;
recentOnly = false;
}}><span>全部工具</span><small>{metas.length}</small></button
>
{#each categories as cat}<button
class:active={category === cat}
onclick={() => {
category = cat;
favOnly = false;
recentOnly = false;
}}
><span>{labels[cat] ?? cat}</span><small
>{metas.filter((m) => m.category === cat).length}</small
></button
>{/each}
<div class="divider"></div>
<button
class:active={favOnly}
onclick={() => {
favOnly = !favOnly;
recentOnly = false;
}}><span>☆ 我的收藏</span><small>{metas.filter((m) => m.favorite).length}</small></button
><button
class:active={recentOnly}
onclick={() => {
recentOnly = !recentOnly;
favOnly = false;
}}><span>◷ 最近使用</span></button
>
</aside>
<section class="catalogue-main">
<label class="search"
><span>⌕</span><input
type="search"
bind:this={searchEl}
bind:value={search}
placeholder="搜索工作流、模型或用途…"
aria-label="搜索工作流"
/></label
>
<div class="filters">
<button class:active={source === 'local'} onclick={() => (source = 'local')}
>本地 ComfyUI</button
>{#if !localOnly}<button class:active={source === 'cloud'} onclick={() => (source = 'cloud')}
>云端与智能体</button
>{/if}{#if source === 'local'}<select aria-label="筛选创作任务" bind:value={usage}
><option value="all">所有创作任务</option>{#each [...new Set(metas
.map((m) => m.usage)
.filter(Boolean))] as u}<option value={u!}>{usageLabel(u!)}</option>{/each}</select
><select aria-label="筛选模型家族" bind:value={family}
><option value="all">全部模型</option>{#each families as f}<option value={f}>{f}</option
>{/each}</select
>{/if}<span>{source === 'local' ? filtered.length : cloudModels.length} 个结果</span>
</div>
<div class="results" bind:this={listEl}>
{#if source === 'cloud'}
<div class="cloud-context">
<h3>让专业搭档加入创作。</h3>
<p>使用已配置模型,在智能体或画布中创作。自定义多媒体接口与技能可在设置中管理。</p>
<a href="/canvas" class="a-btn primary">打开创作画布 ↗</a>
</div>
{#each cloudModels as profile}<article class="workflow-card">
<div class="family">{profile.model}<span>已配置模型</span></div>
<h3>{profile.name}</h3>
<a href="/agents" class="tool-link">交给智能体创作 ↗</a>
</article>{:else}<p class="empty">
还没有匹配的模型。<a href="/models">配置模型 ↗</a>
</p>{/each}
{:else}
{#each visible as m (m.id)}<article
class="workflow-card"
class:selected={m.id === selectedId}
class:roving={m.id === activeId}
data-wf-row={m.id}
>
<button
class="workflow-choice"
onclick={() => pick(m)}
onfocus={() => (activeId = m.id)}
disabled={!onPreview && (!m.is_enabled || m.needs_mapping || !m.usage)}
aria-label="{onPreview ? '查看' : '使用'} {m.name}"
aria-current={m.id === selectedId ? 'true' : undefined}
>
<div class="family">
{m.model_family ?? 'COMFYUI'}<span>本地</span>
</div>
<h3 title={m.name}>{m.name}</h3>
{#if m.description}<p class="description">{m.description}</p>{/if}
<div class="card-meta">
<span>{m.usage ? usageLabel(m.usage) : '待映射'}</span><InputRequirementBadge
io={m.media_io}
compact
/>
</div>
</button>
<button
class="favorite"
class:saved={m.favorite}
aria-label="{m.favorite ? '取消收藏' : '收藏'} {m.name}"
aria-pressed={m.favorite}
onclick={() => favorite(m)}>{m.favorite ? '★' : '☆'}</button
>
{#if m.needs_mapping || !m.is_enabled || !m.usage}<a
href="/workflows?manage=1"
class="tool-link">{m.needs_mapping ? '完成映射' : '管理工作流'} ↗</a
>{/if}
</article>{:else}<p class="empty">没有找到匹配的工作流,试试更短的关键词。</p>{/each}
{#if visibleCount < filtered.length}<button
class="more a-btn secondary"
onclick={() => (visibleCount += 48)}
>再看 {Math.min(48, filtered.length - visibleCount)} 个工具</button
>{/if}
{/if}
</div>
{#if pendingCount > 0}<p class="pending">
另有 {pendingCount} 个工作流待映射。<a href="/workflows?manage=1">去整理 ↗</a>
</p>{/if}
</section>
</div>
<style>
.description {
font-size: 11px;
color: var(--text-3);
line-height: 1.5;
margin: 0 0 10px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (max-width: 1100px) {
.catalogue {
grid-template-columns: 112px minmax(0, 1fr);
}
.catalogue-main {
padding-left: 12px;
}
.results {
grid-template-columns: 1fr;
}
}
.catalogue {
display: grid;
grid-template-columns: 145px minmax(0, 1fr);
height: 100%;
min-height: 0;
}
.categories {
padding: 16px 15px 20px 0;
border-right: 1px solid var(--line);
overflow: auto;
}
.categories p {
font-size: 9px;
letter-spacing: 1px;
color: var(--text-3);
margin: 0 9px 15px;
}
.categories button {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border: 0;
background: none;
border-radius: 7px;
text-align: left;
font-size: 11px;
padding: 11px 10px;
color: var(--text-2);
cursor: pointer;
}
.categories button.active {
background: #e8eafe;
color: var(--accent);
}
.categories small {
font-size: 9px;
color: var(--text-3);
}
.divider {
border-top: 1px solid var(--line);
margin: 20px 8px 12px;
}
.catalogue-main {
padding: 16px 0 0 22px;
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.search {
display: flex;
gap: 9px;
align-items: center;
background: var(--bg);
border: 1px solid var(--line);
padding: 9px 13px;
border-radius: 8px;
}
.search input {
border: 0;
outline: none;
background: transparent;
width: 100%;
font-size: 12px;
color: var(--text);
}
.search:focus-within {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.filters {
display: flex;
gap: 7px;
align-items: center;
margin: 16px 0 21px;
flex-wrap: wrap;
}
.filters button,
.filters select {
font-size: 10px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 20px;
background: var(--surface);
color: var(--text-3);
cursor: pointer;
}
.filters button.active {
color: var(--surface);
background: var(--text);
border-color: var(--text);
}
.filters span {
font-size: 9px;
color: var(--text-3);
margin-left: auto;
}
.filters select {
max-width: 140px;
}
.results {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px;
overflow: auto;
min-height: 0;
align-content: start;
padding: 2px 5px 15px 2px;
}
.workflow-card {
border: 1px solid var(--line);
border-radius: 11px;
padding: 14px;
position: relative;
background: var(--surface);
min-width: 0;
content-visibility: auto;
contain-intrinsic-size: auto 132px;
}
.workflow-card.selected {
border-color: var(--accent);
background: #f1f3ff;
}
.workflow-choice {
display: block;
text-align: left;
width: 100%;
padding: 0 0 9px;
border: 0;
background: none;
color: var(--text);
cursor: pointer;
}
.workflow-choice:disabled {
opacity: 0.65;
cursor: default;
}
.family {
font: 9px var(--mono);
letter-spacing: 0.5px;
display: flex;
justify-content: space-between;
margin-bottom: 12px;
color: var(--text-3);
}
.family span {
font: 8px var(--font);
padding: 2px 5px;
border: 1px solid var(--line);
border-radius: 4px;
}
h3 {
font-size: 11px;
line-height: 1.7;
margin: 0 0 9px;
font-weight: 550;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.card-meta {
display: flex;
gap: 5px;
flex-wrap: wrap;
font-size: 9px;
color: var(--text-3);
padding-right: 14px;
}
.favorite {
position: absolute;
bottom: 10px;
right: 10px;
font-size: 18px;
border: 0;
background: none;
color: #838a78;
cursor: pointer;
}
.favorite.saved {
color: var(--accent);
}
.tool-link {
font-size: 10px;
color: var(--accent);
text-decoration: none;
}
.cloud-context {
grid-column: 1/-1;
background: #e9e7df;
padding: 25px;
border-radius: 12px;
}
.cloud-context h3 {
font-size: 19px;
}
.cloud-context p {
font-size: 11px;
color: var(--text-2);
line-height: 1.8;
}
.cloud-context .a-btn {
font-size: 11px;
}
.pending {
font-size: 10px;
color: var(--text-3);
padding-top: 12px;
border-top: 1px solid var(--line);
margin: 0;
}
.empty {
grid-column: 1/-1;
padding: 45px;
text-align: center;
font-size: 12px;
color: var(--text-3);
}
.more {
grid-column: 1/-1;
justify-self: center;
font-size: 11px;
}
@media (hover: hover) {
.workflow-card:hover {
border-color: var(--accent-line);
}
.categories button:hover {
background: var(--bg-3);
}
}
</style>
@@ -0,0 +1,247 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import type { Workflow, WorkflowMeta } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel, slotSummary } from '$lib/components/generate/schema';
import InputRequirementBadge from './InputRequirementBadge.svelte';
/**
* 输入要求预览 (HEL-683):挑选台打开时右侧的详情面板——展示键盘/指针
* 停留的工作流的名称、家族、说明与真实输入要求(S2 元数据 + 已加载的
* 完整记录槽位摘要),确认后进入创作区。
*/
interface Props {
meta: WorkflowMeta | null;
full: Workflow | null;
currentId: number | null;
endpoints: Array<{ id: string; name: string }>;
onUse: () => void;
onToggleFav: (fav: boolean) => void;
}
let { meta, full, currentId, endpoints, onUse, onToggleFav }: Props = $props();
const isCurrent = $derived(meta != null && meta.id === currentId);
const broken = $derived((full?.warnings ?? []).some((w) => w.level === 'error'));
const endpointName = $derived.by(() => {
const id = meta?.endpoint_id;
if (!id) return t('desk.wfDefaultConn');
return endpoints.find((e) => e.id === id)?.name ?? t('desk.wfDefaultConn');
});
const CATEGORY_LABELS: Record<string, string> = {
basic: 'wfCat.basic',
audio_tts: 'wfCat.audioTts',
music: 'wfCat.music',
image: 'wfCat.image',
video: 'wfCat.video',
digital_human: 'wfCat.digitalHuman',
motion_transfer: 'wfCat.motionTransfer',
};
</script>
<div class="preview">
{#if meta}
<div class="head">
<h3 class="name" title={meta.name}>{meta.name}</h3>
<button
type="button"
class="fav"
class:on={meta.favorite}
aria-pressed={meta.favorite}
aria-label={t('desk.bayFavOnly')}
title={meta.favorite ? t('desk.pvUnfav') : t('desk.pvFav')}
onclick={() => onToggleFav(!meta.favorite)}
>
<Icon name="star" size={16} />
</button>
</div>
<div class="tags">
{#if meta.usage}<span class="a-tag accent">{usageLabel(meta.usage)}</span>{/if}
{#if meta.model_family}<span class="a-tag">{meta.model_family}</span>{/if}
{#if meta.category && CATEGORY_LABELS[meta.category]}
<span class="a-tag">{t(CATEGORY_LABELS[meta.category])}</span>
{/if}
{#if isCurrent}<span class="a-tag ok">{t('desk.pvCurrent')}</span>{/if}
{#if broken}<span class="a-tag err">{t('desk.wfBroken')}</span>{/if}
</div>
{#if meta.description}
<p class="desc">{meta.description}</p>
{/if}
<section class="sec">
<h4 class="sec-title">{t('desk.bayReqTitle')}</h4>
<div class="req-line">
<InputRequirementBadge io={meta.media_io} />
</div>
{#if full}
<p class="slot-summary">{slotSummary(full)}</p>
{:else}
<p class="slot-summary dim">{t('desk.pvSlotsLoading')}</p>
{/if}
</section>
<dl class="kv">
<div class="kv-row">
<dt>{t('studio.connLabel')}</dt>
<dd>{endpointName}</dd>
</div>
{#if meta.last_used_at}
<div class="kv-row">
<dt>{t('desk.pvLastUsed')}</dt>
<dd>{new Date(meta.last_used_at).toLocaleString()}</dd>
</div>
{/if}
{#if (full?.input_schema ?? []).length}
<div class="kv-row">
<dt>{t('desk.pvParamCount')}</dt>
<dd>{(full?.input_schema ?? []).length}</dd>
</div>
{/if}
</dl>
<div class="use">
<button type="button" class="a-btn primary" disabled={broken} onclick={onUse}>
<Icon name="check" size={13} />
{isCurrent ? t('desk.pvBackCreate') : t('desk.pvUse')}
</button>
{#if broken}<p class="use-hint">{t('studio.errWfBroken')}</p>{/if}
</div>
{:else}
<div class="preview-empty">
<Icon name="search" size={20} />
<p>{t('desk.pvEmpty')}</p>
</div>
{/if}
</div>
<style>
.preview {
display: flex;
flex-direction: column;
gap: var(--sp-4);
flex: 1;
min-height: 0;
box-sizing: border-box;
height: 100%;
padding: var(--sp-7);
overflow-y: auto;
}
.head {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
}
.name {
flex: 1;
min-width: 0;
margin: 0;
color: var(--text);
font-size: var(--fs-title);
font-weight: 600;
line-height: 1.4;
overflow-wrap: anywhere;
}
.fav {
flex-shrink: 0;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: none;
color: var(--text-3);
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.fav.on {
color: var(--accent);
background: var(--accent-soft);
}
.fav:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-line);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
}
.desc {
margin: 0;
color: var(--text-2);
font-size: var(--fs-aux);
line-height: 1.65;
white-space: pre-line;
}
.sec {
display: grid;
gap: var(--sp-2);
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--bg-2);
}
.sec-title {
margin: 0;
color: var(--text-3);
font-size: var(--fs-mini);
letter-spacing: 0.05em;
}
.req-line {
display: flex;
}
.slot-summary {
margin: 0;
color: var(--text-2);
font-size: var(--fs-aux);
}
.slot-summary.dim {
color: var(--text-3);
}
.kv {
display: grid;
gap: var(--sp-1);
margin: 0;
}
.kv-row {
display: flex;
gap: var(--sp-3);
font-size: var(--fs-aux);
}
.kv-row dt {
flex-shrink: 0;
width: 72px;
color: var(--text-3);
}
.kv-row dd {
margin: 0;
color: var(--text-2);
min-width: 0;
overflow-wrap: anywhere;
}
.use {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: auto;
padding-top: var(--sp-4);
}
.use-hint {
margin: 0;
color: var(--err);
font-size: var(--fs-mini);
}
.preview-empty {
display: grid;
place-content: center;
justify-items: center;
gap: var(--sp-3);
height: 100%;
color: var(--text-3);
font-size: var(--fs-aux);
}
</style>
@@ -0,0 +1,722 @@
<script lang="ts">
import Modal from '$lib/components/ui/Modal.svelte';
import { tick } from 'svelte';
import { assetUrl } from '$lib/api';
import ParameterPopover from './ParameterPopover.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
import type { Snippet } from 'svelte';
import SlotTray from './SlotTray.svelte';
import {
type BlockReason,
type DeskSchema,
type FormValue,
type FormValues,
} from './schema';
/** Scrollable creative inputs with a separate, always visible submission footer. */
interface Props {
header?: Snippet;
dock?: boolean;
ds: DeskSchema;
values: FormValues;
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;
onOpenBay: () => void;
onPickEndpoint: (id: string) => void;
onSubmit: () => void;
onReveal: () => void;
onOpenParams: () => void;
onRestoreAll: () => void;
onFromResults: (nodeId: string) => void;
}
let {
header,
dock = false,
ds,
values,
current,
endpoints,
endpointId,
conn,
errors,
showErrors,
block,
changedCount,
submitting,
sent,
slotBusy = $bindable(false),
promptEl = $bindable(null),
onSet,
onOpenBay,
onPickEndpoint,
onSubmit,
onReveal,
onOpenParams,
onRestoreAll,
onFromResults,
}: Props = $props();
let mediaOpen = $state(false);
let popoverInputs = $state<ComfyDynamicInput[]>([]);
let popoverAnchor = $state<HTMLElement | null>(null);
export function openMedia() {
mediaOpen = true;
}
function openParameter(inp: ComfyDynamicInput, anchor: HTMLElement) {
popoverInputs =
inp === ds.width || inp === ds.height
? [ds.width, ds.height].filter((i): i is ComfyDynamicInput => !!i)
: inp === ds.ratio
? [ds.ratio, ds.megapixels].filter((i): i is ComfyDynamicInput => !!i)
: [inp];
popoverAnchor = anchor;
}
let tray = $state<ReturnType<typeof SlotTray> | null>(null);
let root = $state<HTMLElement | null>(null);
let dragDepth = $state(0);
const hasSlots = $derived(ds.slots.length + ds.lists.length > 0);
const commonInputs = $derived(
ds.megapixels && !ds.common.some((i) => i.nodeId === ds.megapixels?.nodeId)
? [...ds.common, ds.megapixels]
: ds.common,
);
const advancedInputs = $derived(
ds.advanced.filter((i) => !commonInputs.some((c) => c.nodeId === i.nodeId)),
);
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'),
);
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);
}
}
async function onDrop(e: DragEvent) {
e.preventDefault();
dragDepth = 0;
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) {
if (dock) {
mediaOpen = true;
await tick();
}
tray?.dropFiles(files);
}
}
function hasFiles(e: DragEvent): boolean {
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
}
</script>
<div class="composer-wrap" class:dock>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={root}
class="composer"
class:drag={dragDepth > 0}
ondragenter={(e) => {
if (hasFiles(e)) {
e.preventDefault();
dragDepth += 1;
}
}}
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
ondragover={(e) => hasFiles(e) && e.preventDefault()}
ondrop={onDrop}
>
<div class="composer-scroll" aria-label="创作输入">
{@render header?.()}
{#if current}
<section class="input-section prompt-section">
<h3>2 · 准备这次创作</h3>
{#if hasSlots && !dock}<div class="slots">
<SlotTray
bind:this={tray}
bind:busy={slotBusy}
{ds}
{values}
{showErrors}
{errors}
{onSet}
{onFromResults}
/>
</div>{/if}
{#if dock}<div class="media-summary">
{#if hasSlots}
{#each ds.slots.slice(0, 4) as slot}<button
class="media-token"
onclick={openMedia}
aria-label={'设置' + slot.label}
>
{#if values[slot.inp.nodeId] && slot.mediaKind === 'image'}<img
src={assetUrl(String(values[slot.inp.nodeId]))}
alt=""
/>{:else}<Icon name="plus" size={12} />{/if}
{slot.label}{slot.inp.required ? ' *' : ''}
</button>{/each}
{#if ds.lists.length}<button
class="media-token"
onclick={openMedia}>+ 参考材料</button
>{/if}
<button class="media-token" onclick={openMedia}
>{slotBusy ? '正在上传…' : '管理素材'}</button
>
{:else}<small>描述你的画面</small>{/if}
<small class="input-shortcut">Ctrl + Enter 生成</small>
</div>{/if}
<div class="prompt-pair" class:dual={dock && !!ds.negative}>
{#if ds.prompt}<div class="prompt-field">
<label for="desk-prompt"
>{ds.negative ? '正向提示词' : '提示词'}
{#if ds.prompt.required}<span>必填</span>{/if}</label
><textarea
bind:this={promptEl}
id="desk-prompt"
class="prompt"
class:invalid={!!promptErr}
rows="4"
value={promptVal}
placeholder="描述你想要的画面、动作和镜头…"
aria-label={t('studio.promptLabel')}
aria-invalid={!!promptErr || undefined}
oninput={(e) =>
onSet(ds.prompt!.nodeId, e.currentTarget.value)}
></textarea>
<div class="prompt-counter">
<span>Enter 换行 · Ctrl + Enter 生成</span><span
>{promptVal.length} 字</span
>
</div>
{#if promptErr}<p class="error" role="alert">
{promptErr}
</p>{/if}
</div>{:else}<p class="note">
这个工具不需要填写提示词,请准备它需要的素材。
</p>{/if}
{#if dock && ds.negative}<div class="prompt-field negative">
<label for="desk-negative"
>反向提示词 {#if ds.negative.required}<span>必填</span
>{/if}</label
><textarea
id="desk-negative"
class="prompt"
rows="4"
value={String(values[ds.negative.nodeId] ?? '')}
placeholder="填写希望避免的内容…"
aria-label="反向提示词"
aria-invalid={(showErrors && !!errors[ds.negative.nodeId]) ||
undefined}
oninput={(e) =>
onSet(ds.negative!.nodeId, e.currentTarget.value)}
></textarea>
<div class="prompt-counter">
<span>避免的内容</span><span
>{String(values[ds.negative.nodeId] ?? '').length} 字</span
>
</div>
{#if showErrors && errors[ds.negative.nodeId]}<p
class="error"
role="alert"
>
{errors[ds.negative.nodeId]}
</p>{/if}
</div>{/if}
</div>
</section>
{#if !dock && commonInputs.length}<section class="input-section">
<h3>画面与生成设置</h3>
<ComfyDynamicForm
inputs={commonInputs}
values={values as Record<string, string | number>}
quiet
{errors}
{showErrors}
onChange={(v) => patch(commonInputs, v)}
/>
</section>{/if}
{#if !dock && changedCount > 0}<button
class="a-btn ghost sm"
onclick={onRestoreAll}>恢复工具默认参数</button
>{/if}
{#if !dock && advancedInputs.length}<details class="advanced">
<summary>更多参数 · {advancedInputs.length} 项</summary>
<ComfyDynamicForm
inputs={advancedInputs}
values={values as Record<string, string | number>}
quiet
{errors}
{showErrors}
onChange={(v) => patch(advancedInputs, v)}
/>
</details>{/if}
{/if}
{#if !dock}<details
class="runtime"
open={conn.state === 'fail' || !endpointId}
>
<summary id="desk-conn">运行设置 · {endpointName}</summary>
<label for="comfy-endpoint">使用哪台 ComfyUI</label>
<select
id="comfy-endpoint"
class="a-select"
value={endpointId}
onchange={(e) => onPickEndpoint(e.currentTarget.value)}
>
{#if !endpoints.length}<option value="">尚未配置服务</option>{/if}
{#each endpoints as ep}<option value={ep.id}>{ep.name}</option
>{/each}
</select><a href="/connections" target="_blank" rel="noreferrer"
>配置或检查连接 ↗</a
>
</details>{/if}
</div>
<footer class="submit">
{#if dock}<div class="dock-chips">
<button id="desk-conn" class="parameter" onclick={onOpenParams}
>✦ {endpointName}⌄</button
>{#each commonInputs
.filter((i) => i !== ds.height)
.slice(0, 3) as inp}<button
class="parameter"
onclick={(e) => openParameter(inp, e.currentTarget)}
>{inp.label}: {String(
inp === ds.width && ds.height
? String(values[inp.nodeId]) +
' × ' +
String(values[ds.height.nodeId])
: (values[inp.nodeId] ?? inp.defaultValue ?? '—'),
)}⌄</button
>{/each}<button class="parameter" onclick={onOpenParams}
>创作设置⌄</button
>
</div>{/if}
{#if block}<button
type="button"
class="why"
class:err={block.tone === 'err'}
onclick={onReveal}>{block.text}</button
>
{:else}<p class="ready">素材已准备好,可以开始生成。</p>{/if}
<button
type="button"
class="a-btn primary gen"
disabled={!!block || submitting || slotBusy}
onclick={onSubmit}
>
<Icon name="sparkle" size={16} />{submitting
? '正在提交…'
: slotBusy
? '正在上传素材…'
: sent
? '已加入生成队列'
: '开始生成'}
</button>
</footer>
</div>
</div>
{#if dock}
<Modal
bind:open={mediaOpen}
size="lg"
title="本次创作素材"
dismissible={!slotBusy}
>
<div class="media-editor">
<SlotTray
bind:this={tray}
bind:busy={slotBusy}
{ds}
{values}
{showErrors}
{errors}
{onSet}
{onFromResults}
/>
</div>
{#snippet footer()}<button
class="a-btn secondary"
disabled={slotBusy}
onclick={() => (mediaOpen = false)}
>{slotBusy ? '正在上传…' : '完成'}</button
>{/snippet}
</Modal>
{/if}
<ParameterPopover
inputs={popoverInputs}
{values}
anchor={popoverAnchor}
onClose={() => {
popoverInputs = [];
popoverAnchor = null;
}}
onApply={(v) => {
for (const [id, value] of Object.entries(v)) onSet(id, value);
}}
/>
<style>
.composer-wrap,
.composer {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.composer-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 18px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.composer.drag {
outline: 2px dashed var(--accent);
outline-offset: -4px;
}
.input-section {
padding: 18px 0;
border-top: 1px solid var(--line);
}
h3 {
font-size: 13px;
margin: 0 0 14px;
font-weight: 650;
}
label {
display: block;
font-size: 12px;
margin-bottom: 8px;
color: var(--text-2);
}
label span {
font-size: 10px;
color: var(--text-3);
margin-left: 6px;
}
.prompt {
display: block;
width: 100%;
min-height: 100px;
max-height: 260px;
resize: vertical;
box-sizing: border-box;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
padding: 12px;
font: inherit;
font-size: 13px;
line-height: 1.6;
color: var(--text);
}
.prompt:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.slots {
margin-bottom: 16px;
}
.slots :global(.tray) {
padding: 0;
gap: 14px;
}
.slots :global(.note) {
padding: 8px 0 0;
}
.advanced,
.runtime {
border-top: 1px solid var(--line);
padding: 14px 0;
}
summary {
font-size: 12px;
cursor: pointer;
padding: 4px 0;
margin-bottom: 8px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.runtime .a-select {
width: 100%;
margin-bottom: 10px;
}
.runtime a {
font-size: 12px;
}
.submit {
flex-shrink: 0;
padding: 12px 18px 16px;
border-top: 1px solid var(--line);
background: var(--surface);
display: grid;
gap: 10px;
box-shadow: 0 -8px 20px #292a2d05;
}
.gen {
width: 100%;
min-height: 44px;
justify-content: center;
gap: 8px;
font-size: 14px;
}
.why {
padding: 0;
border: 0;
background: none;
text-align: left;
font: inherit;
font-size: 12px;
line-height: 1.5;
color: var(--warn);
cursor: pointer;
overflow-wrap: anywhere;
}
.why.err,
.error {
color: var(--err);
}
.ready,
.note,
.error {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-3);
}
.error {
color: var(--err);
}
.dock {
height: 100%;
min-height: 0;
}
.dock .composer {
height: 100%;
min-height: 0;
border: 1px solid #f5b04246;
border-radius: 16px;
background: linear-gradient(125deg, #1b1a1d, #17161a);
overflow: hidden;
box-shadow: inset 0 0 0 1px #f5b04206;
}
.dock .composer:focus-within {
border-color: #f5b04280;
}
.dock .composer-scroll {
padding: 9px 14px 4px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.dock .input-section {
padding: 0;
border: 0;
margin: 0;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.dock .input-section > h3 {
display: none;
}
.media-summary {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
margin-bottom: 6px;
flex-shrink: 0;
overflow: auto;
}
.media-summary > small {
color: var(--text-3);
font-size: 10px;
white-space: nowrap;
}
.media-token {
display: flex;
align-items: center;
gap: 5px;
background: #ffffff04;
border: 1px dashed #ffffff25;
border-radius: 6px;
padding: 3px 7px;
font-size: 10px;
color: var(--text-2);
cursor: pointer;
white-space: nowrap;
height: 26px;
}
.media-token img {
height: 20px;
width: 20px;
object-fit: cover;
border-radius: 3px;
}
.input-shortcut {
margin-left: auto;
}
.dock .prompt-pair {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-height: 0;
flex: 1;
gap: 18px;
}
.dock .prompt-pair.dual {
grid-template-columns: minmax(0, 1fr) minmax(0, 0.65fr);
}
.dock .prompt-field {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.dock .negative {
border-left: 1px solid var(--line);
padding-left: 18px;
}
.dock .prompt-field label {
font-size: 10px;
color: var(--text-3);
margin: 0 0 4px;
display: flex;
gap: 6px;
}
.dock .prompt-field:first-child label {
color: #d0b37d;
}
.dock .prompt {
background: none;
border: 0;
border-radius: 0;
padding: 0;
font-size: 12px;
line-height: 1.6;
resize: none;
min-height: 0;
max-height: none;
height: 100%;
flex: 1;
overflow: auto;
}
.dock .prompt:focus {
outline: 0;
}
.prompt-counter {
display: flex;
justify-content: space-between;
font-size: 9px;
color: var(--text-3);
margin-top: 3px;
}
.dock .prompt-counter {
display: none;
}
.dock .submit {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
padding: 7px 14px;
height: 45px;
box-sizing: border-box;
background: none;
border-top: 1px solid var(--line);
position: relative;
}
.dock-chips {
display: flex;
gap: 5px;
align-items: center;
flex: 1;
min-width: 0;
overflow: auto;
}
.parameter {
background: #25242a;
border: 1px solid #ffffff07;
border-radius: 7px;
padding: 5px 8px;
font-size: 10px;
color: #c4c2cb;
cursor: pointer;
white-space: nowrap;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
.parameter:hover {
border-color: #f5b04255;
}
.dock .gen {
width: auto;
min-height: 32px;
font-size: 11px;
padding: 7px 18px;
border-radius: 20px;
margin-left: auto;
flex-shrink: 0;
background: linear-gradient(140deg, #ffc65e, #f5b042);
color: #30210c;
border-color: #edb458;
box-shadow: 0 3px 12px #f5b04217;
}
.dock .why {
position: absolute;
bottom: calc(100% + 3px);
right: 14px;
max-width: 65%;
padding: 3px 8px;
border-radius: 5px;
background: #241e17;
font-size: 10px;
color: var(--warn);
z-index: 1;
}
.dock .why.err {
color: var(--err);
background: #241717;
}
.dock .ready {
display: none;
}
.dock .error {
font-size: 10px;
}
</style>
@@ -0,0 +1,74 @@
<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. With a full source identity (HEL-684) results can
* be flowed back per take from the result card; otherwise the old
* note stands — results stay in the desk feed, nothing is written back.
*/
interface Props {
ctx: DeskContext;
projectTitle: string;
onDismiss: () => void;
}
let { ctx, projectTitle, onDismiss }: Props = $props();
const stageName = $derived(t(`workbench.stage.${ctx.stage}`));
const canFlowBack = $derived(!!ctx.sourceKind && !!ctx.sourceId);
</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">
{canFlowBack ? t('desk.ctxBackfill', { stage: stageName }) : 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,888 @@
<script lang="ts">
import { untrack, type Snippet } from 'svelte';
import ImageLightbox from '$lib/components/ImageLightbox.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 即时无动画), video playback is always explicit,
* failed takes get 重试, and a done take whose files no longer load
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
*/
interface Props {
main?: boolean;
comparePath?: string | null;
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;
/** The stage <video> element, for explicit playback controls. */
videoEl?: HTMLVideoElement | null;
params: Snippet;
onClose: () => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onCancel: (job: Job) => void;
onSendToSlot: (path: string) => void;
onDelete: (job: Job) => void;
}
let {
main = false,
comparePath = null,
tab = $bindable(),
job,
wf,
nowMs,
open,
inactive,
canSendToSlot,
crossfade,
videoEl = $bindable(null),
params,
onClose,
onReuse,
onRetry,
onCancel,
onSendToSlot,
onDelete,
}: Props = $props();
let outIndex = $state(0);
let compare = $state(false),
compareAt = $state(50),
lightbox = $state(false);
$effect(() => {
void job?.id;
compare = false;
lightbox = false;
});
$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 compareSource = $derived(inputs.find((i) => i.kind === 'image')?.path ?? comparePath);
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}`;
}
let paused = $state(true);
$effect.pre(() => {
void output;
untrack(() => {
videoEl?.pause();
paused = true;
});
});
function togglePlayback() {
if (!videoEl) return;
if (videoEl.paused) videoEl.play().catch(() => {});
else videoEl.pause();
}
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:main 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">
<span class="empty-symbol">✳</span><strong>给想象留一个位置。</strong><span class="sub"
>在右侧写下描述,或选择一次已有创作。</span
>
</div>
</div>
{:else}
<div class="a-stage stage">
{#key mediaKey}
<div class="stage-media">
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<div class="print a-photo">
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
<video
bind:this={videoEl}
src={assetUrl(output)}
controls
aria-label="生成的视频,使用播放按钮开始播放"
onplay={() => (paused = false)}
onpause={() => (paused = true)}
onended={() => (paused = true)}
playsinline
preload="metadata"
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
</div>
{:else if output}
<div class="print a-photo">
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
<img
src={assetUrl(output)}
alt={prompt || t('desk.tabResult')}
onload={(e) => {
clearOutputMissing(output);
noteImg(e);
}}
onerror={() => output && markOutputMissing(output)}
/>
{#if main && compare && compareSource}<div
class="comparison-layer"
style:clip-path={`inset(0 ${100 - compareAt}% 0 0)`}
>
<img src={assetUrl(compareSource)} alt="对比参考画面" />
</div>
<div class="comparison-divider" style:left={`${compareAt}%`}></div>{/if}
</div>
{:else if fileGone}
<div class="stage-empty gone">
<span>{t('desk.missingTitle')}</span>
<span class="sub">{t('desk.missingSub')}</span>
</div>
{:else}
<div class="stage-empty">
<span>{st.label}</span>
<span class="sub">{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span>
{#if progress > 0}
<div class="a-progress stage-bar" style:--p={progress / 100}>
<i></i>
</div>
{/if}
</div>
{/if}
</div>
{/key}
</div>
{#if output && video}<div class="playback-bar">
<button
class="a-btn primary sm"
onclick={togglePlayback}
aria-label={paused ? '播放视频' : '暂停视频'}
>{paused ? '▶ 播放视频' : 'Ⅱ 暂停视频'}</button
>
<span>视频默认暂停,由你控制播放。</span>
<button
class="a-btn secondary sm"
onclick={() => videoEl?.requestFullscreen().catch(() => {})}>全屏查看 ⤢</button
>
</div>{/if}
{#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>
{#if main && compare && output && !video}<div class="compare-controls">
<span>{inputs.some((i) => i.kind === 'image') ? '参考图片' : '上一版'}</span><input
type="range"
min="0"
max="100"
bind:value={compareAt}
aria-label="拖动对比画面"
/><span>当前结果</span>
</div>{/if}
<div class="actions">
{#if main && output && !video}<button
class="a-btn secondary sm"
onclick={() => (lightbox = true)}>放大查看 ⤢</button
>{#if compareSource && compareSource !== output}<button
class="a-btn secondary sm"
aria-pressed={compare}
onclick={() => (compare = !compare)}>{compare ? '结束对比' : '对比画面'} ◫</button
>{/if}{/if}
{#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
class="a-btn secondary sm"
onclick={() => onCancel(job)}>取消这次生成</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}
<details class="result-details">
<summary>创作详情与参数</summary>
{#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>
</details>
{/if}
</div>
</aside>
{#if lightbox && output && !video}<ImageLightbox
src={assetUrl(output) ?? ''}
alt={prompt || '创作结果'}
onClose={() => (lightbox = false)}
/>{/if}
<style>
.inspector {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
border-left: 1px solid var(--line);
background: var(--bg);
}
.tabs {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--line);
}
.keys {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
font-size: var(--fs-mini);
color: var(--text-3);
}
.close {
padding: 0 var(--sp-3);
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--sp-6);
}
.stage {
aspect-ratio: 16 / 10;
display: grid;
place-items: center;
}
.stage-media {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: var(--sp-5);
box-sizing: border-box;
}
/* 相纸白边 (HEL-680):检视舞台上的结果按相纸呈现,媒体自适应内含 */
.print {
position: relative;
display: grid;
max-width: 100%;
max-height: 100%;
--paper-w: 5px;
}
.print > img,
.print > video {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
}
.stage-empty {
display: grid;
place-items: center;
gap: var(--sp-2);
padding: var(--sp-6);
text-align: center;
color: var(--text-3);
font-size: var(--fs-aux);
}
.stage-empty.gone {
color: var(--warn);
}
.stage-empty .sub {
color: var(--text-3);
font-size: var(--fs-mini);
}
.stage-bar {
width: 160px;
}
.strip {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.strip-item {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
overflow: hidden;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--bg-3);
color: var(--text-3);
cursor: pointer;
}
.strip-item[aria-pressed='true'] {
border-color: var(--accent);
}
.strip-item.gone {
border-style: dashed;
border-color: var(--warn);
color: var(--warn);
}
.strip-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-5);
margin: var(--sp-5) 0;
font-size: var(--fs-aux);
color: var(--text-3);
}
.meta b {
color: var(--text-2);
font-weight: 500;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.note {
margin-top: var(--sp-5);
padding: var(--sp-3) var(--sp-4);
border-radius: var(--r-md);
font-size: var(--fs-aux);
word-break: break-word;
}
.note.err {
background: var(--err-soft);
color: var(--err);
}
.note.warn {
background: var(--warn-soft);
color: var(--warn);
}
.section-title {
margin: var(--sp-7) 0 var(--sp-3);
font-size: var(--fs-aux);
color: var(--text-3);
}
.inputs {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.mini {
width: 40px;
height: 40px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
object-fit: cover;
}
.mini.glyph {
display: grid;
place-items: center;
background: var(--bg-3);
color: var(--text-3);
box-sizing: border-box;
}
.prompt {
margin: 0;
padding: var(--sp-4) var(--sp-5);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-2);
font-size: var(--fs-aux);
white-space: pre-wrap;
word-break: break-word;
}
.kv {
display: grid;
grid-template-columns: 92px 1fr;
gap: var(--sp-2) var(--sp-4);
margin: 0;
font-size: var(--fs-aux);
}
.kv dt {
color: var(--text-3);
}
.kv dd {
margin: 0;
color: var(--text-2);
word-break: break-all;
}
.kv .wf-gone {
margin-left: var(--sp-2);
}
/* ≥1180: a column of the desk grid; the close button hides it (]) */
@media (max-width: 1179px) {
.inspector {
position: fixed;
top: var(--topbar-h);
right: 0;
bottom: 0;
z-index: 40;
width: min(420px, 100vw);
box-shadow: var(--shadow-float);
transform: translateX(100%);
visibility: hidden;
transition:
transform var(--d-drawer) var(--ease-drawer),
visibility 0s linear var(--d-drawer);
}
.inspector.open {
transform: none;
visibility: visible;
transition:
transform var(--d-drawer) var(--ease-drawer),
visibility 0s;
}
:global(:root[data-reduce='1']) .inspector {
transform: none;
opacity: 0;
transition:
opacity var(--d-drawer) ease,
visibility 0s linear var(--d-drawer);
}
:global(:root[data-reduce='1']) .inspector.open {
opacity: 1;
transition:
opacity var(--d-drawer) ease,
visibility 0s;
}
.keys {
display: none;
}
.close {
margin-left: auto;
}
}
@media (max-width: 760px) {
.inspector {
top: 0;
z-index: 70;
width: 100vw;
border-left: 0;
}
.body {
padding: var(--sp-5);
}
}
.inspector.main {
position: static;
inset: auto;
transform: none;
opacity: 1;
pointer-events: auto;
width: 100%;
height: 100%;
min-height: 0;
max-width: none;
border: 0;
background: transparent;
overflow: hidden;
box-shadow: none;
display: flex;
flex-direction: column;
}
.main .tabs {
display: none;
}
.main .body {
padding: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
}
.main .stage {
aspect-ratio: auto;
width: 100%;
min-height: 110px;
flex: 1;
height: auto;
max-height: none;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
background: #eae9e3;
}
.main .stage-media {
position: relative;
inset: auto;
padding: 0;
height: 100%;
width: 100%;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
.main .print {
width: 100%;
height: 100%;
min-height: 0;
max-height: 100%;
max-width: 100%;
padding: 0;
border: 0;
background: none;
display: grid;
grid-template-rows: minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
}
.main .print img,
.main .print video {
min-height: 0;
min-width: 0;
width: 100%;
height: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 0;
}
.main .meta,
.main .actions,
.main .playback-bar,
.main .strip,
.main .note,
.main .result-details {
flex-shrink: 0;
margin: 0;
}
.main .meta {
font-size: 10px;
gap: 8px;
line-height: 1.5;
max-height: 30px;
overflow: auto;
}
.main .actions {
gap: 6px;
flex-wrap: wrap;
}
.main .result-details {
font-size: 11px;
max-height: 160px;
overflow: auto;
}
.playback-bar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding: 4px 0;
}
.playback-bar span {
flex: 1;
font-size: 11px;
color: var(--text-3);
}
@media (max-height: 650px) {
.playback-bar span {
display: none;
}
.main .meta {
max-height: 16px;
}
.main .actions :global(.a-btn) {
font-size: 11px;
padding: 4px 8px;
}
}
.main .a-fix {
display: none;
}
.main .stage-empty {
display: flex;
gap: 10px;
flex-direction: column;
align-items: center;
color: var(--text-3);
font-size: 12px;
}
.empty-symbol {
color: var(--accent);
font-size: 55px;
font-weight: 300;
line-height: 1.4;
}
.stage-empty strong {
font-size: 16px;
font-weight: 500;
color: var(--text);
}
.main .meta {
font-size: 9px;
gap: 12px;
margin-top: 10px;
}
.main .actions {
margin-top: 12px;
}
.result-details {
font-size: 11px;
color: var(--text-3);
margin-top: 12px;
}
.result-details summary {
cursor: pointer;
}
.result-details .prompt {
margin-top: 12px;
}
.comparison-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.comparison-layer img {
width: 100%;
height: 100%;
object-fit: contain;
background: #eae9e3;
}
.comparison-divider {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--surface);
box-shadow: 0 0 8px #292a2d40;
pointer-events: none;
}
.compare-controls {
display: flex;
align-items: center;
gap: 15px;
margin-top: 10px;
font-size: 10px;
color: var(--text-3);
}
.compare-controls input {
flex: 1;
accent-color: var(--accent);
}
</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,155 @@
<script lang="ts">
import { tick } from 'svelte';
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
import type { ComfyDynamicInput } from '$lib/comfy/types';
import { validateForm, classifySchema, type FormValues } from './schema';
let {
inputs,
values,
anchor,
onClose,
onApply,
}: {
inputs: ComfyDynamicInput[];
values: FormValues;
anchor: HTMLElement | null;
onClose: () => void;
onApply: (values: FormValues) => void;
} = $props();
let draft = $state<FormValues>({});
let showErrors = $state(false);
let panel = $state<HTMLDivElement | null>(null);
let x = $state(0),
y = $state(0);
const errors = $derived(
validateForm(inputs, classifySchema(inputs, 'image'), draft),
);
$effect(() => {
if (!anchor || !inputs.length) return;
const source = anchor;
draft = Object.fromEntries(
inputs.map((i) => [i.nodeId, values[i.nodeId] ?? i.defaultValue ?? '']),
);
showErrors = false;
const rect = source.getBoundingClientRect();
x = Math.max(12, Math.min(rect.left, innerWidth - 312));
y = Math.max(12, innerHeight - rect.top + 8);
void tick().then(() =>
panel
?.querySelector<HTMLElement>('input,select,textarea,button')
?.focus(),
);
const closeOutside = (e: PointerEvent) => {
if (
!panel?.contains(e.target as Node) &&
!source.contains(e.target as Node)
)
onClose();
};
const keys = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
if (e.key === 'Tab' && panel) {
const all = [
...panel.querySelectorAll<HTMLElement>(
'input,select,textarea,button',
),
].filter(
(e) => !e.hasAttribute('disabled') && e.getClientRects().length,
);
if (e.shiftKey && document.activeElement === all[0]) {
e.preventDefault();
all.at(-1)?.focus();
} else if (!e.shiftKey && document.activeElement === all.at(-1)) {
e.preventDefault();
all[0]?.focus();
}
}
};
document.addEventListener('pointerdown', closeOutside);
document.addEventListener('keydown', keys, true);
window.addEventListener('resize', onClose);
return () => {
document.removeEventListener('pointerdown', closeOutside);
document.removeEventListener('keydown', keys, true);
window.removeEventListener('resize', onClose);
if (source.isConnected) source.focus();
};
});
function save() {
if (Object.keys(errors).length) {
showErrors = true;
return;
}
onApply(draft);
onClose();
}
</script>
{#if inputs.length && anchor}
<div
bind:this={panel}
class="parameter-popover"
role="dialog"
aria-label={inputs.map((i) => i.label).join(' / ')}
style:left={`${x}px`}
style:bottom={`${y}px`}
>
<header>{inputs.map((i) => i.label).join(' / ')}</header>
<ComfyDynamicForm
{inputs}
values={draft as Record<string, string | number>}
quiet
{errors}
{showErrors}
onChange={(v) => (draft = { ...draft, ...v })}
/>
<footer>
<button class="a-btn ghost sm" onclick={onClose}>取消</button><button
class="a-btn secondary sm"
onclick={save}>应用参数</button
>
</footer>
</div>
{/if}
<style>
.parameter-popover {
position: fixed;
z-index: 800;
width: 300px;
box-sizing: border-box;
max-height: calc(100dvh - 36px);
overflow: auto;
padding: 16px;
background: var(--float-bg);
border: 1px solid var(--line-2);
border-radius: 12px;
box-shadow: var(--shadow-float);
transform-origin: bottom left;
animation: appear var(--d-pop) var(--ease-out);
}
header {
font-size: 12px;
color: var(--text-2);
margin-bottom: 14px;
}
footer {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
@keyframes appear {
from {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
</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,647 @@
<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,
workbenchCtxOf,
} 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.
* HEL-684: a take generated with a real workbench source offers an
* explicit 回流到源项目 action; once flowed back it shows 已回流 and
* a direct 返回项目 link instead.
*/
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;
/** Flow-back request in flight (HEL-684). */
attachBusy?: boolean;
onSelect: () => void;
onReuse: () => void;
onRetry: () => void;
onCancel: () => void;
onDelete: () => void;
onAttach?: () => void;
}
let {
job,
wf,
selected,
nowMs,
busy = false,
enter = false,
flash = false,
shake = false,
attachBusy = false,
onSelect,
onReuse,
onRetry,
onCancel,
onDelete,
onAttach,
}: 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 }));
// HEL-684: flow-back only for finished takes with files and a real,
// still-recorded workbench source; the attached marker comes from the
// server so it survives reloads.
const wbCtx = $derived(workbenchCtxOf(job));
const attached = $derived(
!!job.workbench_attach && !!wbCtx && job.workbench_attach.target_id === wbCtx.sourceId,
);
const canAttach = $derived(
!!onAttach && !!wbCtx && !attached && st.tone === 'ok' && !fileGone && !!output,
);
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}
{#if canAttach}
<button
type="button"
class="a-btn ghost xs attach"
disabled={attachBusy}
title={t('desk.attachHint', { label: wbCtx?.label ?? '' })}
onclick={() => onAttach?.()}
>
{attachBusy ? t('desk.attaching') : t('desk.attachToProject')}
</button>
{/if}
{#if attached && wbCtx}
<span class="a-tag ok" title={t('desk.attachHint', { label: wbCtx.label ?? '' })}>
{t('desk.attachedTag')}
</span>
<a class="a-btn ghost xs" href={`/workbench/${wbCtx.projectId}?stage=${wbCtx.stage}`}>
{t('desk.attachBack')}
</a>
{/if}
<button type="button" class="a-btn ghost xs" onclick={onReuse}>{t('desk.reuse')}</button>
{#if st.tone === 'fail' && job.error !== 'cancelled'}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>{t('common.retry')}</button>
{/if}
{#if active}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onCancel}>{t('common.cancel')}</button>
{/if}
{#if output}
<a class="a-btn ghost xs" href={assetUrl(output)} download>{t('desk.download')}</a>
{/if}
{#if !active}
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('studio.deleteBtn')}</button>
{/if}
{/if}
</div>
</div>
<div class="out">
{#if output}
<div class="thumb a-photo" class:portrait>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
{#if isVideoOutput(job, output)}
<video
src={assetUrl(output)}
preload="metadata"
muted
playsinline
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
<span class="play" aria-hidden="true"><Icon name="play" size={10} /></span>
{#if clipSec != null}<span class="badge mono">{fmtDur(clipSec)}</span>{/if}
{:else}
<img
src={assetUrl(output)}
alt=""
loading="lazy"
onload={(e) => {
clearOutputMissing(output);
noteImage(e);
}}
onerror={() => output && markOutputMissing(output)}
/>
{/if}
{#if job.output_paths.length > 1}<span class="count mono">×{job.output_paths.length}</span>{/if}
</div>
{:else if active}
<div class="thumb pending">{st.label}</div>
{:else if fileGone}
<div class="thumb gone">{t('jobst.missing')}</div>
{:else}
<div class="thumb missing">{t('desk.notGenerated')}</div>
{/if}
</div>
</article>
<style>
.take {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-4) var(--sp-6);
margin-bottom: var(--sp-3);
padding: var(--sp-5) var(--sp-6);
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--bg-2);
transition:
border-color var(--d-state) ease,
background-color var(--d-state) ease,
box-shadow var(--d-state) ease;
}
/* 选中框:取景框角线从上一条记录滑到新选中的记录(feed 驱动 WAAPI,
HEL-680 四角短线取代整框描边) */
.sel-mark {
position: absolute;
inset: -1px;
z-index: 3;
display: none;
pointer-events: none;
background-image:
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0);
background-position:
left top,
left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
background-size:
14px 2px,
2px 14px,
14px 2px,
2px 14px,
14px 2px,
2px 14px,
14px 2px,
2px 14px;
background-repeat: no-repeat;
}
.take.selected {
border-color: transparent;
background: var(--accent-wash), var(--bg-3);
box-shadow: var(--sel-shadow);
}
.take.selected > .sel-mark {
display: block;
}
@media (hover: hover) and (pointer: fine) {
.take:hover:not(.selected) {
border-color: var(--line-2);
background: var(--bg-3);
}
}
/* 新记录 / 撤销恢复:220ms 上移 8px 入场(减动效只余淡变) */
.take.enter {
animation: take-in var(--d-enter) var(--ease-out);
}
@keyframes take-in {
from {
opacity: 0;
transform: translateY(8px);
}
}
/* 完成一次性金色光晕 900ms;失败原地轻抖一次 */
.take.done-flash {
animation: done-flash var(--d-flash) var(--ease-out);
}
@keyframes done-flash {
from {
box-shadow: var(--accent-glow), 0 0 0 1px var(--accent-line);
}
}
.take.shake {
animation: take-shake 320ms var(--ease-out);
}
@keyframes take-shake {
20% {
transform: translateX(-4px);
}
45% {
transform: translateX(4px);
}
70% {
transform: translateX(-2px);
}
}
:global(:root[data-reduce='1']) .take.enter {
animation: take-fade var(--d-state) ease;
}
:global(:root[data-reduce='1']) .take.done-flash,
:global(:root[data-reduce='1']) .take.shake {
animation: none;
}
@keyframes take-fade {
from {
opacity: 0;
}
}
.hit {
position: absolute;
inset: 0;
z-index: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.hit:focus-visible {
outline-offset: -2px;
border-radius: var(--r-lg);
}
.head,
.prompt,
.inputs,
.state {
grid-column: 1;
position: relative;
pointer-events: none;
}
.head {
display: flex;
align-items: center;
gap: var(--sp-3);
min-width: 0;
}
.name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
color: var(--text-3);
font-size: var(--fs-aux);
white-space: nowrap;
}
.prompt {
margin: 0;
color: var(--text-2);
font-size: var(--fs-body-sm);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.inputs {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.mini {
width: 26px;
height: 26px;
border-radius: 5px;
object-fit: cover;
border: 1px solid var(--line-2);
}
.more {
padding-left: 2px;
font-size: var(--fs-mini);
color: var(--text-3);
}
.state {
display: flex;
align-items: center;
gap: var(--sp-3);
min-height: 22px;
font-size: var(--fs-aux);
}
.bar {
flex: 0 0 140px;
}
.txt {
color: var(--text-2);
white-space: nowrap;
}
.gone-txt {
min-width: 0;
color: var(--warn);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 失败:一行中文结论自动展开,点开看完整详情 */
.err-txt {
min-width: 0;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
color: var(--err);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: auto;
cursor: pointer;
}
.err-txt.open {
white-space: normal;
word-break: break-word;
}
.err-txt:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--r-sm);
}
.actions {
display: flex;
gap: var(--sp-1);
margin-left: auto;
pointer-events: auto;
opacity: 0;
transition: opacity var(--d-state) ease;
}
.take:focus-within .actions,
.take.selected .actions {
opacity: 1;
}
@media (hover: hover) and (pointer: fine) {
.take:hover .actions {
opacity: 1;
}
}
@media (hover: none) {
.actions {
opacity: 1;
}
}
.out {
grid-column: 2;
grid-row: 1 / span 4;
align-self: start;
position: relative;
pointer-events: none;
}
.thumb {
position: relative;
width: 150px;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: var(--r-md);
background: var(--bg-4);
box-shadow: inset 0 0 0 1px var(--on-media-line);
}
/* 相纸白边 (HEL-680):有产出的记录缩略图按相纸呈现,压过占位态样式 */
.thumb.a-photo {
border-radius: 2px;
background: var(--accent);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}
.thumb.portrait {
width: 96px;
aspect-ratio: 3 / 4;
}
.thumb img,
.thumb video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.badge,
.count {
position: absolute;
bottom: var(--sp-2);
padding: 1px 5px;
border-radius: 4px;
background: var(--media-scrim);
color: var(--on-media);
font-size: var(--fs-mini);
}
.badge {
right: var(--sp-2);
}
.count {
right: var(--sp-2);
top: var(--sp-2);
bottom: auto;
}
.play {
position: absolute;
left: var(--sp-2);
bottom: var(--sp-2);
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--media-scrim);
color: var(--on-media);
}
.thumb.pending,
.thumb.missing,
.thumb.gone {
display: grid;
place-items: center;
padding: var(--sp-2);
box-sizing: border-box;
text-align: center;
font-size: var(--fs-mini);
color: var(--text-3);
border: 1px dashed var(--line-2);
background: var(--bg-2);
box-shadow: none;
}
.thumb.pending {
border-style: solid;
color: var(--run);
}
.thumb.gone {
color: var(--warn);
border-color: var(--warn);
}
.thumb.pending::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 30%, var(--run-soft) 50%, transparent 70%);
animation: take-sheen 1.4s linear infinite;
}
@keyframes take-sheen {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
:global(:root[data-reduce='1']) .thumb.pending::after {
animation: none;
}
@media (max-width: 760px) {
.take {
padding: var(--sp-4);
}
.head {
flex-wrap: wrap;
row-gap: 2px;
}
.out {
grid-row: 1 / span 2;
}
.state {
grid-column: 1 / -1;
flex-wrap: wrap;
}
.thumb {
width: 108px;
}
.thumb.portrait {
width: 72px;
}
.bar {
flex-basis: 80px;
}
}
</style>
@@ -0,0 +1,351 @@
<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;
/** Flow-back to the workbench source row in flight (HEL-684). */
attachBusyId?: number | null;
onSelect: (job: Job) => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onCancel: (job: Job) => void;
onDelete: (job: Job) => void;
onAttach?: (job: Job) => void;
}
let {
jobs,
workflowOf,
filter = $bindable(),
selectedId,
nowMs,
busyId,
loading,
pendingIds,
flashIds,
shakeIds,
orderedIds = $bindable(),
banner,
inspectorToggle,
attachBusyId = null,
onSelect,
onReuse,
onRetry,
onCancel,
onDelete,
onAttach,
}: 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)}
attachBusy={attachBusyId === job.id}
onAttach={onAttach ? () => onAttach(job) : undefined}
/>
</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,107 @@
import type { WorkbenchStageKey } from '$lib/api';
import type { DeskKind } from './schema';
/**
* 五段创作 → 生成台 handoff, carried in the URL so a reload keeps it.
* The desk prefills from it and, when the full source identity is present
* (sourceKind/sourceId), sends it along with the generate request so a
* finished take can be flowed back to its source row (HEL-684).
*/
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;
/** Real source row identity — only present on links the workbench
* stamped with it; without it no flow-back is offered (nothing guessed). */
sourceKind?: 'asset' | 'shot';
sourceId?: string;
}
/** The generate-request context payload shape (HEL-684). */
export interface DeskWorkbenchContext {
project_id: number;
stage: 'assets' | 'shot';
source_kind: 'asset' | 'shot';
source_id: string;
label?: string;
}
const STAGES: WorkbenchStageKey[] = ['script', 'style', 'storyboard', 'assets', 'shot'];
/** Stage ↔ desk kind pairing the workbench rows render (assets → image,
* shot → video). Anything else cannot display a flowed-back take. */
const KIND_BY_STAGE: Partial<Record<WorkbenchStageKey, DeskKind>> = {
assets: 'image',
shot: 'video',
};
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));
if (ctx.sourceKind && ctx.sourceId) {
q.set('source_kind', ctx.sourceKind);
q.set('source_id', ctx.sourceId);
}
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'));
const rawKind = q.get('source_kind');
const rawId = q.get('source_id') ?? '';
// Source identity is only kept when it is complete and self-consistent;
// old/hand-made links keep today's prefill-only behavior.
const expectedKind = stage === 'assets' ? 'asset' : stage === 'shot' ? 'shot' : null;
const sourceKind = rawKind === 'asset' || rawKind === 'shot' ? rawKind : null;
const hasSource = sourceKind !== null && sourceKind === expectedKind && rawId.trim() !== '';
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,
sourceKind: hasSource ? (sourceKind as 'asset' | 'shot') : undefined,
sourceId: hasSource ? rawId : undefined,
};
}
/**
* The context a generate request carries: only when the full, real source
* identity is present AND the desk kind matches what that stage renders.
*/
export function workbenchContextOf(ctx: DeskContext | null): DeskWorkbenchContext | undefined {
if (!ctx?.sourceId || !ctx.sourceKind) return undefined;
if (ctx.stage !== 'assets' && ctx.stage !== 'shot') return undefined;
if (KIND_BY_STAGE[ctx.stage] !== ctx.kind) return undefined;
const out: DeskWorkbenchContext = {
project_id: ctx.projectId,
stage: ctx.stage,
source_kind: ctx.sourceKind,
source_id: ctx.sourceId,
};
if (ctx.label.trim()) out.label = ctx.label;
return out;
}
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,157 @@
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';
/** The workbench source a take was generated with, if any (HEL-684). */
export interface TakeWorkbenchCtx {
projectId: number;
stage: string;
sourceKind: 'asset' | 'shot';
sourceId: string;
label?: string;
}
export function workbenchCtxOf(job: Job): TakeWorkbenchCtx | null {
const stamp = job.payload?.workbench;
if (!stamp || typeof stamp !== 'object') return null;
const s = stamp as Record<string, unknown>;
const projectId = s.project_id;
const stage = typeof s.stage === 'string' ? s.stage : '';
const sourceKind = s.source_kind === 'asset' || s.source_kind === 'shot' ? s.source_kind : null;
const sourceId = typeof s.source_id === 'string' ? s.source_id : '';
if (typeof projectId !== 'number' || !stage || !sourceKind || !sourceId) return null;
const label = typeof s.label === 'string' && s.label.trim() ? s.label : undefined;
return {
projectId,
stage,
sourceKind,
sourceId,
label,
};
}
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;
}
@@ -35,23 +35,33 @@
);
// Five-stage creation progress (HEL-614) — real stage rows only.
const STAGE_SEGMENTS = ['script', 'style', 'storyboard', 'assets', 'shot'] as const;
const STAGE_SEGMENTS = [
'script',
'style',
'storyboard',
'assets',
'shot',
] as const;
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 '';
}
// Spec §2.1 allows the five avatar gradients as cover placeholders.
const PLACEHOLDER_GRADIENTS = [
'var(--avatar-1)',
'var(--avatar-2)',
'var(--avatar-3)',
'var(--avatar-4)',
'var(--avatar-5)',
'#1f262c',
'#253036',
'#2d2333',
'#26372c',
'#302836',
];
const placeholder = $derived(PLACEHOLDER_GRADIENTS[project.id % PLACEHOLDER_GRADIENTS.length]);
const placeholderIdx = $derived(project.id % PLACEHOLDER_GRADIENTS.length);
const placeholder = $derived(PLACEHOLDER_GRADIENTS[placeholderIdx]);
// HEL-680 灰阶封面占位:1–2 亮阶配深图标,3–5 暗阶配浅图标
const placeholderIconColor = $derived('var(--text-2)');
const coverUrl = $derived(assetUrl(project.cover_path));
@@ -63,17 +73,28 @@
if (stats.stage_confirmed >= STAGE_SEGMENTS.length) {
return { cls: 'st-done', label: t('home.statusDone') };
}
const current = STAGE_SEGMENTS.find((k) => stats.stage_status?.[k] !== 'confirmed');
const current = STAGE_SEGMENTS.find(
(k) => stats.stage_status?.[k] !== 'confirmed',
);
const state = current ? stats.stage_status?.[current] : undefined;
const stageName = current ? t(`workbench.stage.${current}`) : '';
if (state === 'running') {
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.running')}` };
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')}` };
return {
cls: 'st-progress',
label: `${stageName} · ${t('home.statusProgress')}`,
};
}
return { cls: 'st-draft', label: t('home.statusDraft') };
});
@@ -131,7 +152,9 @@
toast.success(t('projectCard.renamedTo', { title: renameValue }));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('projectCard.renameFailed'));
toast.error(
err instanceof Error ? err.message : t('projectCard.renameFailed'),
);
},
});
@@ -151,7 +174,9 @@
toast.success(t('projectCard.deleted', { title: project.title }));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('projectCard.deleteFailed'));
toast.error(
err instanceof Error ? err.message : t('projectCard.deleteFailed'),
);
},
});
</script>
@@ -173,8 +198,15 @@
onerror={() => (coverFailed = true)}
/>
{:else}
<span class="cover-ph" style:background={placeholder} aria-hidden="true">
<Icon name="film" size={22} />
<span
class="cover-ph"
style:background={placeholder}
style:color={placeholderIconColor}
aria-hidden="true"
>
<span class="cover-type"
>✳<small>STORY / {String(project.id).padStart(3, '0')}</small></span
>
</span>
{/if}
<span class="status-tag {cover.cls}">{cover.label}</span>
@@ -187,11 +219,14 @@
<span class="idea">{project.idea || t('home.noIdea')}</span>
<span class="stage-bar" aria-label={t('home.stageProgress')}>
{#each STAGE_SEGMENTS as key (key)}
<span class="seg {segClass(key)}" title={t(`workbench.stage.${key}`)}></span>
<span class="seg {segClass(key)}" title={t(`workbench.stage.${key}`)}
></span>
{/each}
<span class="seg-count mono">{stats.stage_confirmed}/5</span>
{#if stats.active_jobs > 0}
<span class="jobs-pill">{t('home.activeJobs', { n: stats.active_jobs })}</span>
<span class="jobs-pill"
>{t('home.activeJobs', { n: stats.active_jobs })}</span
>
{/if}
</span>
<span class="meta">
@@ -221,7 +256,12 @@
</button>
{#if menuOpen}
<div class="menu" role="menu">
<button type="button" role="menuitem" class="menu-item" onclick={openRename}>
<button
type="button"
role="menuitem"
class="menu-item"
onclick={openRename}
>
<Icon name="edit" size={14} />
{t('common.rename')}
</button>
@@ -255,7 +295,9 @@
</label>
</form>
{#snippet footer()}
<Button variant="ghost" onclick={() => (renameOpen = false)}>{t('common.cancel')}</Button>
<Button variant="ghost" onclick={() => (renameOpen = false)}
>{t('common.cancel')}</Button
>
<Button
variant="primary"
disabled={!renameValue.trim() || $renameMutation.isPending}
@@ -329,7 +371,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 +382,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 +392,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 +422,7 @@
white-space: nowrap;
}
.arrow {
color: var(--accent);
color: var(--text-2);
font-size: 13px;
opacity: 0;
transform: translateX(-6px);
@@ -409,8 +454,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 +473,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 +510,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;
@@ -517,9 +565,81 @@
}
.menu-item.danger:hover {
background: var(--error-dim);
} .a-card :global(.field-input) {
}
.a-card :global(.field-input) {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text);
}
.a-card {
background: transparent;
border: 0;
border-radius: 0;
overflow: visible;
}
.cover {
height: 180px;
border-radius: 12px;
}
.body {
padding: 12px 0 0;
}
.status-tag {
top: auto;
left: 12px;
right: auto;
bottom: 12px;
border: 0;
border-radius: 4px;
padding: 4px 7px;
background: #121218ed;
color: var(--text);
font-size: 9px;
}
.cover-type {
font-size: 56px;
font-weight: 300;
color: var(--text-3);
}
.cover-type small {
display: block;
font: 9px var(--mono);
letter-spacing: 1px;
}
.cover-img {
transition: transform 250ms var(--ease-out);
}
.stage-bar {
width: 100%;
}
.seg {
flex: 1;
height: 3px;
}
.meta-stats,
.meta-time {
font-size: 10px;
}
.idea {
font-size: 11px;
min-height: 0;
}
.menu-wrap {
left: auto;
right: 10px;
}
.menu-btn {
background: var(--surface);
}
.menu {
left: auto;
right: 0;
box-shadow: var(--shadow-float);
}
@media (hover: hover) {
.a-card:hover .cover-img {
transform: scale(1.035);
}
}
</style>
@@ -231,7 +231,7 @@
.tabs button.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
color: var(--accent-ink);
}
.tabs button:focus-visible {
@@ -341,7 +341,7 @@
height: 20px;
border-radius: 9999px;
background: var(--accent);
color: #fff;
color: var(--accent-ink);
display: flex;
align-items: center;
justify-content: center;
@@ -271,9 +271,9 @@
width: 20px;
height: 20px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.65);
background: var(--media-scrim);
border: none;
color: white;
color: var(--on-media);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;

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