dsh-comfyui 0.3.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/README.zh.md +160 -0
- package/client/client.js +3089 -0
- package/client/client.js.map +1 -0
- package/cordis.patch.yml +4 -0
- package/lib/analyze.d.ts +61 -0
- package/lib/analyze.js +96 -0
- package/lib/comfyui.d.ts +195 -0
- package/lib/comfyui.js +392 -0
- package/lib/config.d.ts +71 -0
- package/lib/config.js +28 -0
- package/lib/convert.d.ts +35 -0
- package/lib/convert.js +289 -0
- package/lib/host-hint.d.ts +29 -0
- package/lib/host-hint.js +73 -0
- package/lib/http.d.ts +15 -0
- package/lib/http.js +41 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +344 -0
- package/lib/params.d.ts +63 -0
- package/lib/params.js +537 -0
- package/lib/progress.d.ts +27 -0
- package/lib/progress.js +98 -0
- package/lib/proxy.d.ts +13 -0
- package/lib/proxy.js +89 -0
- package/lib/queue.d.ts +50 -0
- package/lib/queue.js +103 -0
- package/lib/routes.d.ts +13 -0
- package/lib/routes.js +800 -0
- package/lib/skill.d.ts +12 -0
- package/lib/skill.js +72 -0
- package/lib/store.d.ts +120 -0
- package/lib/store.js +193 -0
- package/lib/templates.d.ts +36 -0
- package/lib/templates.js +84 -0
- package/lib/tools.d.ts +165 -0
- package/lib/tools.js +493 -0
- package/package.json +80 -0
package/lib/skill.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dsh-comfyui companion skill: canvas analysis and graph→API extraction
|
|
3
|
+
* rules for the model, registered through `ctx.skills.register` (runtime
|
|
4
|
+
* skill, rank 250 — user/project skills can override it).
|
|
5
|
+
*/
|
|
6
|
+
export declare const COMFYUI_SKILL: {
|
|
7
|
+
name: string;
|
|
8
|
+
source: string;
|
|
9
|
+
description: string;
|
|
10
|
+
whenToUse: string;
|
|
11
|
+
content: string;
|
|
12
|
+
};
|
package/lib/skill.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dsh-comfyui companion skill: canvas analysis and graph→API extraction
|
|
3
|
+
* rules for the model, registered through `ctx.skills.register` (runtime
|
|
4
|
+
* skill, rank 250 — user/project skills can override it).
|
|
5
|
+
*/
|
|
6
|
+
export const COMFYUI_SKILL = {
|
|
7
|
+
name: 'dsh-comfyui-workflows',
|
|
8
|
+
source: 'runtime',
|
|
9
|
+
description: 'ComfyUI 工作流管理:区分“图工作流”(ComfyUI 端保存的画布,衍生主题)与“API 工作流”(可执行,运行主题),分析画布中的多个独立流程(连通分量),以及图→API 提取的规则与面板操作引导。处理 comfyui_workflow 或用户要求运行 ComfyUI 端保存的工作流时加载。',
|
|
10
|
+
whenToUse: '用户要求运行/管理 ComfyUI 中保存的工作流,或 comfyui_workflow list 显示未提取的图工作流时;需要判断一个画布是否包含多个独立流程、或解释为何某个工作流无法直接运行时。',
|
|
11
|
+
content: `# dsh-comfyui 工作流管理
|
|
12
|
+
|
|
13
|
+
本插件把 ComfyUI 工作流分成两个主题:
|
|
14
|
+
|
|
15
|
+
- **图工作流(衍生主题)**:用户在 ComfyUI 画布上保存的 UI 图(nodes/links/widgets),是"源"。不能直接运行。一个图可能包含**多个互相独立的流程**。
|
|
16
|
+
- **API 工作流(运行主题)**:可执行的 API 格式 prompt,是"运行单元"。从图里**提取(extract)**出来,或用户直接粘贴导入。运行必须用 API 工作流。
|
|
17
|
+
|
|
18
|
+
comfyui_workflow 的 \`action: list\` 会同时返回:插件库中的 API 工作流(\`workflows\`)和 ComfyUI 端保存的图工作流(\`comfyuiWorkflows\`,带 \`extracted\` 与 \`derived\` 派生列表)。
|
|
19
|
+
|
|
20
|
+
## 画布分析规则(判断一个图是不是"多个工作流")
|
|
21
|
+
|
|
22
|
+
1. **groups(视觉分组)只是矩形**,title + bounding box,没有任何执行语义。不要把它当工作流边界。
|
|
23
|
+
2. **Fast Groups Bypasser (rgthree) 的激活状态不序列化**,但被 bypass 的组里的节点 \`mode: 4\` 是可靠信号——转换/提取时这些节点**自动跳过**(即保存时未激活的区块不会进入任何运行工作流)。
|
|
24
|
+
3. **可执行单元 = 连通分量**(按 links 连通的激活节点集合)。悬空单节点(Markdown、Fast Groups Bypasser、未连接的 Primitive 等 UI-only/未使用节点)自动忽略。
|
|
25
|
+
4. 判定:
|
|
26
|
+
- **1 个连通分量** → 一个完整工作流(多个组只是它的功能块),直接提取即可。
|
|
27
|
+
- **多个连通分量** → 画布上躺着多个独立流程(用户可能只是放一起测试)。**不要自作主张**,把选择权交给用户(面板提取选项):整体(合成一个,运行时全部执行)/ 按分量提取(推荐,每个独立流程一个运行工作流)/ 只拆主流程(最大分量,通常是当前测试区块)。面板的提取选项会先展示分析报告(各分量的节点数、所在组)。
|
|
28
|
+
5. 用户说"跑我在 ComfyUI 里保存的 X"时:若 list 显示该图 \`extracted: false\`,**先告诉用户需要到面板里"提取"它**(说明可选择整体/按分量/主流程),不要尝试用图本身运行;若已提取,用 \`derived\` 里的 libraryId 运行。
|
|
29
|
+
|
|
30
|
+
## 图 → API 提取的技术规则
|
|
31
|
+
|
|
32
|
+
- **节点 id 引用必须是字符串**:\`[String(节点id), 输出槽位]\`。服务器按字符串键做字典查找(execution.py validate_inputs),数字引用会 KeyError。
|
|
33
|
+
- **widget 顺序取图节点自身 \`inputs\` 数组**(含 \`widget.name\` 的条目按数组序),而不是 object_info 顺序:它包含动态子 widget(如 TextGenerate 的 sampling_mode.temperature)且**被链接的 widget 仍占据 widgets_values 位置**。object_info 只用于在 INT 输入后插入 \`control_after_generate\`。
|
|
34
|
+
- **对象型 widgets_values**(如 VHS_VideoCombine)按名取值,跳过内部状态(\`videopreview\` 等带 \`hidden\` 的对象)。
|
|
35
|
+
- **Reroute 与 bypass(mode 4)节点直通**:输出跟随其第一条有连线的输入。
|
|
36
|
+
- **未注册的 UI-only 节点**:若是 \`Primitive*\` 内联其第一个 widget 值;有输出被使用且非 Primitive → 报错。
|
|
37
|
+
- **输出槽位越界**(保存图里 SaveImage 声称 2 个输出但服务端只有 1 个)→ 断开该引用并给警告。
|
|
38
|
+
- **必需输入缺失**(required 里没有、且非 lazy/template 类型)→ 明确报错(源图本身断线),不产出"能转但跑不起来"的工作流。\`COMFY_AUTOGROW_V3\`/带 \`template\`/带 \`lazy\` 的输入豁免。
|
|
39
|
+
- 提取的每个运行工作流都会先被 \`POST /prompt\` 校验(nodeErrors 必须为空)才算成功。
|
|
40
|
+
|
|
41
|
+
## 可调参数(运行传参)
|
|
42
|
+
|
|
43
|
+
- 每个运行工作流带一组**可调参数**:提取时自动识别 提示词(文本输入)、分辨率(EmptyLatentImage 宽高)、步数(KSampler.steps)、种子(KSampler.seed,默认每次随机)、时长(duration/length/frames)、宽高比(aspect_ratio)、加载节点(LoadImage/LoadVideo/LoadAudio 的图片/视频/音频文件,值填服务器上的文件名,用户在面板可拖拽上传)。识别是**通用规则**(对象信息里的 upload 标记/文件列表),不针对具体节点;像 MiniMaxH3 综合加载(media_state JSON)这类通用规则识别不到的输入,让用户在面板"高级参数"里手动添加——值若形如媒体 JSON 数组,会自动拆成 \`media_1/media_2/...\` 参考位(值填子目录文件名,空字符串 = 移除该参考位)。cfg/denoise 与模型选择不在其中,保持工作流原样。用户可在面板"编辑工作流"里改默认值/随机开关,或添加高级参数(任意节点输入)。
|
|
44
|
+
- \`action: list\` 输出每个工作流的 \`parameters\` 字段就是结构化参数清单(\`name\` 是英文参数名、\`label\` 是中文含义、\`default\` 是默认值、\`random\` 表示是否每次随机、\`options\` 是下拉选项列表、\`upload\` 表示加载节点上传类型),用之前先看它。带 \`options\` 的参数必须从选项里取值(加载节点可从文件列表选,也可提示用户拖拽上传新文件)。
|
|
45
|
+
- 运行时可传 \`parameters\` 覆盖:\`{"prompt": "新提示词", "seed": 42, "width": 1024}\`。显式传值优先于默认;seed 类参数不传时按工作流设置随机(每次不同)。**参数名必须用清单里的英文 \`name\`**(prompt/seed/width/height/steps/duration/aspect_ratio),不要自造;带 \`options\` 的参数传值必须落在选项内,否则会报错。\`aspect_ratio\` 这类联动参数:只传它时 \`size\` 会自动跟随新比例的默认尺寸(如 9:16 → 768×1376),也可显式传 \`size\` 覆盖。
|
|
46
|
+
|
|
47
|
+
## 加载区与上传(给用户)
|
|
48
|
+
|
|
49
|
+
- 工作流库页面(运行主题)**最底部**有一个"**加载区**"(类似 ComfyUI 的 LoadImage 节点):大图显示当前选中的图像,点击打开选择窗口——顶部"全部 / 已导入 / 已生成"导航条 + 右侧粘贴/点击上传区,下面是瀑布流图像列表。
|
|
50
|
+
- **已导入** = ComfyUI input 目录里的文件;**已生成** = 之前工作流产出的图像(output);**全部** = 两者合并。
|
|
51
|
+
- 粘贴/上传新图 → 自动上传 → 列表刷新出现该图;点击任意图像 → 选定并关闭窗口,加载区大图更新为该图(像素尺寸在**上传时**自动记录并显示在大图下方;历史旧图若无记录则不显示尺寸、也不参与尺寸匹配)。
|
|
52
|
+
- **默认源图**:加载区选中的图像会成为图生图的**默认源图**——你 run 含 \`image\`(LoadImage)参数的工作流时,如果**没显式传 image**,插件自动用加载区选中的文件名(显式传 \`image\` 则优先)。聊天消息里出现 \`[已上传图片: 文件名]\` 标记同理:文件已由"贴图"按钮上传到 ComfyUI 的 input 目录,直接可用;文件名带 \`subfolder/\` 前缀的按原样传。
|
|
53
|
+
- **尺寸自动匹配**:面板上传/加载时插件会记录图片像素尺寸。图生图 run 时如果**没显式传 width/height**,插件自动用源图分辨率作为输出尺寸(显式传值优先)。加载区大图下方会显示选中图的尺寸(如 \`1024×768\`)。
|
|
54
|
+
- **命名与去重**:上传文件按内容哈希命名(\`原名_短哈希.ext\`,如 \`image_3f9a2b1c0d.png\`),同一张图重复上传会直接复用已有文件(不会产生 \`image(1).png\` 之类的重复文件)。
|
|
55
|
+
- 选中"已生成"的图后,插件会自动把它从 output 复制到 input 目录(同名),保证加载节点可用。
|
|
56
|
+
|
|
57
|
+
## 运行工作流的正确流程(省 token)
|
|
58
|
+
|
|
59
|
+
1. **list 了解**:\`comfyui_workflow list\` 看每个工作流的名称、描述、**标签**(图生图/文生图/文生视频/图生视频/参考生视频/文生音频/参考生音频等,可自定义)和 \`parameters\` 参数清单(含含义 label 和默认值),挑出符合当前场景的工作流。面板上可点标签筛选工作流列表。
|
|
60
|
+
2. **参数决策**:只依据清单里的参数(键名用英文名),选择/覆盖适合的值——**不需要查看工作流内部结构**。
|
|
61
|
+
3. **run 直接调用**:\`comfyui_workflow run { id, parameters: {...} }\`。插件直接从库里取该工作流的 JSON 提交给 ComfyUI,你**只传参数覆盖值,绝不自己输出或复制完整工作流 JSON**(那会浪费大量 token)。
|
|
62
|
+
- **视频/音频工作流一律 \`mode: "async"\`**:生成要几分钟,sync 会等待超时中断(报 generation aborted)。async 立即返回 job id,**启动后不要阻塞等待**(不要对返回的 job 用 wait: true 卡住),继续做其他事,后台任务完成时系统会通知你,到时再用 job_output(不 wait)收集结果和媒体回显。
|
|
63
|
+
- 图片生成可 sync(通常 <1 分钟);若不确定输出类型,也用 async。
|
|
64
|
+
- \`action: get\` 只在**诊断/检查**工作流内容时使用(它会输出完整 JSON、占用大量 token),它**不是运行路径**。
|
|
65
|
+
|
|
66
|
+
## 面板操作引导(转告用户)
|
|
67
|
+
|
|
68
|
+
- 面板"ComfyUI 端保存"分区列出每个图工作流:未提取显示"未提取",已提取显示派生出的运行工作流列表。
|
|
69
|
+
- 点**提取**:单分量直接拆;多分量弹出选项(分析报告 + 整体/按分量/主流程)。也可点**查看**看节点清单和 JSON。
|
|
70
|
+
- 用户也可以不经过图,直接在插件库"新建工作流"粘贴 API JSON(或选择 .json 文件)作为运行工作流导入。
|
|
71
|
+
`,
|
|
72
|
+
};
|
package/lib/store.d.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { WorkflowParameter } from './params.js';
|
|
2
|
+
/** One saved workflow in the library. */
|
|
3
|
+
export interface StoredWorkflow {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
/** What the workflow does — the overview the agent reads to pick one. */
|
|
7
|
+
description: string;
|
|
8
|
+
/** ComfyUI API-format workflow: node id → { class_type, inputs }. */
|
|
9
|
+
workflow: Record<string, {
|
|
10
|
+
class_type: string;
|
|
11
|
+
inputs: Record<string, unknown>;
|
|
12
|
+
}>;
|
|
13
|
+
/** Exposed adjustable parameters (auto-detected + user advanced). */
|
|
14
|
+
parameters?: WorkflowParameter[];
|
|
15
|
+
/** Classification tags (preset kinds like 图生图 plus user-defined). */
|
|
16
|
+
tags?: string[];
|
|
17
|
+
/** Where the workflow came from: 'user' (panel/tool) or 'comfyui' (imported). */
|
|
18
|
+
source?: 'user' | 'comfyui';
|
|
19
|
+
/** Original ComfyUI-side user-data file name when imported from the server. */
|
|
20
|
+
comfyuiFile?: string;
|
|
21
|
+
updatedAt: string;
|
|
22
|
+
}
|
|
23
|
+
/** One media file reference inside an asset record. */
|
|
24
|
+
export interface AssetMediaRef {
|
|
25
|
+
filename: string;
|
|
26
|
+
subfolder: string;
|
|
27
|
+
type: string;
|
|
28
|
+
node: string;
|
|
29
|
+
index: number;
|
|
30
|
+
kind: 'image' | 'video' | 'audio' | 'other';
|
|
31
|
+
url: string;
|
|
32
|
+
}
|
|
33
|
+
/** One completed generation in the asset index. */
|
|
34
|
+
export interface AssetRecord {
|
|
35
|
+
promptId: string;
|
|
36
|
+
ts: string;
|
|
37
|
+
workflowName: string | null;
|
|
38
|
+
source: string;
|
|
39
|
+
media: AssetMediaRef[];
|
|
40
|
+
}
|
|
41
|
+
/** The load-area selection: the image the user picked for image-to-image.
|
|
42
|
+
* `name` is always a file name ComfyUI can load (generated outputs are copied
|
|
43
|
+
* into the input dir on selection). */
|
|
44
|
+
export interface CurrentImage {
|
|
45
|
+
name: string;
|
|
46
|
+
kind: 'image' | 'video' | 'audio';
|
|
47
|
+
source: 'imported' | 'generated';
|
|
48
|
+
}
|
|
49
|
+
/** Validate a workflow-shaped value; returns an error message or undefined. */
|
|
50
|
+
export declare function validateWorkflow(value: unknown): string | undefined;
|
|
51
|
+
/** Persistent queue-tracker memory (survives web-server restarts). */
|
|
52
|
+
export interface TrackedState {
|
|
53
|
+
runs: Array<{
|
|
54
|
+
promptId: string;
|
|
55
|
+
ts: string;
|
|
56
|
+
workflowName: string | null;
|
|
57
|
+
source: string;
|
|
58
|
+
}>;
|
|
59
|
+
archived: string[];
|
|
60
|
+
}
|
|
61
|
+
/** JSON-file-backed store for workflows and assets. */
|
|
62
|
+
export declare class ComfyUIStore {
|
|
63
|
+
private readonly maxAssets;
|
|
64
|
+
private readonly workflowsPath;
|
|
65
|
+
private readonly assetsPath;
|
|
66
|
+
private readonly trackedPath;
|
|
67
|
+
private readonly mediaSizesPath;
|
|
68
|
+
private readonly mediaHashesPath;
|
|
69
|
+
private readonly currentImagePath;
|
|
70
|
+
constructor(dir: string, maxAssets: number);
|
|
71
|
+
/** Ensure the data directory exists. */
|
|
72
|
+
init(): Promise<void>;
|
|
73
|
+
/** Pixel sizes of files uploaded through the panel, keyed by file name —
|
|
74
|
+
* used to default the workflow output size to the source image. */
|
|
75
|
+
loadMediaSizes(): Promise<Record<string, {
|
|
76
|
+
width: number;
|
|
77
|
+
height: number;
|
|
78
|
+
}>>;
|
|
79
|
+
saveMediaSize(name: string, size: {
|
|
80
|
+
width: number;
|
|
81
|
+
height: number;
|
|
82
|
+
}): Promise<void>;
|
|
83
|
+
/** Content-hash → file name index: re-uploading identical bytes reuses the
|
|
84
|
+
* existing file instead of creating a duplicate. */
|
|
85
|
+
loadMediaHashes(): Promise<Record<string, string>>;
|
|
86
|
+
lookupMediaHash(hash: string): Promise<string | undefined>;
|
|
87
|
+
saveMediaHash(hash: string, name: string): Promise<void>;
|
|
88
|
+
/** The load-area selection (survives restarts so image-to-image keeps its default source image). */
|
|
89
|
+
loadCurrentImage(): Promise<CurrentImage | undefined>;
|
|
90
|
+
saveCurrentImage(image: CurrentImage): Promise<void>;
|
|
91
|
+
listWorkflows(): Promise<StoredWorkflow[]>;
|
|
92
|
+
getWorkflow(id: string): Promise<StoredWorkflow | undefined>;
|
|
93
|
+
private writeWorkflows;
|
|
94
|
+
/** Create or update a workflow (update when `input.id` matches an existing one). */
|
|
95
|
+
saveWorkflow(input: {
|
|
96
|
+
id?: string;
|
|
97
|
+
name: string;
|
|
98
|
+
description: string;
|
|
99
|
+
workflow: unknown;
|
|
100
|
+
parameters?: WorkflowParameter[];
|
|
101
|
+
tags?: string[];
|
|
102
|
+
source?: 'user' | 'comfyui';
|
|
103
|
+
comfyuiFile?: string;
|
|
104
|
+
}): Promise<{
|
|
105
|
+
ok: true;
|
|
106
|
+
workflow: StoredWorkflow;
|
|
107
|
+
} | {
|
|
108
|
+
ok: false;
|
|
109
|
+
error: string;
|
|
110
|
+
}>;
|
|
111
|
+
/** Delete a workflow by id; false when it did not exist. */
|
|
112
|
+
deleteWorkflow(id: string): Promise<boolean>;
|
|
113
|
+
listAssets(): Promise<AssetRecord[]>;
|
|
114
|
+
/** Prepend an asset record (deduplicated by promptId, capped at maxAssets). */
|
|
115
|
+
appendAsset(record: AssetRecord): Promise<void>;
|
|
116
|
+
/** Load the persisted queue-tracker state (empty when absent/corrupt). */
|
|
117
|
+
loadTracked(): Promise<TrackedState>;
|
|
118
|
+
/** Persist the queue-tracker state so completed runs survive restarts. */
|
|
119
|
+
saveTracked(state: TrackedState): Promise<void>;
|
|
120
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable plugin data: the saved-workflow library and the generated-asset
|
|
3
|
+
* index, both persisted as JSON files under the plugin data directory
|
|
4
|
+
* (`dataDir`, default DSH_HOME/data/dsh-comfyui). ComfyUI's own history is
|
|
5
|
+
* ephemeral, so the asset index is the plugin's memory of what it generated.
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
/** Validate a workflow-shaped value; returns an error message or undefined. */
|
|
11
|
+
export function validateWorkflow(value) {
|
|
12
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
13
|
+
return 'workflow must be an object mapping node id → { class_type, inputs }';
|
|
14
|
+
}
|
|
15
|
+
const entries = Object.entries(value);
|
|
16
|
+
if (entries.length === 0)
|
|
17
|
+
return 'workflow must contain at least one node';
|
|
18
|
+
for (const [id, node] of entries) {
|
|
19
|
+
if (typeof node !== 'object' || node === null)
|
|
20
|
+
return `node "${id}" must be an object`;
|
|
21
|
+
const record = node;
|
|
22
|
+
if (typeof record.class_type !== 'string' || record.class_type === '') {
|
|
23
|
+
return `node "${id}" needs a non-empty class_type`;
|
|
24
|
+
}
|
|
25
|
+
if (typeof record.inputs !== 'object' || record.inputs === null) {
|
|
26
|
+
return `node "${id}" needs an inputs object`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function sanitizeText(value, max) {
|
|
32
|
+
return typeof value === 'string' ? value.slice(0, max) : '';
|
|
33
|
+
}
|
|
34
|
+
async function readJsonFile(path, fallback) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return fallback;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** JSON-file-backed store for workflows and assets. */
|
|
44
|
+
export class ComfyUIStore {
|
|
45
|
+
maxAssets;
|
|
46
|
+
workflowsPath;
|
|
47
|
+
assetsPath;
|
|
48
|
+
trackedPath;
|
|
49
|
+
mediaSizesPath;
|
|
50
|
+
mediaHashesPath;
|
|
51
|
+
currentImagePath;
|
|
52
|
+
constructor(dir, maxAssets) {
|
|
53
|
+
this.maxAssets = maxAssets;
|
|
54
|
+
this.workflowsPath = join(dir, 'workflows.json');
|
|
55
|
+
this.assetsPath = join(dir, 'assets.json');
|
|
56
|
+
this.trackedPath = join(dir, 'tracked.json');
|
|
57
|
+
this.mediaSizesPath = join(dir, 'media-sizes.json');
|
|
58
|
+
this.mediaHashesPath = join(dir, 'media-hashes.json');
|
|
59
|
+
this.currentImagePath = join(dir, 'current-image.json');
|
|
60
|
+
}
|
|
61
|
+
/** Ensure the data directory exists. */
|
|
62
|
+
async init() {
|
|
63
|
+
await mkdir(dirname(this.workflowsPath), { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
/** Pixel sizes of files uploaded through the panel, keyed by file name —
|
|
66
|
+
* used to default the workflow output size to the source image. */
|
|
67
|
+
async loadMediaSizes() {
|
|
68
|
+
const parsed = await readJsonFile(this.mediaSizesPath, {});
|
|
69
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
70
|
+
}
|
|
71
|
+
async saveMediaSize(name, size) {
|
|
72
|
+
const sizes = await this.loadMediaSizes();
|
|
73
|
+
sizes[name] = size;
|
|
74
|
+
await writeFile(this.mediaSizesPath, `${JSON.stringify(sizes, null, 2)}\n`, 'utf8');
|
|
75
|
+
}
|
|
76
|
+
/** Content-hash → file name index: re-uploading identical bytes reuses the
|
|
77
|
+
* existing file instead of creating a duplicate. */
|
|
78
|
+
async loadMediaHashes() {
|
|
79
|
+
const parsed = await readJsonFile(this.mediaHashesPath, {});
|
|
80
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
81
|
+
}
|
|
82
|
+
async lookupMediaHash(hash) {
|
|
83
|
+
return (await this.loadMediaHashes())[hash];
|
|
84
|
+
}
|
|
85
|
+
async saveMediaHash(hash, name) {
|
|
86
|
+
const hashes = await this.loadMediaHashes();
|
|
87
|
+
hashes[hash] = name;
|
|
88
|
+
await writeFile(this.mediaHashesPath, `${JSON.stringify(hashes, null, 2)}\n`, 'utf8');
|
|
89
|
+
}
|
|
90
|
+
/** The load-area selection (survives restarts so image-to-image keeps its default source image). */
|
|
91
|
+
async loadCurrentImage() {
|
|
92
|
+
const parsed = await readJsonFile(this.currentImagePath, undefined);
|
|
93
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
94
|
+
return undefined;
|
|
95
|
+
if (typeof parsed.name !== 'string' || parsed.name === '')
|
|
96
|
+
return undefined;
|
|
97
|
+
return {
|
|
98
|
+
name: parsed.name,
|
|
99
|
+
kind: parsed.kind === 'video' || parsed.kind === 'audio' ? parsed.kind : 'image',
|
|
100
|
+
source: parsed.source === 'generated' ? 'generated' : 'imported',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async saveCurrentImage(image) {
|
|
104
|
+
await writeFile(this.currentImagePath, `${JSON.stringify(image, null, 2)}\n`, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
async listWorkflows() {
|
|
107
|
+
const list = await readJsonFile(this.workflowsPath, []);
|
|
108
|
+
return Array.isArray(list) ? list : [];
|
|
109
|
+
}
|
|
110
|
+
async getWorkflow(id) {
|
|
111
|
+
return (await this.listWorkflows()).find((workflow) => workflow.id === id);
|
|
112
|
+
}
|
|
113
|
+
async writeWorkflows(list) {
|
|
114
|
+
await writeFile(this.workflowsPath, `${JSON.stringify(list, null, 2)}\n`, 'utf8');
|
|
115
|
+
}
|
|
116
|
+
/** Create or update a workflow (update when `input.id` matches an existing one). */
|
|
117
|
+
async saveWorkflow(input) {
|
|
118
|
+
const problem = validateWorkflow(input.workflow);
|
|
119
|
+
if (problem !== undefined)
|
|
120
|
+
return { ok: false, error: problem };
|
|
121
|
+
const name = sanitizeText(input.name, 80) || 'unnamed-workflow';
|
|
122
|
+
const description = sanitizeText(input.description, 2000);
|
|
123
|
+
const workflow = input.workflow;
|
|
124
|
+
const parameters = Array.isArray(input.parameters) && input.parameters.length > 0 ? input.parameters : undefined;
|
|
125
|
+
const tags = Array.isArray(input.tags)
|
|
126
|
+
? [...new Set(input.tags.map((tag) => sanitizeText(tag, 24)).filter((tag) => tag !== ''))]
|
|
127
|
+
: undefined;
|
|
128
|
+
const now = new Date().toISOString();
|
|
129
|
+
const list = await this.listWorkflows();
|
|
130
|
+
if (input.id !== undefined) {
|
|
131
|
+
const index = list.findIndex((entry) => entry.id === input.id);
|
|
132
|
+
if (index === -1)
|
|
133
|
+
return { ok: false, error: `workflow "${input.id}" not found` };
|
|
134
|
+
const updated = {
|
|
135
|
+
...list[index], name, description, workflow,
|
|
136
|
+
parameters,
|
|
137
|
+
tags: tags !== undefined ? tags : list[index].tags,
|
|
138
|
+
source: input.source ?? list[index].source,
|
|
139
|
+
comfyuiFile: input.comfyuiFile ?? list[index].comfyuiFile,
|
|
140
|
+
updatedAt: now,
|
|
141
|
+
};
|
|
142
|
+
list[index] = updated;
|
|
143
|
+
await this.writeWorkflows(list);
|
|
144
|
+
return { ok: true, workflow: updated };
|
|
145
|
+
}
|
|
146
|
+
const created = {
|
|
147
|
+
id: randomUUID(), name, description, workflow,
|
|
148
|
+
parameters,
|
|
149
|
+
tags: tags !== undefined && tags.length > 0 ? tags : undefined,
|
|
150
|
+
source: input.source,
|
|
151
|
+
comfyuiFile: input.comfyuiFile,
|
|
152
|
+
updatedAt: now,
|
|
153
|
+
};
|
|
154
|
+
list.push(created);
|
|
155
|
+
await this.writeWorkflows(list);
|
|
156
|
+
return { ok: true, workflow: created };
|
|
157
|
+
}
|
|
158
|
+
/** Delete a workflow by id; false when it did not exist. */
|
|
159
|
+
async deleteWorkflow(id) {
|
|
160
|
+
const list = await this.listWorkflows();
|
|
161
|
+
const next = list.filter((entry) => entry.id !== id);
|
|
162
|
+
if (next.length === list.length)
|
|
163
|
+
return false;
|
|
164
|
+
await this.writeWorkflows(next);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
async listAssets() {
|
|
168
|
+
const list = await readJsonFile(this.assetsPath, []);
|
|
169
|
+
return Array.isArray(list) ? list : [];
|
|
170
|
+
}
|
|
171
|
+
/** Prepend an asset record (deduplicated by promptId, capped at maxAssets). */
|
|
172
|
+
async appendAsset(record) {
|
|
173
|
+
const list = await this.listAssets();
|
|
174
|
+
if (list.some((entry) => entry.promptId === record.promptId))
|
|
175
|
+
return;
|
|
176
|
+
list.unshift(record);
|
|
177
|
+
if (list.length > this.maxAssets)
|
|
178
|
+
list.length = this.maxAssets;
|
|
179
|
+
await writeFile(this.assetsPath, `${JSON.stringify(list, null, 2)}\n`, 'utf8');
|
|
180
|
+
}
|
|
181
|
+
/** Load the persisted queue-tracker state (empty when absent/corrupt). */
|
|
182
|
+
async loadTracked() {
|
|
183
|
+
const data = await readJsonFile(this.trackedPath, {});
|
|
184
|
+
const runs = Array.isArray(data.runs) ? data.runs.filter((run) => typeof run === 'object' && run !== null && typeof run.promptId === 'string')
|
|
185
|
+
: [];
|
|
186
|
+
const archived = Array.isArray(data.archived) ? data.archived.filter((id) => typeof id === 'string') : [];
|
|
187
|
+
return { runs, archived };
|
|
188
|
+
}
|
|
189
|
+
/** Persist the queue-tracker state so completed runs survive restarts. */
|
|
190
|
+
async saveTracked(state) {
|
|
191
|
+
await writeFile(this.trackedPath, `${JSON.stringify(state)}\n`, 'utf8');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in ComfyUI workflow templates in API format (node id → class_type +
|
|
3
|
+
* inputs). `txt2img` and `img2img` use only core ComfyUI nodes; `video` is a
|
|
4
|
+
* Wan 2.1 text-to-video skeleton that requires the ComfyUI-WanVideoWrapper
|
|
5
|
+
* custom nodes and matching model files. The `guide` field is shown to the
|
|
6
|
+
* model so it can override the right node inputs.
|
|
7
|
+
*/
|
|
8
|
+
export interface WorkflowTemplate {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
guide: string;
|
|
13
|
+
workflow: Record<string, {
|
|
14
|
+
class_type: string;
|
|
15
|
+
inputs: Record<string, unknown>;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export declare const TEMPLATES: WorkflowTemplate[];
|
|
19
|
+
/** Look up a template by id. */
|
|
20
|
+
export declare function findTemplate(id: string): WorkflowTemplate | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Merge per-node input overrides into a workflow copy. Each entry maps a node
|
|
23
|
+
* id to a partial inputs object; later entries observe earlier merges.
|
|
24
|
+
*/
|
|
25
|
+
export declare function applyTemplateInputs(workflow: Record<string, {
|
|
26
|
+
class_type: string;
|
|
27
|
+
inputs: Record<string, unknown>;
|
|
28
|
+
}>, overrides: Record<string, Record<string, unknown>>): void;
|
|
29
|
+
/** Clone a template workflow so callers never mutate the shared constant. */
|
|
30
|
+
export declare function cloneWorkflow(workflow: Record<string, {
|
|
31
|
+
class_type: string;
|
|
32
|
+
inputs: Record<string, unknown>;
|
|
33
|
+
}>): Record<string, {
|
|
34
|
+
class_type: string;
|
|
35
|
+
inputs: Record<string, unknown>;
|
|
36
|
+
}>;
|
package/lib/templates.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in ComfyUI workflow templates in API format (node id → class_type +
|
|
3
|
+
* inputs). `txt2img` and `img2img` use only core ComfyUI nodes; `video` is a
|
|
4
|
+
* Wan 2.1 text-to-video skeleton that requires the ComfyUI-WanVideoWrapper
|
|
5
|
+
* custom nodes and matching model files. The `guide` field is shown to the
|
|
6
|
+
* model so it can override the right node inputs.
|
|
7
|
+
*/
|
|
8
|
+
export const TEMPLATES = [
|
|
9
|
+
{
|
|
10
|
+
id: 'txt2img',
|
|
11
|
+
name: 'SDXL text-to-image',
|
|
12
|
+
description: 'Generate an image from a text prompt using a standard SDXL checkpoint.',
|
|
13
|
+
guide: 'Node ids: 4 checkpoint (ckpt_name), 5 EmptyLatentImage (width/height/batch_size), 6 positive CLIPTextEncode (text), 7 negative CLIPTextEncode (text), 3 KSampler (seed/steps/cfg/denoise), 9 SaveImage (filename_prefix). Override 6.text with the prompt and 7.text with negative prompt; set a random 3.seed for variety.',
|
|
14
|
+
workflow: {
|
|
15
|
+
'3': {
|
|
16
|
+
class_type: 'KSampler',
|
|
17
|
+
inputs: { seed: 0, steps: 20, cfg: 8, sampler_name: 'euler', scheduler: 'normal', denoise: 1, model: ['4', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0] },
|
|
18
|
+
},
|
|
19
|
+
'4': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'sd_xl_base_1.0.safetensors' } },
|
|
20
|
+
'5': { class_type: 'EmptyLatentImage', inputs: { width: 1024, height: 1024, batch_size: 1 } },
|
|
21
|
+
'6': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['4', 1] } },
|
|
22
|
+
'7': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['4', 1] } },
|
|
23
|
+
'8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
|
|
24
|
+
'9': { class_type: 'SaveImage', inputs: { filename_prefix: 'dsh-comfyui', images: ['8', 0] } },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'img2img',
|
|
29
|
+
name: 'SDXL image-to-image',
|
|
30
|
+
description: 'Edit or restyle an input image with a text prompt and denoise strength.',
|
|
31
|
+
guide: 'Node ids: 4 checkpoint, 10 LoadImage (image), 11 VAEEncode, 6 positive text, 7 negative text, 3 KSampler (denoise controls how much the input changes, 0..1; seed/steps/cfg), 9 SaveImage. Put the input image filename in 10.image, the prompt in 6.text.',
|
|
32
|
+
workflow: {
|
|
33
|
+
'3': {
|
|
34
|
+
class_type: 'KSampler',
|
|
35
|
+
inputs: { seed: 0, steps: 20, cfg: 7, sampler_name: 'euler', scheduler: 'normal', denoise: 0.6, model: ['4', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['11', 0] },
|
|
36
|
+
},
|
|
37
|
+
'4': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'sd_xl_base_1.0.safetensors' } },
|
|
38
|
+
'6': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['4', 1] } },
|
|
39
|
+
'7': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['4', 1] } },
|
|
40
|
+
'8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
|
|
41
|
+
'9': { class_type: 'SaveImage', inputs: { filename_prefix: 'dsh-comfyui', images: ['8', 0] } },
|
|
42
|
+
'10': { class_type: 'LoadImage', inputs: { image: 'example.png' } },
|
|
43
|
+
'11': { class_type: 'VAEEncode', inputs: { pixels: ['10', 0], vae: ['4', 2] } },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'video',
|
|
48
|
+
name: 'Wan 2.1 text-to-video',
|
|
49
|
+
description: 'Generate a short video from a text prompt (requires ComfyUI-WanVideoWrapper custom nodes and Wan 2.1 models).',
|
|
50
|
+
guide: 'Node ids: 10 UNETLoader (unet_name), 11 CLIPLoader, 12 VAELoader, 13 WanTextEncode (prompt/negative_prompt), 14 WanImageToVideo (width/height/length frames/batch_size), 15 KSampler (seed/steps/cfg), 17 SaveVideo (filename_prefix). Requires the ComfyUI-WanVideoWrapper custom nodes and downloaded Wan 2.1 checkpoints; verify node names with comfyui_object_info if your install differs.',
|
|
51
|
+
workflow: {
|
|
52
|
+
'10': { class_type: 'UNETLoader', inputs: { unet_name: 'wan2.1_t2v_14B_fp8_e4m3fn.safetensors', weight_dtype: 'fp8_e4m3fn' } },
|
|
53
|
+
'11': { class_type: 'CLIPLoader', inputs: { clip_name: 'wan2.1_t2v_14B_clip.safetensors', type: 'wan' } },
|
|
54
|
+
'12': { class_type: 'VAELoader', inputs: { vae_name: 'wan_2.1_vae.safetensors' } },
|
|
55
|
+
'13': { class_type: 'WanTextEncode', inputs: { prompt: '', negative_prompt: '', clip: ['11', 0] } },
|
|
56
|
+
'14': { class_type: 'WanImageToVideo', inputs: { positive: ['13', 0], negative: ['13', 1], vae: ['12', 0], width: 832, height: 480, length: 81, batch_size: 1 } },
|
|
57
|
+
'15': { class_type: 'KSampler', inputs: { seed: 0, steps: 20, cfg: 6, sampler_name: 'euler', scheduler: 'simple', denoise: 1, model: ['10', 0], positive: ['13', 0], negative: ['13', 1], latent_image: ['14', 0] } },
|
|
58
|
+
'16': { class_type: 'WanVideoDecode', inputs: { samples: ['15', 0], vae: ['12', 0] } },
|
|
59
|
+
'17': { class_type: 'SaveVideo', inputs: { filename_prefix: 'dsh-comfyui', video: ['16', 0] } },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
/** Look up a template by id. */
|
|
64
|
+
export function findTemplate(id) {
|
|
65
|
+
return TEMPLATES.find((template) => template.id === id);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Merge per-node input overrides into a workflow copy. Each entry maps a node
|
|
69
|
+
* id to a partial inputs object; later entries observe earlier merges.
|
|
70
|
+
*/
|
|
71
|
+
export function applyTemplateInputs(workflow, overrides) {
|
|
72
|
+
for (const [nodeId, partial] of Object.entries(overrides)) {
|
|
73
|
+
const node = workflow[nodeId];
|
|
74
|
+
if (node === undefined)
|
|
75
|
+
continue;
|
|
76
|
+
if (typeof partial !== 'object' || partial === null)
|
|
77
|
+
continue;
|
|
78
|
+
node.inputs = { ...node.inputs, ...partial };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Clone a template workflow so callers never mutate the shared constant. */
|
|
82
|
+
export function cloneWorkflow(workflow) {
|
|
83
|
+
return JSON.parse(JSON.stringify(workflow));
|
|
84
|
+
}
|