ceroomedia commited on
Commit
6e0df2b
·
verified ·
1 Parent(s): 1758ac6

Release 0.1.1: Prompter Generate and measured workflow progress

Browse files
CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
  # Changelog
2
 
 
 
 
 
 
 
 
3
  ## 0.1.0 — 2026-09-28
4
 
5
  Initial public DMAI NODES release, using the Slate design.
 
1
  # Changelog
2
 
3
+ ## 0.1.1 — 2026-09-28
4
+
5
+ - Move Generate from the Generation Engine to the Prompter.
6
+ - Add a softly glowing Prompter outline that fills from measured sampling and decoding progress across all requested images.
7
+ - Wait for successful workflow completion before showing 100%; errors and cancellation retain their failure state.
8
+ - Publish execution-specific progress events through ComfyUI's existing WebSocket, with handlers removed after every run.
9
+
10
  ## 0.1.0 — 2026-09-28
11
 
12
  Initial public DMAI NODES release, using the Slate design.
DMAI-NODES-v0.1.1.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:62652886fc8ac29d2de100d89692f57b74affd5583084da016ec826f4c6aafdd
3
+ size 139204
DMAI-NODES-v0.1.1.zip.sha256 ADDED
@@ -0,0 +1 @@
 
 
1
+ 62652886fc8ac29d2de100d89692f57b74affd5583084da016ec826f4c6aafdd DMAI-NODES-v0.1.1.zip
README.md CHANGED
@@ -14,7 +14,7 @@ tags:
14
 
15
  **A compact image workflow for ComfyUI.** Slate surfaces, clean Inter typography and English controls.
16
 
