animastor-comfyui-workflow-connector 0.1.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.
@@ -0,0 +1,340 @@
1
+ // ======================================================
2
+ // Entity Schema — Animastor Data Types
3
+ // ======================================================
4
+ // Defines the catalog of all data entities that can flow
5
+ // between backend and ComfyUI workflows.
6
+ //
7
+ // Each entity has:
8
+ // key — canonical identifier (used in connectors)
9
+ // label — human-readable name (for future UI)
10
+ // type — data type (string, image, audio, video, int, float, number)
11
+ // kind — 'input' | 'output' | 'parameter' | 'output'
12
+ //
13
+ // This schema serves as the foundation for:
14
+ // - Connector-to-workflow mapping validation
15
+ // - Future UI workflow configurator
16
+ // - Parameter editing interface
17
+
18
+ const ENTITIES = {
19
+ // ─── Text / Prompt ──────────────────────────────
20
+ positivePrompt: {
21
+ key: 'positivePrompt',
22
+ label: 'Positive Prompt',
23
+ type: 'string',
24
+ kind: 'input',
25
+ description: 'Main text prompt for generation (image or video)'
26
+ },
27
+ negativePrompt: {
28
+ key: 'negativePrompt',
29
+ label: 'Negative Prompt',
30
+ type: 'string',
31
+ kind: 'input',
32
+ description: 'Negative / undesired content prompt'
33
+ },
34
+ narrationText: {
35
+ key: 'narrationText',
36
+ label: 'Narration Text',
37
+ type: 'string',
38
+ kind: 'input',
39
+ description: 'Text to be synthesized as narration speech'
40
+ },
41
+ voiceInstruction: {
42
+ key: 'voiceInstruction',
43
+ label: 'Voice Instruction',
44
+ type: 'string',
45
+ kind: 'input',
46
+ description: 'Natural language description of the desired voice'
47
+ },
48
+ dialogueScript: {
49
+ key: 'dialogueScript',
50
+ label: 'Dialogue Script',
51
+ type: 'string',
52
+ kind: 'input',
53
+ description: 'Multi-character dialogue script with role markers'
54
+ },
55
+ defaultInstruct: {
56
+ key: 'defaultInstruct',
57
+ label: 'Default Instruction',
58
+ type: 'string',
59
+ kind: 'input',
60
+ description: 'Default TTS instruction for dialogue'
61
+ },
62
+
63
+ // ─── Character Voices ───────────────────────────
64
+ character1Voice: {
65
+ key: 'character1Voice',
66
+ label: 'Character 1 Voice',
67
+ type: 'string',
68
+ kind: 'input',
69
+ description: 'Voice instruction for dialogue character 1'
70
+ },
71
+ character2Voice: {
72
+ key: 'character2Voice',
73
+ label: 'Character 2 Voice',
74
+ type: 'string',
75
+ kind: 'input',
76
+ description: 'Voice instruction for dialogue character 2'
77
+ },
78
+ roleName1: {
79
+ key: 'roleName1',
80
+ label: 'Role Name 1',
81
+ type: 'string',
82
+ kind: 'input',
83
+ description: 'Name/ID of dialogue character 1'
84
+ },
85
+ roleName2: {
86
+ key: 'roleName2',
87
+ label: 'Role Name 2',
88
+ type: 'string',
89
+ kind: 'input',
90
+ description: 'Name/ID of dialogue character 2'
91
+ },
92
+ character3Voice: {
93
+ key: 'character3Voice',
94
+ label: 'Character 3 Voice',
95
+ type: 'string',
96
+ kind: 'input',
97
+ description: 'Voice instruction for dialogue character 3'
98
+ },
99
+ roleName3: {
100
+ key: 'roleName3',
101
+ label: 'Role Name 3',
102
+ type: 'string',
103
+ kind: 'input',
104
+ description: 'Name/ID of dialogue character 3'
105
+ },
106
+
107
+ // ─── Images ─────────────────────────────────────
108
+ sourceImage: {
109
+ key: 'sourceImage',
110
+ label: 'Source Image',
111
+ type: 'image',
112
+ kind: 'input',
113
+ description: 'Single source image for conditioning'
114
+ },
115
+ sourceImages: {
116
+ key: 'sourceImages',
117
+ label: 'Source Images',
118
+ type: 'image[]',
119
+ kind: 'input',
120
+ description: 'Array of source images for multi-image workflows',
121
+ arrayLength: 4
122
+ },
123
+ mask: {
124
+ key: 'mask',
125
+ label: 'Mask',
126
+ type: 'image',
127
+ kind: 'input',
128
+ description: 'Binary mask for inpainting / compositing'
129
+ },
130
+ characterImage: {
131
+ key: 'characterImage',
132
+ label: 'Character Image',
133
+ type: 'image',
134
+ kind: 'input',
135
+ description: 'Character reference image for consistent appearance'
136
+ },
137
+ coverImage: {
138
+ key: 'coverImage',
139
+ label: 'Cover Image',
140
+ type: 'image',
141
+ kind: 'input',
142
+ description: 'Book cover image'
143
+ },
144
+
145
+ // ─── Audio ──────────────────────────────────────
146
+ audio: {
147
+ key: 'audio',
148
+ label: 'Audio',
149
+ type: 'audio',
150
+ kind: 'input',
151
+ description: 'Audio input for conditioning or merging'
152
+ },
153
+
154
+ // ─── Generated Outputs ──────────────────────────
155
+ generatedImage: {
156
+ key: 'generatedImage',
157
+ label: 'Generated Image',
158
+ type: 'image',
159
+ kind: 'output',
160
+ description: 'Image generated by the workflow'
161
+ },
162
+ generatedVideo: {
163
+ key: 'generatedVideo',
164
+ label: 'Generated Video',
165
+ type: 'video',
166
+ kind: 'output',
167
+ description: 'Video generated by the workflow'
168
+ },
169
+ generatedAudio: {
170
+ key: 'generatedAudio',
171
+ label: 'Generated Audio',
172
+ type: 'audio',
173
+ kind: 'output',
174
+ description: 'Audio generated by the workflow'
175
+ },
176
+ videoFrames: {
177
+ key: 'videoFrames',
178
+ label: 'Video Frames',
179
+ type: 'video',
180
+ kind: 'output',
181
+ description: 'Raw video frames from LTX model'
182
+ },
183
+
184
+ // ─── Parameters ─────────────────────────────────
185
+ totalFrames: {
186
+ key: 'totalFrames',
187
+ label: 'Total Frames',
188
+ type: 'int',
189
+ kind: 'parameter',
190
+ description: 'Total number of video frames to generate'
191
+ },
192
+ frameRate: {
193
+ key: 'frameRate',
194
+ label: 'Frame Rate',
195
+ type: 'float',
196
+ kind: 'parameter',
197
+ description: 'Video frame rate (FPS)'
198
+ },
199
+ width: {
200
+ key: 'width',
201
+ label: 'Width',
202
+ type: 'int',
203
+ kind: 'parameter',
204
+ description: 'Output image/video width in pixels'
205
+ },
206
+ height: {
207
+ key: 'height',
208
+ label: 'Height',
209
+ type: 'int',
210
+ kind: 'parameter',
211
+ description: 'Output image/video height in pixels'
212
+ },
213
+ steps: {
214
+ key: 'steps',
215
+ label: 'Steps',
216
+ type: 'int',
217
+ kind: 'parameter',
218
+ description: 'Number of diffusion sampling steps'
219
+ },
220
+ cfg: {
221
+ key: 'cfg',
222
+ label: 'CFG Scale',
223
+ type: 'float',
224
+ kind: 'parameter',
225
+ description: 'Classifier-free guidance scale'
226
+ },
227
+ sampler: {
228
+ key: 'sampler',
229
+ label: 'Sampler',
230
+ type: 'string',
231
+ kind: 'parameter',
232
+ description: 'Sampling method (euler, dpmpp_2m, etc.)'
233
+ },
234
+ scheduler: {
235
+ key: 'scheduler',
236
+ label: 'Scheduler',
237
+ type: 'string',
238
+ kind: 'parameter',
239
+ description: 'Noise scheduler (normal, karras, etc.)'
240
+ },
241
+ seed: {
242
+ key: 'seed',
243
+ label: 'Seed',
244
+ type: 'int',
245
+ kind: 'parameter',
246
+ description: 'Random seed for reproducibility'
247
+ },
248
+ outputFilenamePrefix: {
249
+ key: 'outputFilenamePrefix',
250
+ label: 'Output Filename Prefix',
251
+ type: 'string',
252
+ kind: 'parameter',
253
+ description: 'Prefix for output file names'
254
+ },
255
+ fps: {
256
+ key: 'fps',
257
+ label: 'FPS',
258
+ type: 'int',
259
+ kind: 'parameter',
260
+ description: 'Frames per second for video'
261
+ },
262
+ quality: {
263
+ key: 'quality',
264
+ label: 'Quality',
265
+ type: 'string',
266
+ kind: 'parameter',
267
+ description: 'Output quality setting (e.g. 320k for audio)'
268
+ },
269
+ language: {
270
+ key: 'language',
271
+ label: 'Language',
272
+ type: 'string',
273
+ kind: 'parameter',
274
+ description: 'Language for TTS output (e.g. Russian, English)'
275
+ },
276
+ temperature: {
277
+ key: 'temperature',
278
+ label: 'Temperature',
279
+ type: 'float',
280
+ kind: 'parameter',
281
+ description: 'Sampling temperature for generation randomness'
282
+ },
283
+
284
+ // ─── Guide Frame (LTX) ──────────────────────────
285
+ guideFrameIndex: {
286
+ key: 'guideFrameIndex',
287
+ label: 'Guide Frame Index',
288
+ type: 'int',
289
+ kind: 'parameter',
290
+ description: 'Frame index for LTXVAddGuide node'
291
+ },
292
+ guideStrength: {
293
+ key: 'guideStrength',
294
+ label: 'Guide Strength',
295
+ type: 'float',
296
+ kind: 'parameter',
297
+ description: 'Strength of the guide image influence'
298
+ }
299
+ };
300
+
301
+ // ─── Quick Lookup ─────────────────────────────────
302
+ const BY_KEY = {};
303
+
304
+ for (const [key, def] of Object.entries(ENTITIES)) {
305
+ BY_KEY[key] = def;
306
+ }
307
+
308
+ /**
309
+ * Look up an entity definition by its key.
310
+ * @param {string} key
311
+ * @returns {object|undefined}
312
+ */
313
+ function getEntity(key) {
314
+ return BY_KEY[key];
315
+ }
316
+
317
+ /**
318
+ * Return all entities of a given kind.
319
+ * @param {'input'|'output'|'parameter'} kind
320
+ * @returns {object[]}
321
+ */
322
+ function getEntitiesByKind(kind) {
323
+ return Object.values(ENTITIES).filter(e => e.kind === kind);
324
+ }
325
+
326
+ /**
327
+ * Return all entities of a given data type.
328
+ * @param {string} type
329
+ * @returns {object[]}
330
+ */
331
+ function getEntitiesByType(type) {
332
+ return Object.values(ENTITIES).filter(e => e.type === type);
333
+ }
334
+
335
+ module.exports = {
336
+ ENTITIES,
337
+ getEntity,
338
+ getEntitiesByKind,
339
+ getEntitiesByType
340
+ };
package/src/index.js ADDED
@@ -0,0 +1,265 @@
1
+ // ======================================================
2
+ // Connector API — public surface of the
3
+ // animastor-comfyui-workflow-connector package
4
+ // ======================================================
5
+ // A small dependency-injected adapter over the mapping core
6
+ // (workflow-loader + connector-loader + entity-schema). It presents the
7
+ // API the package exposes to its host:
8
+ //
9
+ // createWorkflowConnector({ workflowsDir, connectorsDir, logger })
10
+ // .listWorkflows() → [{ name, hash, type, label, hasConnector, compatible }]
11
+ // .getWorkflow(name) → deep-cloned workflow JSON
12
+ // .getConnector(name) → entity-level connector VIEW (no nodeIds/fields)
13
+ // .validate(name) → { compatible, warnings }
14
+ // .build({ workflow, inputs, parameters }) → { workflowJson, workflowHash }
15
+ //
16
+ // Extraction invariants:
17
+ // - ComfyUI node ids, field paths and the raw connector JSON format are
18
+ // NOT part of this API. Binding application happens inside build().
19
+ // - No dispatch, no queues, no GPU Hub, no DB/Redis, no business logic:
20
+ // this package may only depend on node builtins
21
+ // (guarded by tests/connector-core.test.js + the host architecture
22
+ // suite tests/architecture/comfyui-connector-core-boundary.test.js).
23
+ // - The host owns WHERE assets live (injected dirs) and HOW jobs are
24
+ // dispatched (gpu-dispatcher stays host-side).
25
+
26
+ const wfLoader = require('./workflow-loader');
27
+ const connectorLoader = require('./connector-loader');
28
+
29
+ // ─── Typed errors ───────────────────────────────────
30
+
31
+ class ConnectorApiError extends Error {
32
+ constructor(message, code) {
33
+ super(message);
34
+ this.name = this.constructor.name;
35
+ this.code = code;
36
+ }
37
+ }
38
+
39
+ /** Requested workflow is not loaded. */
40
+ class WorkflowNotFoundError extends ConnectorApiError {
41
+ constructor(name) { super(`Workflow not found: ${name}`, 'WORKFLOW_NOT_FOUND'); }
42
+ }
43
+
44
+ /** Workflow has no connector registered (connectors are mandatory). */
45
+ class ConnectorMissingError extends ConnectorApiError {
46
+ constructor(name) {
47
+ super(`No connector registered for workflow "${name}". Connectors are mandatory for build().`,
48
+ 'CONNECTOR_MISSING');
49
+ }
50
+ }
51
+
52
+ /** Workflow ↔ connector compatibility check failed (hash / node structure drift). */
53
+ class IncompatibleWorkflowError extends ConnectorApiError {
54
+ constructor(name, warnings) {
55
+ super(`Workflow "${name}" is incompatible with its connector:\n - ${(warnings || []).join('\n - ')}`,
56
+ 'INCOMPATIBLE_WORKFLOW');
57
+ this.warnings = warnings || [];
58
+ }
59
+ }
60
+
61
+ /** build() received inputs/parameters that the connector does not map. */
62
+ class BuildError extends ConnectorApiError {
63
+ constructor(message) { super(message, 'BUILD_FAILED'); }
64
+ }
65
+
66
+ // ─── Connector view (entity-level, node-id-free) ────
67
+
68
+ /**
69
+ * Strip ComfyUI-specific internals (nodeId, field, expectedClass) from a
70
+ * binding section, keeping only the entity-level metadata.
71
+ */
72
+ function sanitizeBindings(section) {
73
+ if (!section || typeof section !== 'object') return {};
74
+ const view = {};
75
+ for (const [key, binding] of Object.entries(section)) {
76
+ if (!binding || typeof binding !== 'object') continue;
77
+ if (binding.type === 'multi' && Array.isArray(binding.bindings)) {
78
+ view[key] = {
79
+ type: 'multi',
80
+ entityType: binding.entityType || key,
81
+ label: binding.label || key,
82
+ required: !!binding.required,
83
+ count: binding.bindings.length,
84
+ };
85
+ continue;
86
+ }
87
+ view[key] = {
88
+ entityType: binding.entityType || key,
89
+ label: binding.label || key,
90
+ required: !!binding.required,
91
+ };
92
+ if (binding.default !== undefined) view[key].default = binding.default;
93
+ if (binding.min !== undefined) view[key].min = binding.min;
94
+ if (binding.max !== undefined) view[key].max = binding.max;
95
+ }
96
+ return view;
97
+ }
98
+
99
+ /**
100
+ * Entity-level connector view: everything a business consumer may know
101
+ * about a workflow's ports WITHOUT seeing ComfyUI node ids or field paths.
102
+ */
103
+ function toConnectorView(connector) {
104
+ if (!connector) return null;
105
+ return {
106
+ name: connector.workflow,
107
+ type: connector.type || 'unknown',
108
+ label: connector.label || connector.workflow,
109
+ description: connector.description || '',
110
+ version: connector.connectorVersion || '1.0.0',
111
+ profile: connector.profile || {},
112
+ inputs: sanitizeBindings(connector.inputs),
113
+ outputs: sanitizeBindings(connector.outputs),
114
+ parameters: sanitizeBindings(connector.parameters),
115
+ };
116
+ }
117
+
118
+ // ─── Public API factory ─────────────────────────────
119
+
120
+ /**
121
+ * Create the workflow-connector API instance.
122
+ *
123
+ * @param {{ workflowsDir?: string, connectorsDir?: string, logger?: object }} [options]
124
+ * workflowsDir/connectorsDir — injected asset directories (host-owned);
125
+ * logger — injected logger (defaults to console).
126
+ * @returns the API object (listWorkflows/getWorkflow/getConnector/validate/build/…)
127
+ */
128
+ function createWorkflowConnector({ workflowsDir, connectorsDir, logger } = {}) {
129
+ wfLoader.configure({ workflowsDir, logger });
130
+ connectorLoader.configure({ connectorsDir, logger });
131
+
132
+ // Load templates + connectors (connectors are mandatory: throws when a
133
+ // workflow lacks one — the same fail-closed semantics the host startup
134
+ // enforces via backend.cjs).
135
+ function load() {
136
+ return wfLoader.loadWorkflows();
137
+ }
138
+ load();
139
+
140
+ return {
141
+ load,
142
+
143
+ /** All loaded workflows with entity-level metadata (no node ids). */
144
+ listWorkflows() {
145
+ const wfMap = wfLoader.workflows || {};
146
+ return Object.keys(wfMap).map((name) => {
147
+ const connector = connectorLoader.getConnector(name);
148
+ let compatible = null;
149
+ if (connector) {
150
+ compatible = connectorLoader.checkCompatibility(connector, wfMap[name]).compatible;
151
+ }
152
+ return {
153
+ name,
154
+ hash: wfLoader.getWorkflowHash(name),
155
+ hasConnector: !!connector,
156
+ type: connector ? connector.type || 'unknown' : null,
157
+ label: connector ? connector.label || name : name,
158
+ compatible,
159
+ };
160
+ });
161
+ },
162
+
163
+ /** Deep-cloned workflow JSON by name. Throws WorkflowNotFoundError. */
164
+ getWorkflow(name) {
165
+ try {
166
+ return wfLoader.getWorkflow(name);
167
+ } catch (err) {
168
+ throw new WorkflowNotFoundError(name);
169
+ }
170
+ },
171
+
172
+ /**
173
+ * Entity-level connector VIEW for a workflow (no nodeIds/fields).
174
+ * Throws ConnectorMissingError when the workflow has no connector.
175
+ */
176
+ getConnector(name) {
177
+ const connector = connectorLoader.getConnector(name);
178
+ if (!connector) throw new ConnectorMissingError(name);
179
+ return toConnectorView(connector);
180
+ },
181
+
182
+ /**
183
+ * Workflow ↔ connector compatibility check.
184
+ * Throws WorkflowNotFoundError; returns { compatible, warnings }.
185
+ */
186
+ validate(name) {
187
+ const wfMap = wfLoader.workflows || {};
188
+ const wf = wfMap[name];
189
+ if (!wf) throw new WorkflowNotFoundError(name);
190
+ const connector = connectorLoader.getConnector(name);
191
+ if (!connector) throw new ConnectorMissingError(name);
192
+ return connectorLoader.checkCompatibility(connector, wf);
193
+ },
194
+
195
+ /**
196
+ * Build a runnable workflow JSON from entity-keyed inputs and
197
+ * parameters. Node ids and field paths stay inside the connector.
198
+ *
199
+ * @param {{ workflow: string, inputs?: object, parameters?: object }} request
200
+ * inputs — { entityKey: value } applied via connector bindings
201
+ * parameters — { entityKey: value }; connector defaults fill gaps
202
+ * @returns {{ workflowJson: object, workflowHash: string }}
203
+ */
204
+ build({ workflow, inputs = {}, parameters = {} } = {}) {
205
+ if (!workflow || typeof workflow !== 'string') {
206
+ throw new BuildError('build() requires a workflow name');
207
+ }
208
+
209
+ let wfJson;
210
+ try {
211
+ wfJson = wfLoader.getWorkflow(workflow);
212
+ } catch (err) {
213
+ throw new WorkflowNotFoundError(workflow);
214
+ }
215
+
216
+ const connector = connectorLoader.getConnector(workflow);
217
+ if (!connector) throw new ConnectorMissingError(workflow);
218
+
219
+ const compat = connectorLoader.checkCompatibility(connector, wfJson);
220
+ if (!compat.compatible) throw new IncompatibleWorkflowError(workflow, compat.warnings);
221
+
222
+ const unknownInputs = Object.keys(inputs).filter((k) => !connectorLoader.getBinding(connector, k));
223
+ if (unknownInputs.length > 0) {
224
+ throw new BuildError(`Unknown input key(s) for workflow "${workflow}": ${unknownInputs.join(', ')}`);
225
+ }
226
+ const unknownParams = Object.keys(parameters).filter((k) => !connector.parameters?.[k]);
227
+ if (unknownParams.length > 0) {
228
+ throw new BuildError(`Unknown parameter key(s) for workflow "${workflow}": ${unknownParams.join(', ')}`);
229
+ }
230
+
231
+ for (const [key, value] of Object.entries(inputs)) {
232
+ connectorLoader.setValue(wfJson, connector, key, value);
233
+ }
234
+ for (const [key, param] of Object.entries(connector.parameters || {})) {
235
+ const value = parameters[key] !== undefined ? parameters[key] : param.default;
236
+ if (value === undefined) continue;
237
+ connectorLoader.setValue(wfJson, connector, key, value);
238
+ }
239
+
240
+ return { workflowJson: wfJson, workflowHash: wfLoader.getWorkflowHash(workflow) };
241
+ },
242
+
243
+ /** Computed sha256 of a loaded workflow template. */
244
+ getWorkflowHash(name) {
245
+ return wfLoader.getWorkflowHash(name);
246
+ },
247
+ };
248
+ }
249
+
250
+ module.exports = {
251
+ createWorkflowConnector,
252
+ ConnectorApiError,
253
+ WorkflowNotFoundError,
254
+ ConnectorMissingError,
255
+ IncompatibleWorkflowError,
256
+ BuildError,
257
+
258
+ // Compatibility surface (extraction §6.1): the loader singletons remain
259
+ // reachable so host consumers migrated from backend/src/workflows/*
260
+ // keep their exact call shapes. The createWorkflowConnector() factory
261
+ // is the preferred long-term API.
262
+ workflowLoader: wfLoader,
263
+ connectorLoader,
264
+ entitySchema: require('./entity-schema'),
265
+ };