17
- **0.1.0 preview** · [GitHub releases](https://github.com/ceroomedia/DMAI-NODES/releases) · [Hugging Face download mirror](https://huggingface.co/ceroomedia/DMAI-NODES)
18
 
19
  Prompter → Generation Engine → Gallery, with an ordered LoRA Loader feeding the engine.
20
 
@@ -22,7 +22,7 @@ Prompter → Generation Engine → Gallery, with an ordered LoRA Loader feeding
22
 
23
  | Node | Purpose |
24
  | --- | --- |
25
- | **DMAI Prompter** | Prompt, 19 aspect ratios, 1K / 2K / Custom dimensions, image count and optional connected text. |
26
  | **DMAI LoRA Loader** | Reorderable stack, enabled state, separate model/CLIP strengths and strict file checks. |
27
  | **DMAI Generation Engine** | Model profiles, DMAI Enhanced or Manual sampling, imported settings presets, seed sequence and execution report. |
28
  | **DMAI Gallery** | Persistent PNG originals, multiple selection, per-image download and selection ZIP. |
@@ -56,7 +56,7 @@ ZIP installation is also supported: extract the release so `ComfyUI/custom_nodes
56
  1. Import a starter from `workflows`: Krea, SDXL, Qwen or FLUX. Use the `.json` file for the UI and `.api.json` for API calls.
57
  2. Select installed model files in the Engine. Install any optional enhancement dependency needed by the selected preset.
58
  3. Enter the prompt and image count. Add LoRAs if needed.
59
- 4. Choose **Generate**. Preview or download saved results from Gallery.
60
 
61
  For Krea without the optional enhancer, choose **Krea / Clean Base** or Manual with Enhancer set to `none`. Models and LoRAs remain subject to their own licenses.
62
 
@@ -64,6 +64,7 @@ For Krea without the optional enhancer, choose **Krea / Clean Base** or Manual w
64
 
65
  - LoRAs are applied in their displayed order, before the Engine encodes both prompts. Disabled rows and rows with both strengths at zero are skipped; an active nonzero LoRA with zero effective patch matches raises an error. “Loaded” reports registered patches, not a completed image-quality test. See [advanced conditioning](docs/MODELS.md#advanced-connections) when encoding text upstream.
66
  - Image count is handled by the backend. Images use `seed`, `seed + 1`, and so on, modulo 2^64. The report stores the exact seeds as decimal strings.
 
67
  - Gallery IDs are serialized in workflows. The new package uses separate storage under `output/DMAI-NODES`; existing DMAI Studio history is not deleted or automatically imported.
68
  - ZIP exports are limited to **100 images / 2 GiB**. Full-history selection uses a snapshot so later images are not silently added.
69
  - Invalid saved JSON remains visible for correction. It is not replaced by defaults.
 
14
 
15
  **A compact image workflow for ComfyUI.** Slate surfaces, clean Inter typography and English controls.
16
 
17
+ **0.1.1 preview** · [GitHub releases](https://github.com/ceroomedia/DMAI-NODES/releases) · [Hugging Face download mirror](https://huggingface.co/ceroomedia/DMAI-NODES)
18
 
19
  Prompter → Generation Engine → Gallery, with an ordered LoRA Loader feeding the engine.
20
 
 
22
 
23
  | Node | Purpose |
24
  | --- | --- |
25
+ | **DMAI Prompter** | Prompt, 19 aspect ratios, 1K / 2K / Custom dimensions, image count, Generate button and generation progress outline. |
26
  | **DMAI LoRA Loader** | Reorderable stack, enabled state, separate model/CLIP strengths and strict file checks. |
27
  | **DMAI Generation Engine** | Model profiles, DMAI Enhanced or Manual sampling, imported settings presets, seed sequence and execution report. |
28
  | **DMAI Gallery** | Persistent PNG originals, multiple selection, per-image download and selection ZIP. |
 
56
  1. Import a starter from `workflows`: Krea, SDXL, Qwen or FLUX. Use the `.json` file for the UI and `.api.json` for API calls.
57
  2. Select installed model files in the Engine. Install any optional enhancement dependency needed by the selected preset.
58
  3. Enter the prompt and image count. Add LoRAs if needed.
59
+ 4. Choose **Generate** in the Prompter. Its softly glowing outline follows generation across all requested images and reaches 100% when the workflow finishes. Preview or download saved results from Gallery.
60
 
61
  For Krea without the optional enhancer, choose **Krea / Clean Base** or Manual with Enhancer set to `none`. Models and LoRAs remain subject to their own licenses.
62
 
 
64
 
65
  - LoRAs are applied in their displayed order, before the Engine encodes both prompts. Disabled rows and rows with both strengths at zero are skipped; an active nonzero LoRA with zero effective patch matches raises an error. “Loaded” reports registered patches, not a completed image-quality test. See [advanced conditioning](docs/MODELS.md#advanced-connections) when encoding text upstream.
66
  - Image count is handled by the backend. Images use `seed`, `seed + 1`, and so on, modulo 2^64. The report stores the exact seeds as decimal strings.
67
+ - Prompter progress follows native sampler updates, completed image decoding and workflow completion. Loading has no invented percentage; errors and cancellation never display successful completion. Optional connected text is still supported.
68
  - Gallery IDs are serialized in workflows. The new package uses separate storage under `output/DMAI-NODES`; existing DMAI Studio history is not deleted or automatically imported.
69
  - ZIP exports are limited to **100 images / 2 GiB**. Full-history selection uses a snapshot so later images are not silently added.
70
  - Invalid saved JSON remains visible for correction. It is not replaced by defaults.
dmai_nodes/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
  """DMAI NODES domain modules; importing these does not initialize a GPU."""
2
- __version__ = "0.1.0"
 
1
  """DMAI NODES domain modules; importing these does not initialize a GPU."""
2
+ __version__ = "0.1.1"
dmai_nodes/engine.py CHANGED
@@ -310,7 +310,18 @@ def _prepare_input_latent(latent, model, request):
310
 
311
 
312
  def execute_engine(request, config, loras=None, *, sampler=None, sigmas=None,
313
- model=None, clip=None, vae=None, latent=None, positive=None, negative=None):
 
 
 
 
 
 
 
 
 
 
 
314
  """Render one image per seed, in order. Comfy interruptions propagate unchanged.
315
 
316
  The first public adapter uses standard EmptyLatentImage and KSampler. The
@@ -322,6 +333,7 @@ def execute_engine(request, config, loras=None, *, sampler=None, sigmas=None,
322
  from . import __version__ as package_version
323
  profile = get_profile(config["profile_id"])
324
  settings = config["settings"]
 
325
  external_models = _external_model_inputs(model, clip, vae)
326
  if (positive is None) != (negative is None):
327
  raise ValueError("Connect positive and negative CONDITIONING together, or leave both disconnected.")
@@ -374,6 +386,7 @@ def execute_engine(request, config, loras=None, *, sampler=None, sigmas=None,
374
  from comfy_extras.nodes_custom_sampler import BasicScheduler
375
  sigmas = BasicScheduler.execute(model=model, scheduler=settings["scheduler"], steps=settings["steps"], denoise=settings["denoise"])[0]
376
  schedule = _sigma_schedule(sigmas)
 
377
  # Both prompts must use the LoRA-patched encoder, in the same order as MODEL.
378
  if not external_conditioning:
379
  positive = _conditioning(nodes, profile, clip, request["prompt"], request)
@@ -389,6 +402,7 @@ def execute_engine(request, config, loras=None, *, sampler=None, sigmas=None,
389
  image_latent = nodes.EmptyLatentImage().generate(request["width"], request["height"], batch_size=1)[0]
390
  else:
391
  image_latent = {key: value.clone() if torch.is_tensor(value) else deepcopy(value) for key, value in prepared_latent.items()}
 
392
  if not custom_sampling:
393
  samples = nodes.KSampler().sample(model=model, seed=image_seed, steps=settings["steps"], cfg=settings["cfg"],
394
  sampler_name=settings["sampler"], scheduler=settings["scheduler"],
@@ -400,11 +414,13 @@ def execute_engine(request, config, loras=None, *, sampler=None, sigmas=None,
400
  latent_image=image_latent)
401
  samples = result[0]
402
  interrupt()
 
403
  decoded = nodes.VAEDecode().decode(vae, samples)[0]
404
  if tuple(decoded.shape) != (1, request["height"], request["width"], 3):
405
  raise RuntimeError("The model returned unexpected image dimensions or count; generation stopped.")
406
  images.append(decoded.to(device="cpu"))
407
  image_report.append({"index": index, "seed": str(image_seed)})
 
408
  interrupt()
409
  preset_info = None
410
  if config["mode"] == "enhanced":
 
310
 
311
 
312
  def execute_engine(request, config, loras=None, *, sampler=None, sigmas=None,
313
+ model=None, clip=None, vae=None, latent=None, positive=None, negative=None,
314
+ node_id=None):
315
+ from .progress import GenerationProgress
316
+ with GenerationProgress.from_comfy(node_id) as progress:
317
+ return _execute_engine(request, config, loras, sampler=sampler, sigmas=sigmas,
318
+ model=model, clip=clip, vae=vae, latent=latent,
319
+ positive=positive, negative=negative, progress=progress)
320
+
321
+
322
+ def _execute_engine(request, config, loras=None, *, sampler=None, sigmas=None,
323
+ model=None, clip=None, vae=None, latent=None, positive=None, negative=None,
324
+ progress):
325
  """Render one image per seed, in order. Comfy interruptions propagate unchanged.
326
 
327
  The first public adapter uses standard EmptyLatentImage and KSampler. The
 
333
  from . import __version__ as package_version
334
  profile = get_profile(config["profile_id"])
335
  settings = config["settings"]
336
+ progress.configure(request["count"], settings["steps"])
337
  external_models = _external_model_inputs(model, clip, vae)
338
  if (positive is None) != (negative is None):
339
  raise ValueError("Connect positive and negative CONDITIONING together, or leave both disconnected.")
 
386
  from comfy_extras.nodes_custom_sampler import BasicScheduler
387
  sigmas = BasicScheduler.execute(model=model, scheduler=settings["scheduler"], steps=settings["steps"], denoise=settings["denoise"])[0]
388
  schedule = _sigma_schedule(sigmas)
389
+ progress.set_steps(schedule["steps"])
390
  # Both prompts must use the LoRA-patched encoder, in the same order as MODEL.
391
  if not external_conditioning:
392
  positive = _conditioning(nodes, profile, clip, request["prompt"], request)
 
402
  image_latent = nodes.EmptyLatentImage().generate(request["width"], request["height"], batch_size=1)[0]
403
  else:
404
  image_latent = {key: value.clone() if torch.is_tensor(value) else deepcopy(value) for key, value in prepared_latent.items()}
405
+ progress.begin_sampling(index)
406
  if not custom_sampling:
407
  samples = nodes.KSampler().sample(model=model, seed=image_seed, steps=settings["steps"], cfg=settings["cfg"],
408
  sampler_name=settings["sampler"], scheduler=settings["scheduler"],
 
414
  latent_image=image_latent)
415
  samples = result[0]
416
  interrupt()
417
+ progress.begin_decode()
418
  decoded = nodes.VAEDecode().decode(vae, samples)[0]
419
  if tuple(decoded.shape) != (1, request["height"], request["width"], 3):
420
  raise RuntimeError("The model returned unexpected image dimensions or count; generation stopped.")
421
  images.append(decoded.to(device="cpu"))
422
  image_report.append({"index": index, "seed": str(image_seed)})
423
+ progress.image_done()
424
  interrupt()
425
  preset_info = None
426
  if config["mode"] == "enhanced":
dmai_nodes/nodes.py CHANGED
@@ -68,11 +68,12 @@ class DMAIGenerationEngine(io.ComfyNode):
68
  io.Model.Input("model",optional=True,advanced=True),io.Clip.Input("clip",optional=True,advanced=True),io.Vae.Input("vae",optional=True,advanced=True),
69
  io.Latent.Input("latent",optional=True,advanced=True),
70
  io.Conditioning.Input("positive",optional=True,advanced=True),io.Conditioning.Input("negative",optional=True,advanced=True)],
71
- outputs=[io.Image.Output(display_name="images"),ReportPort.Output(display_name="report")])
 
72
 
73
  @classmethod
74
  def execute(cls,request,config_json,loras=None,sampler=None,sigmas=None,model=None,clip=None,vae=None,latent=None,positive=None,negative=None):
75
- images,report=execute_engine(request,config_json,loras,sampler=sampler,sigmas=sigmas,model=model,clip=clip,vae=vae,latent=latent,positive=positive,negative=negative)
76
  return io.NodeOutput(images,report,ui={"dmai_report":[report]})
77
 
78
  @classmethod
 
68
  io.Model.Input("model",optional=True,advanced=True),io.Clip.Input("clip",optional=True,advanced=True),io.Vae.Input("vae",optional=True,advanced=True),
69
  io.Latent.Input("latent",optional=True,advanced=True),
70
  io.Conditioning.Input("positive",optional=True,advanced=True),io.Conditioning.Input("negative",optional=True,advanced=True)],
71
+ outputs=[io.Image.Output(display_name="images"),ReportPort.Output(display_name="report")],
72
+ hidden=[io.Hidden.unique_id])
73
 
74
  @classmethod
75
  def execute(cls,request,config_json,loras=None,sampler=None,sigmas=None,model=None,clip=None,vae=None,latent=None,positive=None,negative=None):
76
+ images,report=execute_engine(request,config_json,loras,sampler=sampler,sigmas=sigmas,model=model,clip=clip,vae=vae,latent=latent,positive=positive,negative=negative,node_id=getattr(cls.hidden,"unique_id",None))
77
  return io.NodeOutput(images,report,ui={"dmai_report":[report]})
78
 
79
  @classmethod
dmai_nodes/progress.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scoped, execution-specific progress events without patching Comfy callbacks."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import math
6
+ from uuid import uuid4
7
+
8
+ logger = logging.getLogger(__name__)
9
+ EVENT_NAME = "dmai_generation_progress"
10
+
11
+
12
+ class GenerationProgress:
13
+ """Observe only this engine's native sampler updates while it is sampling.
14
+
15
+ Each image contributes its normalized sampler steps and one completed decode;
16
+ a final unit covers assembling the result. This measures completed work, not
17
+ elapsed time. Model loading remains indeterminate at zero. A sampler that
18
+ does not report native progress advances only when its call actually returns.
19
+ """
20
+
21
+ def __init__(self, *, registry=None, send=None, prompt_id=None, node_id=None,
22
+ client_id=None, node_metadata=None):
23
+ self.name = f"dmai-generation-{uuid4().hex}"
24
+ self.enabled = registry is not None and send is not None and bool(prompt_id) and node_id is not None
25
+ self.registry = registry
26
+ self.send = send
27
+ self.prompt_id = str(prompt_id) if prompt_id is not None else None
28
+ self.node_id = str(node_id) if node_id is not None else None
29
+ self.client_id = client_id
30
+ self.node_metadata = node_metadata or {}
31
+ self.image_count = 1
32
+ self.image_index = 0
33
+ self.steps = 1
34
+ self.value = 0
35
+ self.maximum = 1
36
+ self.fraction = 0.0
37
+ self.phase = "preparing"
38
+ self._registered = False
39
+
40
+ @classmethod
41
+ def from_comfy(cls, node_id=None):
42
+ """Direct library calls without a Comfy execution context stay silent."""
43
+ try:
44
+ from comfy_execution.utils import get_executing_context
45
+ from comfy_execution.progress import get_progress_state
46
+ context = get_executing_context()
47
+ if context is None or not context.prompt_id:
48
+ return cls()
49
+ # Native execution IDs remain authoritative for expanded subgraphs.
50
+ context_node_id = str(context.node_id)
51
+ if node_id is not None and str(node_id) != context_node_id:
52
+ return cls()
53
+ registry = get_progress_state()
54
+ if str(registry.prompt_id) != str(context.prompt_id):
55
+ return cls()
56
+ from server import PromptServer
57
+ server = PromptServer.instance
58
+ if server is None:
59
+ return cls()
60
+ metadata = {}
61
+ for field in ("display_node_id", "parent_node_id", "real_node_id"):
62
+ resolver = getattr(registry.dynprompt, f"get_{field}", None)
63
+ if callable(resolver):
64
+ metadata[field] = resolver(context_node_id)
65
+ return cls(registry=registry, send=server.send_sync, prompt_id=context.prompt_id,
66
+ node_id=context_node_id, client_id=server.client_id, node_metadata=metadata)
67
+ except Exception:
68
+ # Progress must never change the outcome of generation.
69
+ logger.debug("DMAI generation progress is unavailable", exc_info=True)
70
+ return cls()
71
+
72
+ def __enter__(self):
73
+ if self.enabled:
74
+ try:
75
+ self.registry.register_handler(self)
76
+ self._registered = True
77
+ except Exception:
78
+ self.enabled = False
79
+ logger.debug("Could not register DMAI generation progress", exc_info=True)
80
+ return self
81
+
82
+ def __exit__(self, exc_type, exc_value, traceback):
83
+ try:
84
+ if exc_type is None:
85
+ self.phase = "complete"
86
+ self._emit(1.0)
87
+ else:
88
+ interrupted = isinstance(exc_value, InterruptedError) or any(
89
+ base.__name__ == "InterruptProcessingException" for base in exc_type.__mro__)
90
+ self.phase = "interrupted" if interrupted else "error"
91
+ self._emit(self.fraction)
92
+ finally:
93
+ if self._registered:
94
+ try:
95
+ self.registry.unregister_handler(self.name)
96
+ except Exception:
97
+ logger.debug("Could not unregister DMAI generation progress", exc_info=True)
98
+ finally:
99
+ self._registered = False
100
+ self.enabled = False
101
+ return False
102
+
103
+ def configure(self, image_count, steps):
104
+ self.image_count = image_count
105
+ self.steps = steps
106
+ self.maximum = steps
107
+ self._emit(0.0)
108
+
109
+ def set_steps(self, steps):
110
+ # Resolved external SIGMAS may replace the configured schedule.
111
+ self.steps = steps
112
+ self.maximum = steps
113
+
114
+ @property
115
+ def total_units(self):
116
+ return self.image_count * (self.steps + 1) + 1
117
+
118
+ def _image_units(self):
119
+ return self.image_index * (self.steps + 1)
120
+
121
+ def begin_sampling(self, index):
122
+ self.image_index = index
123
+ self.phase = "sampling"
124
+ self.value = 0
125
+ self.maximum = self.steps
126
+ self._emit(self._image_units() / self.total_units)
127
+
128
+ def begin_decode(self):
129
+ self.phase = "decoding"
130
+ self._emit((self._image_units() + self.steps) / self.total_units)
131
+
132
+ def image_done(self):
133
+ self.phase = "finalizing" if self.image_index + 1 == self.image_count else "decoding"
134
+ self._emit(((self.image_index + 1) * (self.steps + 1)) / self.total_units)
135
+
136
+ def _emit(self, fraction):
137
+ self.fraction = min(1.0, max(self.fraction, fraction))
138
+ if not self.enabled:
139
+ return
140
+ payload = {"prompt_id": self.prompt_id, "node_id": self.node_id,
141
+ "phase": self.phase, "image_index": self.image_index,
142
+ "image_count": self.image_count, "value": self.value,
143
+ "max": self.maximum, "fraction": self.fraction,
144
+ **self.node_metadata}
145
+ try:
146
+ self.send(EVENT_NAME, payload, self.client_id)
147
+ except Exception:
148
+ logger.debug("Could not send DMAI generation progress", exc_info=True)
149
+
150
+ def update_handler(self, node_id, value, max_value, state, prompt_id, image=None):
151
+ if (not self.enabled or self.phase != "sampling" or str(node_id) != self.node_id
152
+ or str(prompt_id) != self.prompt_id):
153
+ return
154
+ try:
155
+ value, maximum = float(value), float(max_value)
156
+ except (TypeError, ValueError, OverflowError):
157
+ return
158
+ if not math.isfinite(value) or not math.isfinite(maximum) or maximum <= 0:
159
+ return
160
+ self.value, self.maximum = max(0.0, min(value, maximum)), maximum
161
+ self._emit((self._image_units() + self.steps * self.value / maximum) / self.total_units)
162
+
163
+ # Implement the native ProgressHandler interface without importing Comfy at
164
+ # module import time, so lightweight package tooling remains usable.
165
+ def set_registry(self, registry):
166
+ self.registry = registry
167
+
168
+ def start_handler(self, node_id, state, prompt_id):
169
+ pass
170
+
171
+ def finish_handler(self, node_id, state, prompt_id):
172
+ pass
173
+
174
+ def reset(self):
175
+ self.enabled = False
176
+
177
+ def enable(self):
178
+ self.enabled = True
179
+
180
+ def disable(self):
181
+ self.enabled = False
docs/API.md CHANGED
@@ -24,6 +24,43 @@ In a completed starter job, Gallery metadata is at `history[prompt_id]["outputs"
24
 
25
  Use seed strings for the full unsigned 64-bit range; JavaScript numbers cannot represent all of it exactly. Each image's exact seed is recorded in the Engine report. The CLI timeout only stops waiting; it does not cancel the job.
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  ## Package endpoints
28
 
29
  Routes are relative to ComfyUI's base URL. JSON responses use `{"ok": true, "data": ...}` or `{"ok": false, "error": ...}`. Downloads return file bytes.
 
24
 
25
  Use seed strings for the full unsigned 64-bit range; JavaScript numbers cannot represent all of it exactly. Each image's exact seed is recorded in the Engine report. The CLI timeout only stops waiting; it does not cancel the job.
26
 
27
+ ## Generation progress over WebSocket
28
+
29
+ Connect to ComfyUI's `/ws?clientId=<client ID>` before submitting the workflow. Use the same ID as `client_id` in `POST /prompt`. The Engine sends `dmai_generation_progress` messages to that client through ComfyUI's existing WebSocket:
30
+
31
+ ```json
32
+ {
33
+ "type": "dmai_generation_progress",
34
+ "data": {
35
+ "prompt_id": "<queued prompt ID>",
36
+ "node_id": "3",
37
+ "phase": "sampling",
38
+ "image_index": 1,
39
+ "image_count": 2,
40
+ "value": 4,
41
+ "max": 8,
42
+ "fraction": 0.6842105263
43
+ }
44
+ }
45
+ ```
46
+
47
+ | Field | Meaning |
48
+ | --- | --- |
49
+ | `prompt_id` | Native ComfyUI execution ID; match it to the queued job. |
50
+ | `node_id` | Native executing Engine ID. Expanded subgraphs may use a different ID from the visible graph. |
51
+ | `phase` | `preparing`, `sampling`, `decoding`, `finalizing`, `complete`, `error` or `interrupted`. |
52
+ | `image_index` / `image_count` | Zero-based current image index and total requested images. |
53
+ | `value` / `max` | Current sampler progress counters; use them during `sampling`. |
54
+ | `fraction` | Monotonic completed-work fraction from 0 to 1 across this Engine invocation. |
55
+
56
+ When available, `display_node_id`, `real_node_id` and `parent_node_id` provide ComfyUI's native subgraph mapping. Clients must match both the job and the intended Engine; receiving an event for another node does not advance this Engine.
57
+
58
+ The fraction normalizes each image's native sampler updates to its resolved step count, adds one unit after each completed decode, and reserves one final unit for assembling the Engine result. It measures completed work, not elapsed time. Model loading stays at zero. A sampler that emits no native progress advances only when its call returns; no timer simulates sampling progress. Internal VAE updates are excluded from the sampler counters.
59
+
60
+ **Engine `complete` is not workflow completion.** Gallery or other downstream nodes may still be running. The Prompter caps its display at 99% until ComfyUI emits native `execution_success` for the same `prompt_id`, so saved results are available before 100% appears. Handle native `execution_error` and `execution_interrupted` as failure/cancellation, including failures after the Engine has finished. Cached Engines may emit no package progress events; use native execution events and `/history/{prompt_id}` to determine the job's final state.
61
+
62
+ Progress observers exist only for the duration of Engine execution and are removed after success, failure or interruption. No additional WebSocket server is required.
63
+
64
  ## Package endpoints
65
 
66
  Routes are relative to ComfyUI's base URL. JSON responses use `{"ok": true, "data": ...}` or `{"ok": false, "error": ...}`. Downloads return file bytes.
docs/VALIDATION.md CHANGED
@@ -1,4 +1,4 @@
1
- # Validation — 0.1.0 preview
2
 
3
  Checked on 2026-09-28. This release has working ComfyUI registration, CPU contract tests and real local API checks. GPU image generation is still pending.
4
 
@@ -14,8 +14,8 @@ Checked on 2026-09-28. This release has working ComfyUI registration, CPU contra
14
 
15
  | Check | Evidence |
16
  | --- | --- |
17
- | Python suite | 70 tests passed with optional real-Comfy CPU tests enabled. |
18
- | JavaScript suite | 19 tests passed: dimensions, exact seeds, ordered manifests, strength edits, mode persistence, sampler overrides, ZIP selection, keyboard containment and Comfy change events. |
19
  | Node registration | All seven native V3 nodes appear in real `/object_info`. |
20
  | Runtime choices | Four model profiles, 45 native sampler names and 9 schedulers in the test installation. Extension installations can change these counts. |
21
  | LoRA loading | Four real CPU comparisons use tiny safetensors, Comfy's file loader, ModelPatcher and LoRA weight calculation. Results match the standard loader and expected low-rank math exactly. |
@@ -23,6 +23,9 @@ Checked on 2026-09-28. This release has working ComfyUI registration, CPU contra
23
  | Enhancer dependency | Both pinned upstream implementations, 1.1.0 and 1.4.2, pass source/signature checks and register the expected patch on a small test object. No model inference. |
24
  | Gallery | Real Comfy queue jobs save synthetic 64×64 PNGs. Two selected ZIP entries pass CRC and match original download bytes exactly. Unit tests cover history snapshots, cross-gallery rejection and missing originals. |
25
  | UI transport | Native Comfy merges `dmai_gallery` correctly; no second native image-preview payload is emitted. |
 
 
 
26
  | Browser | Production panels render in real Comfy Canvas. Custom dimensions, image count, Manual/Enhanced round trips and native sampler/scheduler menus were exercised. Saving and reloading retained 1152×768, count 2, 22 steps, DPM++ 2M and Karras. Reordering retained LoRA model strength 0.65 and independent CLIP strength 0.4. Comfy's unsaved marker and Undo respond to edits. Two non-adjacent Gallery images could be selected. |
27
 
28
  The visual Gallery fixtures are solid-color test images, not generated model results.
 
1
+ # Validation — 0.1.1 preview
2
 
3
  Checked on 2026-09-28. This release has working ComfyUI registration, CPU contract tests and real local API checks. GPU image generation is still pending.
4
 
 
14
 
15
  | Check | Evidence |
16
  | --- | --- |
17
+ | Python suite | 81 tests passed with optional real-Comfy CPU tests enabled, including scoped native progress registry updates and multi-image progress. |
18
+ | JavaScript suite | 38 tests passed: dimensions, exact seeds, ordered manifests, strength edits, mode persistence, sampler overrides, ZIP selection, keyboard containment, Comfy change events, progress presentation and job lifecycle handling. |
19
  | Node registration | All seven native V3 nodes appear in real `/object_info`. |
20
  | Runtime choices | Four model profiles, 45 native sampler names and 9 schedulers in the test installation. Extension installations can change these counts. |
21
  | LoRA loading | Four real CPU comparisons use tiny safetensors, Comfy's file loader, ModelPatcher and LoRA weight calculation. Results match the standard loader and expected low-rank math exactly. |
 
23
  | Enhancer dependency | Both pinned upstream implementations, 1.1.0 and 1.4.2, pass source/signature checks and register the expected patch on a small test object. No model inference. |
24
  | Gallery | Real Comfy queue jobs save synthetic 64×64 PNGs. Two selected ZIP entries pass CRC and match original download bytes exactly. Unit tests cover history snapshots, cross-gallery rejection and missing originals. |
25
  | UI transport | Native Comfy merges `dmai_gallery` correctly; no second native image-preview payload is emitted. |
26
+ | Progress transport | A real `/prompt` job and WebSocket emitted `preparing` then `error` with the correct prompt and Engine IDs when the optional enhancer was absent. No completion event was fabricated. |
27
+ | Progress lifecycle | Automated tests cover cached jobs, subgraph identity, multiple Engine branches, queued-job removal, failure, cancellation, disconnect and cleanup. Only native workflow success reaches 100%. |
28
+ | Progress browser check | Generate appears only in Prompter. A real Comfy execution failure re-enabled it and showed the actionable error. The production component harness displayed a 42% perimeter, disabled Generate while active, and restored it at 100% completion using explicitly labeled fixture states. |
29
  | Browser | Production panels render in real Comfy Canvas. Custom dimensions, image count, Manual/Enhanced round trips and native sampler/scheduler menus were exercised. Saving and reloading retained 1152×768, count 2, 22 steps, DPM++ 2M and Karras. Reordering retained LoRA model strength 0.65 and independent CLIP strength 0.4. Comfy's unsaved marker and Undo respond to edits. Two non-adjacent Gallery images could be selected. |
30
 
31
  The visual Gallery fixtures are solid-color test images, not generated model results.
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "dmai-nodes"
3
- version = "0.1.0"
4
  description = "Compact image workflows for ComfyUI: Prompter, ordered LoRAs, Generation Engine and Gallery."
5
  authors = [{name = "DMAI / ceroomedia"}]
6
  readme = "README.md"
 
1
  [project]
2
  name = "dmai-nodes"
3
+ version = "0.1.1"
4
  description = "Compact image workflows for ComfyUI: Prompter, ordered LoRAs, Generation Engine and Gallery."
5
  authors = [{name = "DMAI / ceroomedia"}]
6
  readme = "README.md"
release.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "DMAI NODES",
3
- "version": "0.1.0",
4
  "channel": "preview",
5
  "license": "MIT",
6
  "comfyui_reference": "0.37.0",
 
1
  {
2
  "name": "DMAI NODES",
3
+ "version": "0.1.1",
4
  "channel": "preview",
5
  "license": "MIT",
6
  "comfyui_reference": "0.37.0",
tools/build_release.py CHANGED
@@ -7,7 +7,7 @@ import shutil
7
  import zipfile
8
 
9
  ROOT=Path(__file__).resolve().parents[1]
10
- VERSION="0.1.0"
11
  ROOT_FILES={"__init__.py","README.md","LICENSE","pyproject.toml","requirements.txt","THIRD_PARTY_NOTICES.md","CHANGELOG.md","release.json"}
12
  DIRECTORIES={"dmai_nodes","web","docs","resources","licenses","workflows"}
13
  FORBIDDEN={".git","__pycache__",".validation","node_modules","dist"}
 
7
  import zipfile
8
 
9
  ROOT=Path(__file__).resolve().parents[1]
10
+ VERSION="0.1.1"
11
  ROOT_FILES={"__init__.py","README.md","LICENSE","pyproject.toml","requirements.txt","THIRD_PARTY_NOTICES.md","CHANGELOG.md","release.json"}
12
  DIRECTORIES={"dmai_nodes","web","docs","resources","licenses","workflows"}
13
  FORBIDDEN={".git","__pycache__",".validation","node_modules","dist"}
web/dmai-nodes.js CHANGED
@@ -4,6 +4,7 @@ import { envelope } from "./core.mjs";
4
  import { Prompter, LoRAStack } from "./inputs.mjs";
5
  import { Engine } from "./engine.mjs";
6
  import { Gallery } from "./gallery.mjs";
 
7
 
8
  const controllers = new WeakMap();
9
  const types = {
@@ -13,12 +14,32 @@ const types = {
13
  DMAIGallery: Gallery,
14
  };
15
  let bootstrapPromise;
 
 
 
 
 
 
 
 
 
 
 
 
16
  const context = {
17
  api,
18
  isConfiguring: () => Boolean(app.configuringGraph),
19
  beforeChange: () => app.canvas?.emitBeforeChange?.(),
20
  afterChange: () => app.canvas?.emitAfterChange?.(),
21
- queue: () => app.queuePrompt(0, 1),
 
 
 
 
 
 
 
 
22
  bootstrap(refresh = false) {
23
  if (refresh || !bootstrapPromise)
24
  bootstrapPromise = envelope(api, "/bootstrap").catch((error) => {
@@ -43,7 +64,7 @@ function install(node) {
43
  }
44
  }
45
  function refreshGraph() {
46
- for (const node of app.graph?._nodes ?? []) {
47
  install(node);
48
  controllers.get(node)?.hydrate();
49
  }
@@ -51,6 +72,7 @@ function refreshGraph() {
51
  app.registerExtension({
52
  name: "DMAI.Nodes.Slate",
53
  setup() {
 
54
  const href = new URL("./nodes.css", import.meta.url).href;
55
  if (!document.querySelector("link[data-dmai-nodes]")) {
56
  const link = document.createElement("link");
 
4
  import { Prompter, LoRAStack } from "./inputs.mjs";
5
  import { Engine } from "./engine.mjs";
6
  import { Gallery } from "./gallery.mjs";
7
+ import { GenerationProgress } from "./progress.mjs";
8
 
9
  const controllers = new WeakMap();
10
  const types = {
 
14
  DMAIGallery: Gallery,
15
  };
16
  let bootstrapPromise;
17
+ const progress = new GenerationProgress({
18
+ api,
19
+ getRootGraph: () => app.rootGraph ?? app.graph,
20
+ });
21
+ function* graphNodes(graph, visited = new Set()) {
22
+ if (!graph || visited.has(graph)) return;
23
+ visited.add(graph);
24
+ for (const node of graph._nodes ?? []) {
25
+ yield node;
26
+ if (node.subgraph) yield* graphNodes(node.subgraph, visited);
27
+ }
28
+ }
29
  const context = {
30
  api,
31
  isConfiguring: () => Boolean(app.configuringGraph),
32
  beforeChange: () => app.canvas?.emitBeforeChange?.(),
33
  afterChange: () => app.canvas?.emitAfterChange?.(),
34
+ watchProgress: (node, callback) => progress.watch(node, callback),
35
+ generate: (node) => progress.generate(node, async () => {
36
+ for (const current of graphNodes(app.rootGraph ?? app.graph)) {
37
+ const controller = controllers.get(current);
38
+ if (controller instanceof Engine && !controller.disposed && !controller.commitControls())
39
+ throw new Error("Check the Generation Engine settings before generating.");
40
+ }
41
+ return app.queuePrompt(0, 1);
42
+ }),
43
  bootstrap(refresh = false) {
44
  if (refresh || !bootstrapPromise)
45
  bootstrapPromise = envelope(api, "/bootstrap").catch((error) => {
 
64
  }
65
  }
66
  function refreshGraph() {
67
+ for (const node of graphNodes(app.rootGraph ?? app.graph)) {
68
  install(node);
69
  controllers.get(node)?.hydrate();
70
  }
 
72
  app.registerExtension({
73
  name: "DMAI.Nodes.Slate",
74
  setup() {
75
+ progress.start();
76
  const href = new URL("./nodes.css", import.meta.url).href;
77
  if (!document.querySelector("link[data-dmai-nodes]")) {
78
  const link = document.createElement("link");
web/engine.mjs CHANGED
@@ -400,25 +400,6 @@ export class Engine extends Controller {
400
  ),
401
  );
402
  this.body.append(advanced.root);
403
- this.runButton = button(
404
- "Generate",
405
- "play",
406
- () =>
407
- this.guard(async () => {
408
- if (!this.commitControls()) return;
409
- this.runButton.disabled = true;
410
- try {
411
- await this.context.queue();
412
- this.note("Workflow queued.");
413
- } finally {
414
- if (!this.disposed) this.runButton.disabled = false;
415
- }
416
- }),
417
- true,
418
- );
419
- this.runButton.classList.add("dmai-primary", "dmai-generate");
420
- this.runButton.disabled = !profile;
421
- this.body.append(this.runButton);
422
  if (this.data && !profile)
423
  this.error(
424
  new Error(
 
400
  ),
401
  );
402
  this.body.append(advanced.root);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  if (this.data && !profile)
404
  this.error(
405
  new Error(
web/inputs.mjs CHANGED
@@ -15,12 +15,22 @@ import {
15
  moveEntry,
16
  uid,
17
  } from "./core.mjs";
 
18
 
19
  export class Prompter extends Controller {
20
  constructor(node, context) {
21
  super(node, "config_json", "Prompter", "text", context);
22
- this.size(360, 350);
 
 
 
 
 
 
23
  this.hydrate();
 
 
 
24
  }
25
  hydrate() {
26
  if (this.disposed) return;
@@ -90,7 +100,55 @@ export class Prompter extends Controller {
90
  });
91
  advanced.body.append(negative);
92
  this.body.append(advanced.root);
 
 
 
 
 
 
 
 
93
  this.sync();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  }
95
  intent() {
96
  const v = this.node.properties?.dmai_format;
 
15
  moveEntry,
16
  uid,
17
  } from "./core.mjs";
18
+ import { ProgressView, progressPresentation } from "./progress-view.mjs";
19
 
20
  export class Prompter extends Controller {
21
  constructor(node, context) {
22
  super(node, "config_json", "Prompter", "text", context);
23
+ this.root.classList.add("dmai-prompter");
24
+ const scroll = el("div", "dmai-prompter-scroll");
25
+ scroll.append(...this.root.childNodes);
26
+ this.root.append(scroll);
27
+ this.progress = { phase: "idle", percent: 0 };
28
+ this.progressView = new ProgressView(this.root);
29
+ this.size(360, 400);
30
  this.hydrate();
31
+ this.stopProgress = context.watchProgress?.(node, (state) => {
32
+ if (!this.disposed) this.updateProgress(state);
33
+ });
34
  }
35
  hydrate() {
36
  if (this.disposed) return;
 
100
  });
101
  advanced.body.append(negative);
102
  this.body.append(advanced.root);
103
+ this.runButton = button(
104
+ "Generate",
105
+ "play",
106
+ () => this.generate(),
107
+ true,
108
+ );
109
+ this.runButton.classList.add("dmai-primary", "dmai-generate");
110
+ this.body.append(this.runButton, this.progressView.readout);
111
  this.sync();
112
+ this.renderProgress();
113
+ }
114
+ updateProgress(state) {
115
+ if (this.progress?.phase === "error" && progressPresentation(state).busy)
116
+ this.note();
117
+ this.progress = state;
118
+ if (state.phase === "error" && state.message)
119
+ this.error(new Error(state.message));
120
+ this.renderProgress();
121
+ }
122
+ renderProgress() {
123
+ const view = this.progressView.update(this.progress);
124
+ if (this.runButton) this.runButton.disabled = Boolean(this.queuePending || view.busy);
125
+ }
126
+ async generate() {
127
+ if (this.queuePending || progressPresentation(this.progress).busy || this.disposed) return;
128
+ return this.guard(async () => {
129
+ this.queuePending = true;
130
+ this.note();
131
+ this.renderProgress();
132
+ try {
133
+ await this.context.generate(this.node);
134
+ } catch (error) {
135
+ this.updateProgress({
136
+ phase: "error",
137
+ percent: this.progress?.percent ?? 0,
138
+ message: error?.message ?? String(error),
139
+ });
140
+ throw error;
141
+ } finally {
142
+ this.queuePending = false;
143
+ if (!this.disposed) this.renderProgress();
144
+ }
145
+ });
146
+ }
147
+ dispose() {
148
+ if (this.disposed) return;
149
+ this.stopProgress?.();
150
+ this.progressView?.dispose();
151
+ super.dispose();
152
  }
153
  intent() {
154
  const v = this.node.properties?.dmai_format;
web/nodes.css CHANGED
@@ -646,3 +646,70 @@
646
  animation: none !important;
647
  }
648
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  animation: none !important;
647
  }
648
  }
649
+
650
+ /* The outline stays inside the node so real Comfy sockets remain unobstructed. */
651
+ .dmai-panel.dmai-prompter {
652
+ overflow: hidden;
653
+ }
654
+ .dmai-nodes .dmai-prompter-scroll {
655
+ width: 100%;
656
+ height: 100%;
657
+ overflow: auto;
658
+ scrollbar-width: thin;
659
+ scrollbar-color: #4b584e transparent;
660
+ border-radius: inherit;
661
+ }
662
+ .dmai-nodes .dmai-progress-outline {
663
+ position: absolute;
664
+ inset: 0;
665
+ width: 100%;
666
+ height: 100%;
667
+ pointer-events: none;
668
+ z-index: 2;
669
+ overflow: visible;
670
+ fill: none;
671
+ }
672
+ .dmai-nodes .dmai-progress-track {
673
+ stroke: var(--dmai-accent);
674
+ stroke-width: 1;
675
+ opacity: 0.13;
676
+ }
677
+ .dmai-nodes .dmai-progress-stroke {
678
+ stroke: var(--dmai-accent);
679
+ stroke-width: 2;
680
+ stroke-linecap: round;
681
+ transition: stroke-dashoffset 350ms ease-out;
682
+ filter: drop-shadow(0 0 3px #c8ee9966);
683
+ }
684
+ .dmai-nodes[data-generating="true"] .dmai-progress-stroke {
685
+ animation: dmai-progress-glow 2.4s ease-in-out infinite;
686
+ }
687
+ .dmai-nodes[data-generation-phase="queued"] .dmai-progress-stroke {
688
+ transition: none;
689
+ }
690
+ .dmai-nodes .dmai-progress-readout {
691
+ display: flex;
692
+ justify-content: space-between;
693
+ align-items: center;
694
+ gap: 12px;
695
+ margin-top: 9px;
696
+ color: var(--dmai-muted);
697
+ font-size: 10px;
698
+ }
699
+ .dmai-nodes .dmai-progress-value {
700
+ font-variant-numeric: tabular-nums;
701
+ color: var(--dmai-accent);
702
+ }
703
+ .dmai-nodes[data-generation-phase="error"] .dmai-progress-stroke,
704
+ .dmai-nodes[data-generation-phase="interrupted"] .dmai-progress-stroke {
705
+ stroke: #edb89e;
706
+ filter: none;
707
+ }
708
+ .dmai-nodes[data-generation-phase="error"] .dmai-progress-value,
709
+ .dmai-nodes[data-generation-phase="interrupted"] .dmai-progress-value {
710
+ color: #edb89e;
711
+ }
712
+ @keyframes dmai-progress-glow {
713
+ 0%, 100% { opacity: 0.72; }
714
+ 50% { opacity: 1; }
715
+ }
web/progress-view.mjs ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const PHASES = {
2
+ idle: "",
3
+ queued: "Queued",
4
+ loading: "Loading",
5
+ sampling: "Generating",
6
+ decoding: "Decoding",
7
+ saving: "Saving",
8
+ complete: "Complete",
9
+ error: "Failed",
10
+ interrupted: "Stopped",
11
+ };
12
+ const ACTIVE = new Set(["queued", "loading", "sampling", "decoding", "saving"]);
13
+
14
+ export function progressPresentation(state = {}) {
15
+ const phase = Object.hasOwn(PHASES, state.phase) ? state.phase : "idle";
16
+ const busy = ACTIVE.has(phase);
17
+ const measured = typeof state.percent === "number" && Number.isFinite(state.percent)
18
+ ? Math.max(0, Math.min(100, state.percent)) : 0;
19
+ // A full border means the workflow completed, including decode and save.
20
+ const percent = phase === "complete" ? 100 : Math.min(measured, 99.9);
21
+ return {
22
+ phase,
23
+ percent: phase === "idle" ? 0 : percent,
24
+ busy,
25
+ label: PHASES[phase],
26
+ visible: phase !== "idle",
27
+ value: phase === "complete" ? 100 : Math.floor(percent),
28
+ };
29
+ }
30
+
31
+ export function perimeterPath(width, height) {
32
+ const w = Math.max(8, Number.isFinite(width) ? width : 8);
33
+ const h = Math.max(8, Number.isFinite(height) ? height : 8);
34
+ const inset = 2, right = w - inset, bottom = h - inset;
35
+ const r = Math.min(10, (w - 4) / 2, (h - 4) / 2);
36
+ // Start at the top centre and follow the actual, rounded panel clockwise.
37
+ return `M ${w / 2} ${inset} H ${right - r} Q ${right} ${inset} ${right} ${inset + r} V ${bottom - r} Q ${right} ${bottom} ${right - r} ${bottom} H ${inset + r} Q ${inset} ${bottom} ${inset} ${bottom - r} V ${inset + r} Q ${inset} ${inset} ${inset + r} ${inset} H ${w / 2}`;
38
+ }
39
+
40
+ export class ProgressView {
41
+ constructor(panel) {
42
+ this.panel = panel;
43
+ this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
44
+ this.svg.classList.add("dmai-progress-outline");
45
+ this.svg.setAttribute("aria-hidden", "true");
46
+ this.svg.setAttribute("focusable", "false");
47
+ this.track = document.createElementNS(this.svg.namespaceURI, "path");
48
+ this.track.classList.add("dmai-progress-track");
49
+ this.stroke = document.createElementNS(this.svg.namespaceURI, "path");
50
+ this.stroke.classList.add("dmai-progress-stroke");
51
+ this.stroke.setAttribute("pathLength", "100");
52
+ this.stroke.setAttribute("stroke-dasharray", "100");
53
+ this.stroke.setAttribute("stroke-dashoffset", "100");
54
+ this.svg.append(this.track, this.stroke);
55
+ panel.append(this.svg);
56
+ this.readout = document.createElement("div");
57
+ this.readout.className = "dmai-progress-readout";
58
+ this.readout.setAttribute("role", "progressbar");
59
+ this.readout.setAttribute("aria-label", "Image generation progress");
60
+ this.readout.setAttribute("aria-valuemin", "0");
61
+ this.readout.setAttribute("aria-valuemax", "100");
62
+ this.label = document.createElement("span");
63
+ this.value = document.createElement("span");
64
+ this.value.className = "dmai-progress-value";
65
+ this.readout.append(this.label, this.value);
66
+ this.resize = () => {
67
+ const width = panel.clientWidth, height = panel.clientHeight;
68
+ if (!width || !height) return;
69
+ this.svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
70
+ const path = perimeterPath(width, height);
71
+ this.track.setAttribute("d", path);
72
+ this.stroke.setAttribute("d", path);
73
+ };
74
+ if (typeof ResizeObserver !== "undefined") {
75
+ this.observer = new ResizeObserver(this.resize);
76
+ this.observer.observe(panel);
77
+ }
78
+ this.resize();
79
+ this.update();
80
+ }
81
+ update(state) {
82
+ const view = progressPresentation(state);
83
+ this.panel.dataset.generationPhase = view.phase;
84
+ this.panel.dataset.generating = String(view.busy);
85
+ this.panel.setAttribute("aria-busy", String(view.busy));
86
+ this.svg.style.visibility = view.visible ? "visible" : "hidden";
87
+ this.stroke.style.strokeDashoffset = String(100 - view.percent);
88
+ this.readout.hidden = !view.visible;
89
+ this.readout.setAttribute("aria-valuenow", String(view.value));
90
+ this.readout.setAttribute("aria-valuetext", `${view.label}, ${view.value}%`);
91
+ this.label.textContent = view.label;
92
+ this.value.textContent = `${view.value}%`;
93
+ return view;
94
+ }
95
+ dispose() {
96
+ this.observer?.disconnect();
97
+ this.svg.remove();
98
+ this.readout.remove();
99
+ }
100
+ }
web/progress.mjs ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Job identity comes from the submitted API graph, never the current selection.
2
+ const IDLE = Object.freeze({ phase: "idle", percent: 0 });
3
+ const ACTIVE = new Set(["queued", "loading", "sampling", "decoding", "saving"]);
4
+ const PHASES = { preparing: "loading", sampling: "sampling", decoding: "decoding", finalizing: "saving", complete: "saving" };
5
+ const MAX_JOBS = 64;
6
+
7
+ function resolveNode(root, executionId) {
8
+ const parts = String(executionId).split(":");
9
+ let graph = root;
10
+ for (let index = 0; index < parts.length; index++) {
11
+ const node = graph?.getNodeById?.(parts[index]);
12
+ if (!node) return null;
13
+ if (index === parts.length - 1) return node;
14
+ graph = node.subgraph;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ function connections(entry) {
20
+ if (!Array.isArray(entry) || entry.length < 5) return null;
21
+ const [, id, prompt, extra, outputs] = entry;
22
+ const workflowId = extra?.extra_pnginfo?.workflow?.id;
23
+ if (typeof workflowId !== "string" || !workflowId || !prompt || typeof prompt !== "object" || !Array.isArray(outputs)) return null;
24
+ const reachable = new Set(), pending = outputs.map(String);
25
+ while (pending.length) {
26
+ const nodeId = pending.pop();
27
+ if (reachable.has(nodeId) || !prompt[nodeId]) continue;
28
+ reachable.add(nodeId);
29
+ for (const value of Object.values(prompt[nodeId].inputs ?? {})) {
30
+ if (Array.isArray(value) && value.length === 2 && prompt[String(value[0])]) pending.push(String(value[0]));
31
+ }
32
+ }
33
+ const sources = new Map();
34
+ for (const engineId of reachable) {
35
+ const engine = prompt[engineId], link = engine.inputs?.request;
36
+ if (engine.class_type !== "DMAIGenerationEngine" || !Array.isArray(link)) continue;
37
+ const sourceId = String(link[0]);
38
+ if (prompt[sourceId]?.class_type !== "DMAIPrompter") continue;
39
+ if (!sources.has(sourceId)) sources.set(sourceId, new Set());
40
+ sources.get(sourceId).add(engineId);
41
+ }
42
+ return { id: String(id), workflowId, sources, clientId: extra?.client_id };
43
+ }
44
+
45
+ export class GenerationProgress {
46
+ constructor({ api, getRootGraph }) {
47
+ this.api = api;
48
+ this.getRootGraph = getRootGraph;
49
+ this.records = new Map();
50
+ this.jobs = new Map();
51
+ this.listeners = [];
52
+ this.timers = new Map();
53
+ this.epoch = 0;
54
+ this.sequence = 0;
55
+ this.disposed = false;
56
+ }
57
+
58
+ start() {
59
+ if (this.listeners.length || this.disposed) return;
60
+ const listen = (name, handler) => {
61
+ const listener = (event) => handler(event.detail ?? {});
62
+ this.api.addEventListener(name, listener);
63
+ this.listeners.push([name, listener]);
64
+ };
65
+ listen("execution_start", (data) => {
66
+ const job = this.job(data.prompt_id);
67
+ if (!job || job.terminal) return;
68
+ job.started = true;
69
+ void this.lookup(job);
70
+ this.renderJob(job);
71
+ });
72
+ listen("dmai_generation_progress", (data) => {
73
+ const job = this.job(data.prompt_id);
74
+ if (!job || job.terminal || data.node_id == null) return;
75
+ job.started = true;
76
+ const engineId = String(data.node_id), previous = job.progress.get(engineId);
77
+ if (typeof data.fraction !== "number" || !Number.isFinite(data.fraction)) return;
78
+ job.progress.set(engineId, {
79
+ fraction: Math.max(previous?.fraction ?? 0, Math.min(1, Math.max(0, data.fraction))),
80
+ phase: PHASES[data.phase] ?? "loading",
81
+ });
82
+ if (data.phase === "error" || data.phase === "interrupted") job.terminal = data.phase;
83
+ if (!job.context) void this.lookup(job);
84
+ this.renderJob(job);
85
+ });
86
+ listen("execution_cached", (data) => {
87
+ const job = this.job(data.prompt_id);
88
+ if (!job || job.terminal) return;
89
+ for (const id of data.nodes ?? []) job.cached.add(String(id));
90
+ if (!job.context) void this.lookup(job);
91
+ this.renderJob(job);
92
+ });
93
+ listen("promptQueued", () => {
94
+ const epoch = this.epoch;
95
+ void this.read("/queue").then((queue) => {
96
+ if (!this.disposed && epoch === this.epoch) this.captureQueue(queue);
97
+ });
98
+ });
99
+ listen("status", (data) => {
100
+ if (Number.isFinite(data?.exec_info?.queue_remaining)) void this.reconcilePending();
101
+ });
102
+ for (const [event, phase] of [["execution_success", "complete"], ["execution_error", "error"], ["execution_interrupted", "interrupted"]]) {
103
+ listen(event, (data) => {
104
+ const job = this.job(data.prompt_id);
105
+ if (!job) return;
106
+ // A success event must never overwrite an already observed failure.
107
+ if (phase === "complete" && (job.terminal === "error" || job.terminal === "interrupted")) return;
108
+ job.terminal = phase;
109
+ job.message = phase === "error" ? data.exception_message || "Generation failed." : undefined;
110
+ void this.lookup(job, true);
111
+ this.renderJob(job);
112
+ });
113
+ }
114
+ listen("reconnecting", () => this.disconnect());
115
+ }
116
+
117
+ watch(node, callback) {
118
+ if (this.disposed) return () => {};
119
+ let record = this.records.get(node);
120
+ if (!record) {
121
+ record = { node, callbacks: new Set(), state: IDLE, submission: null };
122
+ this.records.set(node, record);
123
+ for (const job of this.jobs.values()) if (!job.terminal) this.bind(job);
124
+ }
125
+ record.callbacks.add(callback);
126
+ callback(record.state);
127
+ this.renderRecord(record);
128
+ return () => {
129
+ record.callbacks.delete(callback);
130
+ if (!record.callbacks.size) {
131
+ this.records.delete(node);
132
+ for (const job of this.jobs.values()) job.bindings.delete(record);
133
+ }
134
+ };
135
+ }
136
+
137
+ async generate(node, queueFn) {
138
+ this.start();
139
+ const record = this.records.get(node);
140
+ if (!record || this.disposed) throw new Error("The Prompter is no longer available.");
141
+ const token = {};
142
+ const submittedAfter = this.sequence;
143
+ record.submission = token;
144
+ this.publish(record, { phase: "queued", percent: 0 });
145
+ try {
146
+ const accepted = await queueFn();
147
+ if (accepted === false) throw new Error("ComfyUI did not accept the generation request. Check its validation messages.");
148
+ // Also capture jobs waiting behind another workflow, before execution_start.
149
+ const epoch = this.epoch;
150
+ const queue = await this.read("/queue");
151
+ if (epoch === this.epoch && !this.disposed) this.captureQueue(queue);
152
+ if (record.submission === token) record.submission = null;
153
+ const lookups = [...this.jobs.values()].map((job) => job.lookup).filter(Boolean);
154
+ await Promise.allSettled(lookups);
155
+ this.renderRecord(record);
156
+ // Successful queue submission need not mean this Prompter was connected.
157
+ if (![...this.jobs.values()].some((job) => job.order > submittedAfter && job.bindings.has(record))) {
158
+ throw new Error("No connected Generation Engine was queued for this Prompter.");
159
+ }
160
+ return accepted;
161
+ } catch (error) {
162
+ if (record.submission === token) record.submission = null;
163
+ if (this.records.get(node) === record && !this.disposed) {
164
+ this.publish(record, { phase: "error", percent: 0, message: error?.message ?? String(error) });
165
+ }
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ job(id) {
171
+ if (this.disposed || typeof id !== "string" || !id) return null;
172
+ let job = this.jobs.get(id);
173
+ if (!job) {
174
+ job = { id, order: ++this.sequence, started: false, terminal: null, context: null, bindings: new Map(), progress: new Map(), cached: new Set(), lookup: null, queueRead: false, historyRead: false };
175
+ this.jobs.set(id, job);
176
+ while (this.jobs.size > MAX_JOBS) this.jobs.delete(this.jobs.keys().next().value);
177
+ }
178
+ return job;
179
+ }
180
+
181
+ async read(path) {
182
+ try {
183
+ const response = await this.api.fetchApi(path);
184
+ return response.ok ? await response.json() : null;
185
+ } catch { return null; }
186
+ }
187
+
188
+ async reconcilePending() {
189
+ if (this.reconciling || this.disposed) return;
190
+ const pending = [...this.jobs.values()].filter((job) => !job.started && !job.terminal && job.bindings.size);
191
+ if (!pending.length) return;
192
+ const epoch = this.epoch;
193
+ const current = (job) => !this.disposed && epoch === this.epoch && this.jobs.get(job.id) === job && !job.started && !job.terminal;
194
+ this.reconciling = true;
195
+ try {
196
+ const queue = await this.read("/queue");
197
+ if (!queue || this.disposed || epoch !== this.epoch) return;
198
+ this.captureQueue(queue);
199
+ const present = new Set([...(queue.queue_running ?? []), ...(queue.queue_pending ?? [])].map((entry) => String(entry[1])));
200
+ await Promise.all(pending.filter((job) => !present.has(job.id)).map(async (job) => {
201
+ // Start/success can be delivered while a cached job moves into history.
202
+ // Give those authoritative events a short, bounded chance to arrive.
203
+ let historyEntry;
204
+ for (const delay of [50, 150]) {
205
+ await this.delay(delay);
206
+ if (!current(job)) return;
207
+ const history = await this.read(`/history/${encodeURIComponent(job.id)}`);
208
+ if (!current(job)) return;
209
+ historyEntry = history?.[job.id];
210
+ }
211
+ if (!current(job)) return;
212
+ if (historyEntry) {
213
+ // History proves it left the queue, but an absent success event must
214
+ // not fabricate a finished border. The normal history UI has results.
215
+ job.terminal = "error";
216
+ job.message = "The job has left the queue. Check ComfyUI's history for its result.";
217
+ } else job.terminal = "interrupted";
218
+ this.renderJob(job);
219
+ }));
220
+ } finally { this.reconciling = false; }
221
+ }
222
+
223
+ captureQueue(queue) {
224
+ for (const [name, started] of [["queue_running", true], ["queue_pending", false]]) {
225
+ for (const entry of queue?.[name] ?? []) {
226
+ const context = connections(entry);
227
+ if (!context) continue;
228
+ const job = this.job(context.id);
229
+ if (!job) continue;
230
+ job.started ||= started;
231
+ this.setContext(job, context);
232
+ }
233
+ }
234
+ }
235
+
236
+ setContext(job, context) {
237
+ if (this.api.clientId && context.clientId && context.clientId !== this.api.clientId) return;
238
+ job.context = context;
239
+ this.bind(job);
240
+ this.renderJob(job);
241
+ }
242
+
243
+ bind(job) {
244
+ const root = this.getRootGraph?.(), context = job.context;
245
+ if (!root || !context || String(root.id) !== context.workflowId) return;
246
+ for (const [sourceId, engines] of context.sources) {
247
+ const node = resolveNode(root, sourceId), record = this.records.get(node);
248
+ if (record) job.bindings.set(record, { sourceId, engines, workflowId: context.workflowId });
249
+ }
250
+ }
251
+
252
+ async lookup(job, terminal = false) {
253
+ if (job.context || this.disposed) return;
254
+ if (job.lookup) {
255
+ await job.lookup;
256
+ if (terminal && !job.context) return this.lookup(job, true);
257
+ return;
258
+ }
259
+ if (terminal && job.historyRead) return;
260
+ if (!terminal && job.queueRead) return;
261
+ const epoch = this.epoch;
262
+ const current = () => !this.disposed && epoch === this.epoch && this.jobs.get(job.id) === job;
263
+ job.lookup = (async () => {
264
+ if (!job.queueRead) {
265
+ job.queueRead = true;
266
+ const queue = await this.read("/queue");
267
+ if (!current()) return;
268
+ this.captureQueue(queue);
269
+ }
270
+ if (job.context) return;
271
+ // A fast cached job can leave the queue before its start event is handled.
272
+ job.historyRead ||= terminal;
273
+ for (const delay of terminal ? [0, 50, 150] : [0]) {
274
+ if (delay) await this.delay(delay);
275
+ if (!current()) return;
276
+ const history = await this.read(`/history/${encodeURIComponent(job.id)}`);
277
+ if (!current()) return;
278
+ const context = connections(history?.[job.id]?.prompt);
279
+ if (context) { this.setContext(job, context); return; }
280
+ }
281
+ })();
282
+ try { await job.lookup; } finally { job.lookup = null; }
283
+ }
284
+
285
+ delay(ms) {
286
+ return new Promise((resolve) => {
287
+ const timer = setTimeout(() => { this.timers.delete(timer); resolve(); }, ms);
288
+ this.timers.set(timer, resolve);
289
+ });
290
+ }
291
+
292
+ stateFor(job, binding) {
293
+ const engines = [...binding.engines];
294
+ const fractions = engines.map((id) => job.cached.has(id) ? 1 : job.progress.get(id)?.fraction ?? 0);
295
+ const fraction = fractions.reduce((total, value) => total + value, 0) / Math.max(1, engines.length);
296
+ let phase = job.started ? "loading" : "queued";
297
+ if (job.terminal) phase = job.terminal;
298
+ else if (fraction === 1) phase = "saving";
299
+ else {
300
+ for (const id of engines) {
301
+ const progress = job.progress.get(id);
302
+ if (progress && progress.fraction < 1) phase = progress.phase;
303
+ }
304
+ }
305
+ return { phase, percent: phase === "complete" ? 100 : Math.min(99, fraction * 100), promptId: job.id, ...(job.message ? { message: job.message } : {}) };
306
+ }
307
+
308
+ validBinding(record, binding) {
309
+ const root = this.getRootGraph?.();
310
+ return root && String(root.id) === binding.workflowId && resolveNode(root, binding.sourceId) === record.node;
311
+ }
312
+
313
+ renderRecord(record) {
314
+ if (this.records.get(record.node) !== record) return;
315
+ const matches = [...this.jobs.values()].filter((job) => job.bindings.has(record) && this.validBinding(record, job.bindings.get(record)));
316
+ matches.sort((a, b) => {
317
+ const priority = (job) => job.terminal ? 0 : job.started ? 2 : 1;
318
+ return priority(b) - priority(a) || b.order - a.order;
319
+ });
320
+ if (matches.length) this.publish(record, this.stateFor(matches[0], matches[0].bindings.get(record)));
321
+ else if (record.submission) this.publish(record, { phase: "queued", percent: 0 });
322
+ else if (ACTIVE.has(record.state.phase)) this.publish(record, IDLE);
323
+ }
324
+
325
+ renderJob(job) {
326
+ for (const record of job.bindings.keys()) this.renderRecord(record);
327
+ }
328
+
329
+ publish(record, state) {
330
+ if (this.disposed || !record.callbacks.size) return;
331
+ if (JSON.stringify(record.state) === JSON.stringify(state)) return;
332
+ record.state = state;
333
+ for (const callback of record.callbacks) callback(state);
334
+ }
335
+
336
+ disconnect() {
337
+ this.epoch++;
338
+ this.jobs.clear();
339
+ this.clearTimers();
340
+ for (const record of this.records.values()) {
341
+ record.submission = null;
342
+ if (ACTIVE.has(record.state.phase)) this.publish(record, { phase: "error", percent: Math.min(99, record.state.percent), message: "Connection lost. Check the ComfyUI queue before generating again." });
343
+ }
344
+ }
345
+
346
+ clearTimers() {
347
+ for (const [timer, resolve] of this.timers) { clearTimeout(timer); resolve(); }
348
+ this.timers.clear();
349
+ }
350
+
351
+ dispose() {
352
+ if (this.disposed) return;
353
+ this.disposed = true;
354
+ this.epoch++;
355
+ for (const [name, listener] of this.listeners) this.api.removeEventListener(name, listener);
356
+ this.listeners = [];
357
+ this.clearTimers();
358
+ this.jobs.clear();
359
+ this.records.clear();
360
+ }
361
+ }