feat(sweep): mixed standard + Workflow Sweep runs; SaveWEBM/AnimatedWEBP/AnimatedPNG frame sources (Roadmap 32)

- one Generator run may hold standard configs AND workflow-sweep entries: the sweep is validated
  first (job 1 + busy check, nothing registered), the standard loop runs, then start_sweep launches
  into the same session with one global job numbering (Start At Job counts standard jobs first).
  An interrupt in the loop never starts the sweep; meta.configs_json keeps the full Builder list
  (_standard_run_meta/_completion_payload helpers; next_phase on the completion event; the
  dashboard keeps the ETA bar up with "Workflow sweep next…" and clears the 30 s hide timer on
  any later progress event)
- frame sources: FRAME_SINKS table (VHS_VideoCombine, SaveWEBM, SaveAnimatedWEBP, SaveAnimatedPNG)
  feeding the CreateVideo splice; fps read per sink, clamped to CreateVideo's 1-120 with a plan
  warning; find_output_nodes fallback names; Builder label via framesFps
- tests: 33 python (mixed routing, Start At Job translation, interrupt, pre-flight busy/invalid,
  post-loop busy, three sink fixtures, clamping, VHS golden unchanged), 2 node, 2 Playwright

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jason Hoku
2026-11-04 18:48:44 -08:00
co-authored by Claude Fable 5.1
parent 69b93afa5e
commit 725cb79fd4
18 changed files with 735 additions and 168 deletions
+3 -2
View File
@@ -920,8 +920,9 @@ How it behaves:
* Each sweep writes `benchmarks/<session>/workflow_sweeps/<sweep_id>.json` (templates, bindings, per-job status) so any executed prompt can be reconstructed.
* **Re-running the same sweep attaches to it.** Queueing the Generator again (or pressing Start) while the identical sweep is active does not fail and does not queue anything twice: it attaches to the running sweep (console: "Same workflow sweep already running ... attaching") and a paused sweep stays paused (Resume is the Builder's button). "Identical" means the same session name, the same ordered job fingerprints, the same *Start at job* and the same *Overwrite existing*. Anything else gets a clear refusal ("A workflow sweep is already active (session ..., sweep ..., n/m done, state). This run was NOT started. Pause or cancel it ..."; ComfyUI shows it in its error dialog, the route answers 409 with `session_name`). *Add random seeds* are deterministic (derived from the base seed, 0 like the Generator), so the same sweep always produces the same jobs: re-queueing attaches, and a resume after a finished or interrupted run skips the jobs already in the session.
* **Loading a sweep session in the Builder** (Load Session) restores the Workflow Sweep arrays (workflow, output, bindings, custom prompts) from the `configs_json` that sweep sessions now store in their manifest meta. Sessions made before this change have no saved configs: the Builder shows a notice ("Load the workflow JSON again into a Workflow Sweep array; finished jobs are skipped by fingerprint") and leaves the arrays alone.
* * **Video outputs.** *Output to collect* lists *Images* and *Video* sources. Video sources are: the feeder of a `SaveVideo` (a VIDEO socket, e.g. `CreateVideo`), the frames feeding a `VHS_VideoCombine` (marked "frames"; the sweep splices a core `CreateVideo` node in front of the collector with the VHS node's `frame_rate` and `audio`, so a swept `frame_rate` works), and any node that returns a VIDEO. A SaveImage/PreviewImage feeder is still the default when the workflow has one; a video-only workflow (no SaveImage) now defaults to its video output. Each job saves ONE mp4 (H.264, through ComfyUI's own `VideoInput.save_to`, the workflow embedded as metadata) plus a middle-frame poster `vid_<id>.preview.png` into `benchmarks/<session>/images`, with the same manifest fields as LTX videos (`media_type: "video"`, `video_path`, `preview_path`, `poster`, `frames`, `frame_rate`, `duration_seconds`). Cards play the video; **JOB** stays available and the Compare view plays both videos. Resume and *Overwrite existing* work as for images (the old mp4 and its poster are deleted); a video and an image collection of the same socket never skip each other. Limits: VHS `format`, `loop_count` and `pingpong` are ignored (always mp4/h264); a VHS node fed with latents (`vae` input) or a `meta_batch` is not offered as a source; one video per job; the workflow's own SaveVideo/VHS node only runs with *Also run the workflow's own outputs*; collecting a file-backed VIDEO (e.g. `LoadVideo`) decodes all its frames into RAM once to make the poster.
* v1 limits: one image or video output per job, one sweep at a time, a workflow sweep cannot share a Generator run with standard configs (use two Builders), no upscale/distribution/trigger-word features, and Revise does not apply.
* * **Video outputs.** *Output to collect* lists *Images* and *Video* sources. Video sources are: the feeder of a `SaveVideo` (a VIDEO socket, e.g. `CreateVideo`), the frames feeding a `VHS_VideoCombine`, `SaveWEBM`, `SaveAnimatedWEBP` or `SaveAnimatedPNG` (marked "frames"; the sweep splices a core `CreateVideo` node in front of the collector with the sink's `frame_rate` / `fps` (and the VHS `audio`), so a swept fps works), and any node that returns a VIDEO. A SaveImage/PreviewImage feeder is still the default when the workflow has one; a video-only workflow (no SaveImage) now defaults to its video output. Each job saves ONE mp4 (H.264, through ComfyUI's own `VideoInput.save_to`, the workflow embedded as metadata) plus a middle-frame poster `vid_<id>.preview.png` into `benchmarks/<session>/images`, with the same manifest fields as LTX videos (`media_type: "video"`, `video_path`, `preview_path`, `poster`, `frames`, `frame_rate`, `duration_seconds`). Cards play the video; **JOB** stays available and the Compare view plays both videos. Resume and *Overwrite existing* work as for images (the old mp4 and its poster are deleted); a video and an image collection of the same socket never skip each other. Limits: VHS `format`, `loop_count` and `pingpong` are ignored (always mp4/h264); the WEBM `codec`/`crf`, WEBP `lossless`/`quality`/`method` and APNG `compress_level` settings are ignored too, alpha is dropped, and a literal fps outside CreateVideo's 1-120 is clamped (with a warning); a VHS node fed with latents (`vae` input) or a `meta_batch` is not offered as a source; one video per job; the workflow's own SaveVideo/VHS node only runs with *Also run the workflow's own outputs*; collecting a file-backed VIDEO (e.g. `LoadVideo`) decodes all its frames into RAM once to make the poster.
* **Mixed runs.** A Builder with both standard arrays and Workflow Sweep arrays runs in ONE Generator queue: the standard configs run first, then the sweep starts into the same session (and the same manifest). The sweep is validated (job 1) and checked for a conflicting active sweep BEFORE the standard jobs start, so a broken workflow fails early ("nothing ran"). *Start At Job* is one global numbering: standard jobs 1..N, sweep jobs N+1.. (the Builder's own *Start sweep* button keeps sweep-only numbering). *Overwrite existing* applies to both phases. The Preview pass and the optional_* inputs apply to the standard configs only. Cancelling during the standard phase means the sweep never starts; cancelling during the sweep uses the sweep's pause/resume. The dashboard bar shows "Standard jobs complete ... Workflow sweep next" and then "Sweep job x/M".
* v1 limits: one image or video output per job, one sweep at a time, no upscale/distribution/trigger-word features, and Revise does not apply.
**Dashboard for sweeps.** Items carry `wf_params` (the bound values, numbers or strings). The **Quick Filter** gets one extra type per *varied* key (`WF cfg`, `WF sampler_name`, ...; picking a value filters and the facet panel lists them), the **Logic Filter** gets a *Workflow params* group of the numeric keys (`wf:KSampler.cfg >= 5`), and the **Labels** panel has a *Workflow params* checkbox that tags each card with its varied values (`cfg:4`). Standard sessions show none of this. The **JOB** button opens a read-only *Workflow job* panel (source, output, sweep id, job number, parameter table) with **Download job prompt (API JSON)** and **Copy job prompt**: `GET /config_tester/sweep/job_prompt?session_name=&sweep_id=&job_index=` rebuilds the prompt exactly as it ran (template + substitutions) minus the USCG collector node, so queueing it in ComfyUI reproduces the image. If the browser blocks the download inside the dashboard iframe, use Copy.
+2 -2
View File
@@ -129,7 +129,7 @@ A button in the builder UI to pack short sha256 into config with an explanation
* **Problem:** Can only load full sessions, not merge snippets.
#### **23. Pseudo-JSON Nodes (Recursion)** (low priority) - STARTED: v1 is the **Workflow Sweep** (Builder: Add Workflow Sweep): any API-format workflow runs as-is with chosen inputs swept (see README, 'Workflow Sweep'). Still open: inserting nodes into the USCG pipeline itself, mixed workflow + standard runs (video outputs shipped 11/4/26). (v1 shipped 11/4/26)
#### **23. Pseudo-JSON Nodes (Recursion)** (low priority) - STARTED: v1 is the **Workflow Sweep** (Builder: Add Workflow Sweep): any API-format workflow runs as-is with chosen inputs swept (see README, 'Workflow Sweep'). Still open: inserting nodes into the USCG pipeline itself (video outputs and mixed workflow + standard runs shipped 11/4/26). (v1 shipped 11/4/26)
* **Problem:** Advanced. Running a raw JSON workflow as a sub-node. Inserting any node into any part of the genereation would make this tool have an immense customizability increase.
More info needed on how this could work, would like to see a visual interface for it in the builder UI eventaully. (big job, very low priority)
@@ -191,4 +191,4 @@ Verify the node ids of the optional aesthetic / reward scorer packs against the
#### **32. Workflow Sweep: video outputs and mixed runs**
Allow video outputs from a sweep and sweep + standard configs in one Generator run (both are v1 limits).
DONE (11/4/26): video outputs (SaveVideo / CreateVideo VIDEO sockets, VHS_VideoCombine frames via a spliced CreateVideo, `USCGSweepCollectVideo`). Still open: mixed sweep + standard runs in one Generator run; SaveWEBM / SaveAnimatedWEBP / SaveAnimatedPNG as frame sources (same CreateVideo splice); several outputs per job.
DONE (11/4/26): video outputs (SaveVideo / CreateVideo VIDEO sockets, VHS_VideoCombine frames via a spliced CreateVideo, `USCGSweepCollectVideo`). DONE (11/4/26, `feat/sweep-mixed`): mixed sweep + standard runs in one Generator run (standard configs first, then the sweep, one session, global Start At Job numbering); SaveWEBM / SaveAnimatedWEBP / SaveAnimatedPNG as frame sources (same CreateVideo splice, fps clamped to 1-120). Still open: several outputs per job.
+83 -89
View File
@@ -976,9 +976,16 @@ def run_generation_loop(
optional_model, optional_clip, optional_vae,
optional_positive, optional_negative, optional_latent,
distribution_config=None,
session_settings=None
session_settings=None,
meta_configs_json=None,
next_phase=None,
run_info=None
):
"""Main generation loop orchestrator."""
"""Main generation loop orchestrator.
meta_configs_json: the full configs string recorded in meta.configs_json when this loop only runs the
standard part of a mixed run (the Workflow Sweep entries follow after the loop). next_phase: announced on
the completion event so the dashboard keeps its bar. run_info: out-dict, receives total_jobs."""
# Clear stale trigger word caches from previous runs (within same ComfyUI session).
# Without this, @lru_cache and _build_prompt_cache can serve outdated results
@@ -1127,6 +1134,9 @@ def run_generation_loop(
configs_with_res = sum(1 for c in expanded if c.get("resolution") is not None)
configs_without_res = len(expanded) - configs_with_res
total_jobs = configs_with_res + (configs_without_res * len(input_jobs))
_meta_cfg = meta_configs_json or configs_json
if run_info is not None:
run_info["total_jobs"] = total_jobs
# ==== FLAT JOB PLAN (+ optional reload-minimising order) ====
# One entry per job, in exactly the order the old nested loop visited them
@@ -1210,7 +1220,7 @@ def run_generation_loop(
self, distribution_config, expanded, input_jobs, existing_data,
overwrite_existing, has_optional_inputs, lora_triggerwords_mode,
session_name, paths, unique_id, total_jobs, seed, ckpt_name,
positive_text, negative_text, vae_batch_size, configs_json,
positive_text, negative_text, vae_batch_size, _meta_cfg,
resolutions_json, flush_batch_every, use_remote_vae,
remote_vae_endpoint, save_conditioning_cache_to_file,
enable_model_cache, optional_model, optional_clip, optional_vae,
@@ -1219,7 +1229,8 @@ def run_generation_loop(
job_order=job_order, preview_on=preview_on, pv_steps=pv_steps, pv_scale=pv_scale,
pv_cfg=pv_cfg, meta_run=_meta_run, job_plan=job_plan,
skip_rejected_previews=skip_rejected_previews, only_favorited_previews=only_favorited_previews,
upscale_session_settings=session_settings_full
upscale_session_settings=session_settings_full,
next_phase=next_phase
)
# The schedule above used `expanded`; the main loop walks job_plan, so the smart cache needs
@@ -1597,20 +1608,10 @@ def run_generation_loop(
remote_vae_worker.wait_completion()
remote_vae_worker.stop()
existing_data["meta"] = {
"positive": positive_text,
"negative": negative_text,
"model": ckpt_name,
"seed": seed,
"vae_batch_size": vae_batch_size,
"configs_json": configs_json,
"resolutions_json": resolutions_json,
"image_format": image_format,
"job_order": job_order,
"preview_pass": {"enabled": preview_on, "steps": pv_steps, "scale": pv_scale, "cfg_override": pv_cfg},
"rejected_preview_fps": list((existing_data.get("meta") or {}).get("rejected_preview_fps", [])),
**_meta_run
}
existing_data["meta"] = _standard_run_meta(
existing_data, positive_text, negative_text, ckpt_name, seed, vae_batch_size,
_meta_cfg, resolutions_json, image_format, job_order, preview_on, pv_steps,
pv_scale, pv_cfg, _meta_run)
save_manifest(paths["manifest"], existing_data)
loaded_model, loaded_clip, loaded_vae = None, None, None
@@ -1954,20 +1955,10 @@ def run_generation_loop(
remote_vae_worker.wait_completion()
remote_vae_worker.stop()
existing_data["meta"] = {
"positive": positive_text,
"negative": negative_text,
"model": ckpt_name,
"seed": seed,
"vae_batch_size": vae_batch_size,
"configs_json": configs_json,
"resolutions_json": resolutions_json,
"image_format": image_format,
"job_order": job_order,
"preview_pass": {"enabled": preview_on, "steps": pv_steps, "scale": pv_scale, "cfg_override": pv_cfg},
"rejected_preview_fps": list((existing_data.get("meta") or {}).get("rejected_preview_fps", [])),
**_meta_run
}
existing_data["meta"] = _standard_run_meta(
existing_data, positive_text, negative_text, ckpt_name, seed, vae_batch_size,
_meta_cfg, resolutions_json, image_format, job_order, preview_on, pv_steps,
pv_scale, pv_cfg, _meta_run)
save_manifest(paths["manifest"], existing_data)
loaded_model, loaded_clip, loaded_vae = None, None, None
@@ -2795,20 +2786,10 @@ def run_generation_loop(
remote_vae_worker.wait_completion()
remote_vae_worker.stop()
existing_data["meta"] = {
"positive": positive_text,
"negative": negative_text,
"model": ckpt_name,
"seed": seed,
"vae_batch_size": vae_batch_size,
"configs_json": configs_json,
"resolutions_json": resolutions_json,
"image_format": image_format,
"job_order": job_order,
"preview_pass": {"enabled": preview_on, "steps": pv_steps, "scale": pv_scale, "cfg_override": pv_cfg},
"rejected_preview_fps": list((existing_data.get("meta") or {}).get("rejected_preview_fps", [])),
**_meta_run
}
existing_data["meta"] = _standard_run_meta(
existing_data, positive_text, negative_text, ckpt_name, seed, vae_batch_size,
_meta_cfg, resolutions_json, image_format, job_order, preview_on, pv_steps,
pv_scale, pv_cfg, _meta_run)
save_manifest(paths["manifest"], existing_data)
loaded_model, loaded_clip, loaded_vae = None, None, None
@@ -2905,20 +2886,10 @@ def run_generation_loop(
except Exception:
pass
existing_data["meta"] = {
"positive": positive_text,
"negative": negative_text,
"model": ckpt_name,
"seed": seed,
"vae_batch_size": vae_batch_size,
"configs_json": configs_json,
"resolutions_json": resolutions_json,
"image_format": image_format,
"job_order": job_order,
"preview_pass": {"enabled": preview_on, "steps": pv_steps, "scale": pv_scale, "cfg_override": pv_cfg},
"rejected_preview_fps": list((existing_data.get("meta") or {}).get("rejected_preview_fps", [])),
**_meta_run
}
existing_data["meta"] = _standard_run_meta(
existing_data, positive_text, negative_text, ckpt_name, seed, vae_batch_size,
_meta_cfg, resolutions_json, image_format, job_order, preview_on, pv_steps,
pv_scale, pv_cfg, _meta_run)
save_manifest(paths["manifest"], existing_data)
print(f"[GridTester] 🧹 Cleaning up...")
@@ -2948,20 +2919,9 @@ def run_generation_loop(
# Send completion event to dashboard
if PromptServer is not None:
try:
PromptServer.instance.send_sync("ultimate_grid.progress", {
"node": unique_id,
"session_name": session_name,
"current_job": total_jobs,
"total_jobs": total_jobs,
"progress_pct": 100,
"eta_str": "Done",
"finish_time": time.strftime("%H:%M:%S"),
"avg_duration": round(avg_per_job, 1),
"last_duration": 0,
"complete": True,
"total_elapsed": f"{total_hours}h {total_minutes}m {total_seconds}s",
"total_generated": total_generated
})
PromptServer.instance.send_sync("ultimate_grid.progress", _completion_payload(
unique_id, session_name, total_jobs, avg_per_job,
f"{total_hours}h {total_minutes}m {total_seconds}s", total_generated, next_phase))
except Exception:
pass
@@ -3380,6 +3340,49 @@ def _populate_distributed_manager(manager, job_plan, expanded, input_jobs, exist
def _standard_run_meta(existing_data, positive_text, negative_text, ckpt_name, seed, vae_batch_size,
configs_json, resolutions_json, image_format, job_order, preview_on, pv_steps,
pv_scale, pv_cfg, meta_run):
"""Manifest meta written by every exit path of the loop. `configs_json` is the FULL configs string
(for a mixed run: standard + Workflow Sweep entries in Builder order, so Load Session restores both)."""
return {
"positive": positive_text,
"negative": negative_text,
"model": ckpt_name,
"seed": seed,
"vae_batch_size": vae_batch_size,
"configs_json": configs_json,
"resolutions_json": resolutions_json,
"image_format": image_format,
"job_order": job_order,
"preview_pass": {"enabled": preview_on, "steps": pv_steps, "scale": pv_scale, "cfg_override": pv_cfg},
"rejected_preview_fps": list((existing_data.get("meta") or {}).get("rejected_preview_fps", [])),
**(meta_run or {})
}
def _completion_payload(unique_id, session_name, total_jobs, avg_per_job, total_elapsed, total_generated,
next_phase=None):
"""Final 'ultimate_grid.progress' event; next_phase ('sweep') tells the dashboard more work follows."""
payload = {
"node": unique_id,
"session_name": session_name,
"current_job": total_jobs,
"total_jobs": total_jobs,
"progress_pct": 100,
"eta_str": "Done",
"finish_time": time.strftime("%H:%M:%S"),
"avg_duration": round(avg_per_job, 1),
"last_duration": 0,
"complete": True,
"total_elapsed": total_elapsed,
"total_generated": total_generated
}
if next_phase:
payload["next_phase"] = next_phase
return payload
def _distributed_run_meta(existing_data, positive_text, negative_text, ckpt_name, seed, vae_batch_size,
configs_json, resolutions_json, image_format, job_order, preview_on, pv_steps,
pv_scale, pv_cfg, meta_run):
@@ -3411,7 +3414,8 @@ def _run_distributed_generation(
session_settings=None,
job_order="as_written", preview_on=False, pv_steps=8, pv_scale=0.5, pv_cfg=None,
meta_run=None, job_plan=None, skip_rejected_previews=True, only_favorited_previews=False,
upscale_session_settings=None
upscale_session_settings=None,
next_phase=None
):
"""
Run generation in distributed mode.
@@ -4109,20 +4113,10 @@ def _run_distributed_generation(
# Send completion event to dashboard
if PromptServer is not None:
try:
PromptServer.instance.send_sync("ultimate_grid.progress", {
"node": unique_id,
"session_name": session_name,
"current_job": dist_total,
"total_jobs": dist_total,
"progress_pct": 100,
"eta_str": "Done",
"finish_time": time.strftime("%H:%M:%S"),
"avg_duration": round(avg_per_job, 1),
"last_duration": 0,
"complete": True,
"total_elapsed": f"{total_hours}h {total_minutes}m {total_seconds}s",
"total_generated": final_status.get("completed", total_generated)
})
PromptServer.instance.send_sync("ultimate_grid.progress", _completion_payload(
unique_id, session_name, dist_total, avg_per_job,
f"{total_hours}h {total_minutes}m {total_seconds}s",
final_status.get("completed", total_generated), next_phase))
except Exception:
pass
+20 -15
View File
@@ -46,6 +46,19 @@ function applyManifestReplace(manifest, newMeta) {
updateDataPipeline();
}
var _etaHideTimer = null;
/** The ETA bar text for one progress event (pure). */
function formatProgressText(p) {
if (p.complete) {
if (p.next_phase === 'sweep') return `Standard jobs complete: ${p.total_generated} images in ${p.total_elapsed}. Workflow sweep next…`;
return `Complete! ${p.total_generated} images in ${p.total_elapsed} (${p.avg_duration}s/job)`;
}
const phaseLabel = p.phase === 'upscaling' ? 'Upscaling' : (p.phase === 'sweep' ? 'Sweep job' : 'Job');
const finishPart = p.finish_time ? ` | ~${p.finish_time}` : '';
return `${phaseLabel} ${p.current_job}/${p.total_jobs} (${p.progress_pct}%) | ETA: ${p.eta_str}${finishPart} | ${p.avg_duration}s/job${p.errors ? ` | ${p.errors} error${p.errors === 1 ? '' : 's'}` : ''}${(p.state === 'paused' || p.state === 'cancelled') ? ` | ${p.state}` : ''}`;
}
// WebSocket Listener - OPTIMIZED
window.addEventListener('message', (event) => {
// Handle real-time ETA/progress updates
@@ -57,21 +70,13 @@ window.addEventListener('message', (event) => {
etaBar.style.display = 'flex';
const etaText = document.getElementById('eta-text');
const etaProgress = document.getElementById('eta-progress');
if (p.complete) {
// Generation complete
if (etaText) etaText.textContent = `Complete! ${p.total_generated} images in ${p.total_elapsed} (${p.avg_duration}s/job)`;
if (etaProgress) etaProgress.style.width = '100%';
// Auto-hide after 30 seconds
setTimeout(() => { if (etaBar) etaBar.style.display = 'none'; }, 30000);
} else {
if (etaText) {
const phaseLabel = p.phase === 'upscaling' ? 'Upscaling' : (p.phase === 'sweep' ? 'Sweep job' : 'Job');
const finishPart = p.finish_time ? ` | ~${p.finish_time}` : '';
etaText.textContent = `${phaseLabel} ${p.current_job}/${p.total_jobs} (${p.progress_pct}%) | ETA: ${p.eta_str}${finishPart} | ${p.avg_duration}s/job${p.errors ? ` | ${p.errors} error${p.errors === 1 ? '' : 's'}` : ''}${(p.state === 'paused' || p.state === 'cancelled') ? ` | ${p.state}` : ''}`;
}
if (etaProgress) {
etaProgress.style.width = `${p.progress_pct}%`;
}
if (!p.complete || p.next_phase) { clearTimeout(_etaHideTimer); _etaHideTimer = null; }
if (etaText) etaText.textContent = formatProgressText(p);
if (etaProgress) etaProgress.style.width = p.complete ? '100%' : `${p.progress_pct}%`;
if (p.complete && !p.next_phase) {
// Generation complete: auto-hide after 30 seconds (any later progress event cancels it)
clearTimeout(_etaHideTimer);
_etaHideTimer = setTimeout(() => { if (etaBar) etaBar.style.display = 'none'; }, 30000);
}
}
return;
+79 -16
View File
@@ -17,6 +17,7 @@ import comfy.utils
import comfy.sd
import comfy.samplers
import comfy.model_management
from comfy.model_management import InterruptProcessingException
from PIL import Image
import numpy as np
@@ -61,6 +62,16 @@ def _workflow_configs(configs_json):
return None
def _split_configs(configs):
"""(standard_list, workflow_list) of a configs list/dict-with-"configs", each in Builder order."""
if isinstance(configs, dict):
configs = configs.get("configs")
std, wf = [], []
for c in (configs if isinstance(configs, list) else []):
(wf if isinstance(c, dict) and c.get("model_type") == "workflow" else std).append(c)
return std, wf
class SamplerGridTester:
@classmethod
def INPUT_TYPES(s):
@@ -353,24 +364,56 @@ class SamplerGridTester:
# immediately. The sweep prompts go through ComfyUI's own queue AFTER this prompt finishes, so
# this node never waits on them (a node waiting on a prompt it queued would deadlock the executor).
wf_configs = _workflow_configs(configs_json)
mixed_sweep = None
if wf_configs is not None:
if any(x is not None for x in (optional_model, optional_clip, optional_vae,
optional_positive, optional_negative, optional_latent)):
print("[GridTester] ⚠️ Workflow Sweep ignores the optional_* inputs (the workflow has its own loaders)")
from .workflow_sweep_runner import start_sweep, SweepBusy
from .workflow_sweep import read_manifest_items, session_paths
settings = dict(session_settings or {})
settings["session_name"] = session_name
settings["overwrite_existing"] = overwrite_existing
settings["add_random_seeds_to_gens"] = add_random_seeds_to_gens
has_optional = any(x is not None for x in (optional_model, optional_clip, optional_vae,
optional_positive, optional_negative, optional_latent))
std_configs, _wf_only = _split_configs(wf_configs)
if std_configs:
# Mixed run: the standard configs run first (below), the Workflow Sweep follows after the loop
mixed_sweep = (std_configs, _wf_only, wf_configs)
if has_optional:
print("[GridTester] ⚠️ optional inputs apply to the standard configs only")
_pv = (session_settings or {}).get("preview_pass") or {}
if _pv.get("enabled"):
print("[GridTester] ℹ️ Preview pass does not apply to Workflow Sweep configs - they render normally after the standard jobs")
else:
if has_optional:
print("[GridTester] ⚠️ Workflow Sweep ignores the optional_* inputs (the workflow has its own loaders)")
from .workflow_sweep_runner import start_sweep, SweepBusy
from .workflow_sweep import read_manifest_items, session_paths
settings = dict(session_settings or {})
settings["session_name"] = session_name
settings["overwrite_existing"] = overwrite_existing
settings["add_random_seeds_to_gens"] = add_random_seeds_to_gens
try:
sweep = start_sweep(wf_configs, settings, origin_node=unique_id)
except SweepBusy as e:
raise RuntimeError(str(e))
manifest = read_manifest_items(session_paths(sweep.session)["manifest"])
manifest.setdefault("session_name", sweep.session)
manifest.setdefault("meta", {})
return (get_html_template(sweep.session, manifest, unique_id),)
run_info = {}
loop_extra = {}
if mixed_sweep is not None:
from .workflow_sweep_runner import preflight_sweep, SweepBusy
std_configs, wf_only, full_configs = mixed_sweep
sweep_settings = dict(session_settings or {})
sweep_settings["session_name"] = session_name
sweep_settings["overwrite_existing"] = overwrite_existing
sweep_settings["add_random_seeds_to_gens"] = add_random_seeds_to_gens
# Fail BEFORE minutes of standard work: validate sweep job 1 and check for a conflicting sweep
try:
sweep = start_sweep(wf_configs, settings, origin_node=unique_id)
preflight_sweep(wf_only, sweep_settings, origin_node=unique_id)
except SweepBusy as e:
raise RuntimeError(str(e))
manifest = read_manifest_items(session_paths(sweep.session)["manifest"])
manifest.setdefault("session_name", sweep.session)
manifest.setdefault("meta", {})
return (get_html_template(sweep.session, manifest, unique_id),)
except ValueError as e:
raise ValueError(f"Workflow Sweep config is invalid; nothing ran: {e}")
loop_extra = {"meta_configs_json": json.dumps(full_configs, indent=2, ensure_ascii=False),
"next_phase": "sweep", "run_info": run_info}
configs_json = json.dumps(std_configs, indent=2, ensure_ascii=False)
# Optional per-image metrics (Builder "Compute image metrics"). The run context is
# token based so end_run() only ever closes the context THIS run opened.
@@ -379,7 +422,7 @@ class SamplerGridTester:
print(f"[GridTester] ⚙️ metrics: {metrics_spec['builtins'] + metrics_spec['adapters']}")
metrics_token = image_metrics.begin_run(metrics_spec)
try:
return run_generation_loop(
result = run_generation_loop(
self,
ckpt_name, positive_text, negative_text, seed, denoise, vae_batch_size,
overwrite_existing, flush_batch_every, configs_json, resolutions_json,
@@ -388,11 +431,31 @@ class SamplerGridTester:
optional_model, optional_clip, optional_vae,
optional_positive, optional_negative, optional_latent,
distribution_config=dist_config, # Extracted from configs_json
session_settings=session_settings # Extracted from configs_json
session_settings=session_settings, # Extracted from configs_json
**loop_extra
)
finally:
image_metrics.end_run(metrics_token)
if mixed_sweep is not None:
# An interrupt raises out of the loop above, so the sweep is only ever started after a full pass.
# Numbering is global: standard jobs 1..N, sweep jobs N+1.. (Start At Job counts across both).
_pi = getattr(comfy.model_management, "processing_interrupted", None)
if _pi is not None and _pi():
raise InterruptProcessingException()
from .workflow_sweep_runner import start_sweep, SweepBusy
n_std = int(run_info.get("total_jobs", 0) or 0)
try:
sa = int((session_settings or {}).get("start_at_job", 0) or 0)
except (TypeError, ValueError):
sa = 0
sweep_settings["start_at_job"] = sa - n_std if sa > n_std else 0
try:
start_sweep(wf_only, sweep_settings, origin_node=unique_id)
except (SweepBusy, ValueError) as e:
raise RuntimeError(f"Standard jobs finished and were saved; the workflow sweep was NOT started: {e}")
return result
# Node class mappings for ComfyUI
NODE_CLASS_MAPPINGS = {
+47
View File
@@ -0,0 +1,47 @@
"""Dashboard ETA bar across a mixed run: standard jobs complete -> sweep next -> sweep jobs; no stale hide timer."""
import os
import pytest
if os.environ.get("USCG_REQUIRE_E2E") == "1":
import playwright.sync_api # noqa: F401
else:
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect # noqa: E402
pytestmark = pytest.mark.e2e
def post(page, data):
page.evaluate("d => window.postMessage({type: 'progress_update', data: d}, '*')", data)
COMPLETE = {"complete": True, "total_generated": 4, "total_elapsed": "0h 1m 2s", "avg_duration": 15.5,
"current_job": 4, "total_jobs": 4, "progress_pct": 100}
SWEEP = {"phase": "sweep", "current_job": 1, "total_jobs": 2, "progress_pct": 50, "eta_str": "30s",
"avg_duration": 30, "finish_time": "12:00:00"}
def test_complete_with_next_phase_keeps_the_bar_and_names_the_sweep(dashboard):
page = dashboard.page
post(page, dict(COMPLETE, next_phase="sweep"))
expect(page.locator("#eta-bar")).to_be_visible()
expect(page.locator("#eta-text")).to_contain_text("sweep next")
expect(page.locator("#eta-text")).to_contain_text("Standard jobs complete: 4 images")
# no hide timer was armed: even after a long wait the bar stays (fake the 30 s with a real short check)
assert dashboard.ev("typeof _etaHideTimer") != "undefined"
assert dashboard.ev("_etaHideTimer") is None
post(page, SWEEP)
expect(page.locator("#eta-text")).to_contain_text("Sweep job 1/2")
def test_plain_complete_arms_the_timer_and_a_later_event_cancels_it(dashboard):
page = dashboard.page
post(page, COMPLETE)
expect(page.locator("#eta-text")).to_contain_text("Complete! 4 images")
assert dashboard.ev("_etaHideTimer") is not None
post(page, SWEEP) # a sweep that starts < 30 s after a run
assert dashboard.ev("_etaHideTimer") is None
expect(page.locator("#eta-bar")).to_be_visible()
expect(page.locator("#eta-text")).to_contain_text("Sweep job 1/2")
+4
View File
@@ -0,0 +1,4 @@
{
"1": {"class_type": "EmptyImage", "inputs": {"width": 64, "height": 64, "batch_size": 8, "color": 0}, "_meta": {"title": "EmptyImage"}},
"2": {"class_type": "SaveAnimatedPNG", "inputs": {"images": ["1", 0], "filename_prefix": "USCG-test", "fps": 6, "compress_level": 4}, "_meta": {"title": "Save Animated PNG"}}
}
+4
View File
@@ -0,0 +1,4 @@
{
"1": {"class_type": "EmptyImage", "inputs": {"width": 64, "height": 64, "batch_size": 8, "color": 0}, "_meta": {"title": "EmptyImage"}},
"2": {"class_type": "SaveAnimatedWEBP", "inputs": {"images": ["1", 0], "filename_prefix": "USCG-test", "fps": 6, "lossless": false, "quality": 80, "method": "default"}, "_meta": {"title": "Save Animated WEBP"}}
}
+4
View File
@@ -0,0 +1,4 @@
{
"1": {"class_type": "EmptyImage", "inputs": {"width": 64, "height": 64, "batch_size": 8, "color": 0}, "_meta": {"title": "EmptyImage"}},
"2": {"class_type": "SaveWEBM", "inputs": {"images": ["1", 0], "filename_prefix": "USCG-test", "codec": "vp9", "fps": 12, "crf": 32}, "_meta": {"title": "Save WEBM"}}
}
+32
View File
@@ -0,0 +1,32 @@
// framesFps: the fps shown in the Builder's "Video" option label for VHS and the core frame sinks.
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
function grab(src, name) {
const m = new RegExp('(?:export )?(?:async )?function ' + name + '[(]').exec(src);
assert.ok(m, name + ' not found');
let i = src.indexOf('{', m.index), depth = 0, j = i;
for (; j < src.length; j++) {
if (src[j] === '{') depth++;
else if (src[j] === '}') { depth--; if (depth === 0) break; }
}
return src.slice(m.index, j + 1).replace(/^export /, '');
}
const sweep = fs.readFileSync(path.join(__dirname, '../../web/conf_builder/conf-builder-workflow-sweep.js'), 'utf8');
const { framesFps } = new Function(grab(sweep, 'framesFps') + '\nreturn { framesFps };')();
test('VHS frame_rate and core sink fps are read', () => {
assert.strictEqual(framesFps({ 2: { inputs: { frame_rate: 8 } } }, '2'), 8);
assert.strictEqual(framesFps({ 2: { inputs: { fps: 12.5 } } }, '2'), 12.5);
assert.strictEqual(framesFps({ 2: { inputs: { frame_rate: 16, fps: 4 } } }, '2'), 16);
});
test('links, strings and missing nodes give undefined', () => {
assert.strictEqual(framesFps({ 2: { inputs: { fps: ['3', 0] } } }, '2'), undefined);
assert.strictEqual(framesFps({ 2: { inputs: { fps: '12' } } }, '2'), undefined);
assert.strictEqual(framesFps({}, '2'), undefined);
assert.strictEqual(framesFps(null, '2'), undefined);
});
+7 -9
View File
@@ -35,12 +35,10 @@ def test_job_order_preview_and_session_settings_kept():
assert m["seed"] == 7 and m["image_format"] == "webp"
def test_same_keys_as_normal_loop_literal():
"""The normal loop's final meta literal (generation_orchestrator) and this helper write the same keys."""
import re
src = open(go.__file__, encoding="utf-8").read()
i = src.index('existing_data["meta"] = {\n "positive": positive_text,')
block = src[i:src.index("**_meta_run", i)]
normal_keys = set(re.findall(r'^\s+"(\w+)":', block, re.M))
helper_keys = set(_meta({"items": []}).keys()) - set(run_meta_extras({}, False).keys())
assert normal_keys == helper_keys
def test_same_keys_as_normal_loop_helper():
"""The normal loop (_standard_run_meta, shared by its 4 exit paths) and the distributed helper write the same keys."""
args = dict(positive_text="p", negative_text="n", ckpt_name="m", seed=1, vae_batch_size=1, configs_json="[]",
resolutions_json="[]", image_format="webp", job_order="as_written", preview_on=False, pv_steps=8,
pv_scale=0.5, pv_cfg=None, meta_run=run_meta_extras({}, False))
normal = go._standard_run_meta({"items": []}, **args)
assert set(normal) == set(_meta({"items": []}))
+5 -2
View File
@@ -41,7 +41,10 @@ def test_every_run_generation_loop_meta_block_records_extras():
with open(os.path.join(_ROOT, "generation_orchestrator.py"), encoding="utf-8") as f:
src = f.read()
body = src[src.index("def run_generation_loop"):src.index("def _conditioning_to_serializable")]
blocks = re.findall(r'existing_data\["meta"\] = \{(.*?)\n\s*\}\n', body, flags=re.S)
# the four exit paths all build meta through _standard_run_meta(..., _meta_run), which spreads the extras
blocks = re.findall(r'existing_data\["meta"\] = _standard_run_meta\((.*?)\)\n', body, flags=re.S)
assert len(blocks) == 4
for b in blocks:
assert "**_meta_run" in b
assert b.rstrip().endswith("_meta_run")
helper = src[src.index("def _standard_run_meta"):src.index("def _completion_payload")]
assert "**(meta_run or {})" in helper
+147
View File
@@ -0,0 +1,147 @@
"""Workflow Sweep: SaveWEBM / SaveAnimatedWEBP / SaveAnimatedPNG as frame sources (spliced CreateVideo)."""
import json
import os
import sys
import pytest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.join(ROOT, "tests"))
import workflow_sweep as ws # noqa: E402
import workflow_sweep_runner as runner # noqa: E402
from test_workflow_sweep_video import VideoBridge, load, lit # noqa: E402,F401
FIXTURES = {"SaveWEBM": "video_webm_api.json",
"SaveAnimatedWEBP": "video_animated_webp_api.json",
"SaveAnimatedPNG": "video_animated_png_api.json"}
SINK_PARAMS = [pytest.param(c, f, id=c) for c, f in FIXTURES.items()]
def sink_entry(fixture, bindings=(), prompt=None):
return {"model_type": "workflow", "name": "s", "workflow": prompt or load(fixture),
"workflow_output": {"node_id": "1", "output_index": 0, "media": "video", "frames_from": "2"},
"workflow_bindings": list(bindings)}
def createvideo(prompt):
return [n for n in prompt.values() if n["class_type"] == "CreateVideo"][0]["inputs"]
@pytest.fixture
def vbridge(tmp_path, monkeypatch):
import folder_paths
import test_workflow_sweep_runner as rt
monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(tmp_path), raising=False)
monkeypatch.setattr(runner, "manifest_utils", rt._real_manifest_utils(), raising=False)
runner.reset_registry()
yield VideoBridge()
runner.reset_registry()
@pytest.mark.parametrize("cls,fixture", SINK_PARAMS)
def test_sink_is_a_frames_source_and_the_default(cls, fixture):
r = ws.parse_for_ui(load(fixture), class_info=None)
assert r["video_sources"] == [{"node_id": "1", "output_index": 0, "type": "IMAGE", "media": "video",
"kind": "frames", "frames_from": "2", "picked_from": "2"}]
assert r["default_output"] == {"node_id": "1", "output_index": 0, "media": "video", "frames_from": "2"}
@pytest.mark.parametrize("cls,fixture", SINK_PARAMS)
def test_find_output_nodes_lists_the_sinks_without_a_registry(cls, fixture):
outs = ws.find_output_nodes(load(fixture), class_info=None)
assert [(o["node_id"], o["class_type"], o["source"]) for o in outs] == [("2", cls, ["1", 0])]
@pytest.mark.parametrize("cls,fixture", SINK_PARAMS)
def test_build_job_prompt_splices_createvideo_from_the_sink_fps(cls, fixture):
p = load(fixture)
p["2"]["inputs"]["fps"] = 12
prompt, cid = ws.build_job_prompt(p, [], ["1", 0], {}, "video", "2")
cv_ids = [k for k, n in prompt.items() if n["class_type"] == "CreateVideo"]
assert len(cv_ids) == 1
inputs = prompt[cv_ids[0]]["inputs"]
assert inputs["fps"] == 12.0 and isinstance(inputs["fps"], float)
assert inputs["images"] == ["1", 0] and "audio" not in inputs
assert prompt[cid]["class_type"] == "USCGSweepCollectVideo" and prompt[cid]["inputs"]["video"] == [cv_ids[0], 0]
assert prompt["2"] == p["2"] # the sink itself is untouched
def test_swept_sink_fps_changes_fingerprints_and_createvideo_fps():
plan = ws.plan_workflow_entry(sink_entry("video_webm_api.json", [lit("b", "2", "fps", [12, 24])]))
assert len({j["wf_fingerprint"] for j in plan["jobs"]}) == 2
assert not any("not upstream" in w for w in plan["warnings"]), plan["warnings"]
tpl = plan["template"]
fps = []
for j in plan["jobs"]:
prompt, _ = ws.build_job_prompt(tpl["prompt"], j["wf_subs"], tpl["output"], {}, "video", tpl["frames_from"])
fps.append(createvideo(prompt)["fps"])
assert fps == [12.0, 24.0]
def test_fps_is_clamped_to_createvideo_range_with_a_warning():
p = load("video_webm_api.json")
p["2"]["inputs"]["fps"] = 0.5
plan = ws.plan_workflow_entry(sink_entry("", prompt=p))
assert any("0.5" in w and "outside CreateVideo's 1–120" in w and "uses 1." in w for w in plan["warnings"]), plan["warnings"]
tpl = plan["template"]
prompt, _ = ws.build_job_prompt(tpl["prompt"], [], tpl["output"], {}, "video", "2")
assert createvideo(prompt)["fps"] == 1.0
p["2"]["inputs"]["fps"] = 240
prompt, _ = ws.build_job_prompt(p, [], ["1", 0], {}, "video", "2")
assert createvideo(prompt)["fps"] == 120.0
assert ws._sink_fps({"class_type": "SaveWEBM", "inputs": {"fps": 240}}) == (120.0, True)
assert ws._sink_fps({"class_type": "SaveWEBM", "inputs": {"fps": 24}}) == (24.0, False)
assert ws._sink_fps({"class_type": "SaveWEBM", "inputs": {}}) == (24.0, False) # default fps
def test_swept_out_of_range_fps_gives_one_combined_warning():
plan = ws.plan_workflow_entry(sink_entry("video_webm_api.json", [lit("b", "2", "fps", [0.5, 12, 240])]))
clamp = [w for w in plan["warnings"] if "outside CreateVideo" in w]
assert len(clamp) == 1 and "0.5" in clamp[0] and "240" in clamp[0]
def test_linked_fps_is_forwarded_unchanged():
p = load("video_webm_api.json")
p["3"] = {"class_type": "PrimitiveFloat", "inputs": {"value": 12}}
p["2"]["inputs"]["fps"] = ["3", 0]
assert ws._sink_fps(p["2"]) == (["3", 0], False)
prompt, _ = ws.build_job_prompt(p, [], ["1", 0], {}, "video", "2")
assert createvideo(prompt)["fps"] == ["3", 0]
def test_one_image_feeding_vhs_and_webm_gives_two_distinct_sources():
p = load("video_vhs_api.json")
p["3"] = {"class_type": "SaveWEBM", "inputs": {"images": ["1", 0], "filename_prefix": "x", "codec": "vp9", "fps": 12, "crf": 32}}
srcs = ws.find_video_sources(p)
assert [(s["frames_from"], s["kind"]) for s in srcs] == [("2", "frames"), ("3", "frames")] # VHS first
fps = []
for fr in ("2", "3"):
e = sink_entry("", prompt=p)
e["workflow_output"]["frames_from"] = fr
fps.append(ws.plan_workflow_entry(e)["jobs"][0]["wf_fingerprint"])
assert fps[0] != fps[1]
def test_vhs_still_reads_frame_rate_and_audio():
p = load("video_vhs_api.json")
p["3"] = {"class_type": "LoadAudio", "inputs": {"audio": "a.wav"}}
p["2"]["inputs"]["audio"] = ["3", 0]
p["2"]["inputs"]["frame_rate"] = 16
prompt, _ = ws.build_job_prompt(p, [], ["1", 0], {}, "video", "2")
cv = createvideo(prompt)
assert cv["fps"] == 16.0 and cv["audio"] == ["3", 0]
def test_runner_queues_a_webp_sweep(vbridge):
sw = runner.prepare_sweep([sink_entry("video_animated_webp_api.json", [lit("b", "2", "fps", [6, 12])])],
{"session_name": "wf_sink"}, None, None, vbridge, class_info=None)
runner.launch(sw, start_thread=False)
assert runner.tick(sw)
pid, prompt, partial, _ = vbridge.enqueued[0]
classes = sorted(n["class_type"] for n in prompt.values())
assert "USCGSweepCollectVideo" in classes and "CreateVideo" in classes
status, body = runner.job_prompt("wf_sink", sw.id, 0)
assert status == 200
assert "USCGSweepCollect" not in json.dumps(body["prompt"]) and "CreateVideo" not in json.dumps(body["prompt"])
+19 -3
View File
@@ -124,16 +124,32 @@ def test_run_tests_starts_sweep_and_returns_dashboard_without_waiting(sampler_no
runner.reset_registry()
def test_mixed_workflow_and_standard_configs_raise(sampler_node, tmp_path, monkeypatch):
def test_mixed_workflow_and_standard_configs_route_loop_then_sweep(sampler_node, tmp_path, monkeypatch):
"""Formerly raised "cannot be mixed": now the standard configs run in the loop, then the sweep starts."""
import folder_paths
monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(tmp_path), raising=False)
monkeypatch.setattr(folder_paths, "get_filename_list", lambda t: ["a.safetensors"], raising=False)
monkeypatch.setattr(sys.modules["comfy"], "model_management", sys.modules["comfy.model_management"], raising=False)
monkeypatch.setattr(runner, "TICK", 0.05)
loops = []
def fake_loop(self, *args, **kwargs):
loops.append((json.loads(args[8]), kwargs))
kwargs["run_info"]["total_jobs"] = 1
return ("<loop>",)
monkeypatch.setattr(sys.modules[f"{PKG}.generation_orchestrator"], "run_generation_loop", fake_loop, raising=False)
runner.reset_registry()
runner.set_bridge(_FakeBridge())
cfg = json.dumps({"configs": [wf_config(), {"model_type": "checkpoint", "model": "x"}]})
try:
with pytest.raises(ValueError, match="cannot be mixed"):
sampler_node.SamplerGridTester().run_tests(cfg, unique_id="1")
assert sampler_node.SamplerGridTester().run_tests(cfg, unique_id="1") == ("<loop>",)
assert loops[0][0] == [{"model_type": "checkpoint", "model": "x"}] and loops[0][1]["next_phase"] == "sweep"
assert runner._active_sweep() is not None
finally:
sw = runner._active_sweep()
if sw is not None:
runner.cancel(sw.id)
sw.thread.join(5)
runner.set_bridge(None)
runner.reset_registry()
+171
View File
@@ -0,0 +1,171 @@
"""Mixed runs: standard configs + Workflow Sweep entries in ONE Generator run (standard loop first, then the sweep)."""
import json
import os
import sys
import pytest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.join(ROOT, "tests"))
from test_workflow_sweep_generator import ( # noqa: E402,F401
PKG, _FakeBridge, runner, sampler_node, wf_config)
STD = {"model_type": "checkpoint", "model": "x"}
class _BadBridge(_FakeBridge):
async def validate(self, prompt, partial):
return False, {"type": "prompt_outputs_failed_validation", "message": "nope"}, {}
@pytest.fixture
def env(sampler_node, tmp_path, monkeypatch):
import folder_paths
monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(tmp_path), raising=False)
monkeypatch.setattr(folder_paths, "get_filename_list", lambda t: ["a.safetensors"], raising=False)
monkeypatch.setattr(runner, "TICK", 0.05)
monkeypatch.setattr(sampler_node, "get_html_template", lambda *a, **k: "<html>")
monkeypatch.setattr(sys.modules["comfy"], "model_management", sys.modules["comfy.model_management"], raising=False)
runner.reset_registry()
bridge = _FakeBridge()
runner.set_bridge(bridge)
calls = []
state = {"total": 3, "hook": None, "raise": None}
go = sys.modules[f"{PKG}.generation_orchestrator"]
def fake_loop(self, *args, **kwargs):
calls.append({"args": args, "kwargs": kwargs, "active": runner._active_sweep()})
kwargs["run_info"]["total_jobs"] = state["total"]
if state["hook"]:
state["hook"]()
if state["raise"]:
raise state["raise"]
return ("<loop>",)
monkeypatch.setattr(go, "run_generation_loop", fake_loop, raising=False)
yield {"calls": calls, "state": state, "bridge": bridge, "mod": sampler_node}
sw = runner._active_sweep()
if sw is not None:
runner.cancel(sw.id)
if sw.thread:
sw.thread.join(5)
runner.set_bridge(None)
runner.reset_registry()
def cfg(configs, **settings):
s = {"session_name": "mixed_s", "start_at_job": 0}
s.update(settings)
return json.dumps({"configs": configs, "_session_settings": s})
def run(env, configs, **settings):
return env["mod"].SamplerGridTester().run_tests(cfg(configs, **settings), unique_id="9")
def test_split_configs_keeps_order_dict_or_list(sampler_node):
a, w1, b, w2 = dict(STD, name="a"), wf_config(), dict(STD, name="b"), wf_config()
std, wf = sampler_node._split_configs([a, w1, b, w2])
assert std == [a, b] and wf == [w1, w2]
assert sampler_node._split_configs({"configs": [w1, a]}) == ([a], [w1])
assert sampler_node._split_configs(None) == ([], [])
def test_mixed_run_loop_first_then_sweep_registered(env):
out = run(env, [STD, wf_config()])
assert out == ("<loop>",)
assert len(env["calls"]) == 1
call = env["calls"][0]
assert call["active"] is None # nothing registered while the standard loop runs
kw = call["kwargs"]
assert json.loads(call["args"][8]) == [STD] # configs_json = standard entries only
full = json.loads(kw["meta_configs_json"])
assert [c["model_type"] for c in full] == ["checkpoint", "workflow"]
assert kw["next_phase"] == "sweep"
sw = runner._active_sweep()
assert sw is not None and sw.session == "mixed_s" and sw.origin_node == "9" and len(sw.jobs) == 2
def test_mixed_run_keeps_builder_order_in_meta(env):
run(env, [wf_config(), STD])
full = json.loads(env["calls"][0]["kwargs"]["meta_configs_json"])
assert [c["model_type"] for c in full] == ["workflow", "checkpoint"]
@pytest.mark.parametrize("sa,expected", [(0, 0), (2, 0), (3, 0), (4, 1), (6, 3)])
def test_start_at_job_is_global_standard_first(env, sa, expected):
run(env, [STD, wf_config()], start_at_job=sa)
(sw,) = list(runner._sweeps.values())
assert sw.settings["start_at_job"] == expected
def test_start_at_one_past_standard_skips_nothing(env):
run(env, [STD, wf_config()], start_at_job=4)
sw = runner._active_sweep()
assert not [j for j in sw.jobs if j["status"] == "skipped"]
def test_overwrite_and_random_seeds_reach_the_sweep(env):
run(env, [STD, wf_config()], overwrite_existing=True, add_random_seeds_to_gens=1)
sw = runner._active_sweep()
assert sw.settings["overwrite_existing"] is True
assert len(sw.jobs) == 3 # 2 seeds + 1 derived extra seed
def test_interrupt_during_standard_phase_registers_no_sweep(env):
from comfy.model_management import InterruptProcessingException
env["state"]["raise"] = InterruptProcessingException()
with pytest.raises(InterruptProcessingException):
run(env, [STD, wf_config()])
assert runner._active_sweep() is None and not runner._sweeps
def test_preflight_busy_refuses_before_any_standard_job(env):
other = wf_config()
other["workflow_bindings"][0]["values"] = [5, 6]
runner.start_sweep([other], {"session_name": "mixed_s"}, origin_node="1")
with pytest.raises(RuntimeError, match="NOT started"):
run(env, [STD, wf_config()])
assert env["calls"] == []
def test_preflight_validation_failure_refuses_before_any_standard_job(env):
runner.set_bridge(_BadBridge())
with pytest.raises(ValueError, match="nothing ran"):
run(env, [STD, wf_config()])
assert env["calls"] == []
def test_sweep_busy_after_the_loop_says_standard_jobs_were_saved(env):
other = wf_config()
other["workflow_bindings"][0]["values"] = [5, 6]
env["state"]["hook"] = lambda: runner.start_sweep([other], {"session_name": "mixed_s"}, origin_node="1")
with pytest.raises(RuntimeError, match="Standard jobs finished"):
run(env, [STD, wf_config()])
def test_preflight_sweep_registers_nothing(env):
info = runner.preflight_sweep([wf_config()], {"session_name": "pf"}, origin_node="1")
assert info["total"] == 2 and info["to_run"] == 2
assert not runner._sweeps and env["bridge"].enqueued == []
def test_busy_conflict_ignores_an_identical_sweep(env):
sw = runner.start_sweep([wf_config()], {"session_name": "bc"}, origin_node="1")
fps = [j["fingerprint"] for j in sw.jobs]
assert runner.busy_conflict("bc", fps) is None
assert runner.busy_conflict("bc", ["other"]) is sw
assert runner.busy_conflict("elsewhere", fps) is sw
def test_pure_run_helpers():
go = sys.modules[f"{PKG}.generation_orchestrator"]
meta = go._standard_run_meta({"meta": {"rejected_preview_fps": ["a"]}}, "p", "n", "m", 1, 2, "FULL", "[[1,1]]",
"webp", "as_written", False, 8, 0.5, None, {"x": 1})
assert meta["configs_json"] == "FULL" and meta["rejected_preview_fps"] == ["a"] and meta["x"] == 1
assert meta["preview_pass"] == {"enabled": False, "steps": 8, "scale": 0.5, "cfg_override": None}
done = go._completion_payload("1", "s", 3, 1.234, "0h 0m 5s", 3)
assert done["complete"] is True and "next_phase" not in done and done["avg_duration"] == 1.2
assert go._completion_payload("1", "s", 3, 1.0, "x", 3, "sweep")["next_phase"] == "sweep"
@@ -100,6 +100,14 @@ function nodeTitle(prompt, id) {
return (n && ((n._meta && n._meta.title) || n.class_type)) || String(id);
}
/** The fps of a frame-sink node (VHS frame_rate, or SaveWEBM / SaveAnimatedWEBP / SaveAnimatedPNG fps) when it is a number. */
export function framesFps(prompt, id) {
const inputs = prompt && prompt[id] && prompt[id].inputs;
if (!inputs) return undefined;
const v = inputs.frame_rate !== undefined ? inputs.frame_rate : inputs.fps;
return typeof v === "number" ? v : undefined;
}
/** Stable <option> value of a collectable socket: `node:index:media:frames_from` (media defaults to image). */
export function outputKey(o) {
return `${o.node_id}:${o.output_index || 0}:${o.media || "image"}:${o.frames_from || ""}`;
@@ -373,7 +381,7 @@ export function renderWorkflowSweepSection(node, arrayElement, configArray, arra
addGroup("Images", sources, s => `${nodeTitle(wf.api_prompt, s.node_id)} #${s.node_id} output ${s.output_index}` + (s.picked_from ? ` (feeds ${nodeTitle(wf.api_prompt, s.picked_from)} #${s.picked_from})` : ""));
addGroup("Video", videos, s => {
if (s.kind === "frames") {
const fps = wf.api_prompt && wf.api_prompt[s.frames_from] && wf.api_prompt[s.frames_from].inputs && wf.api_prompt[s.frames_from].inputs.frame_rate;
const fps = framesFps(wf.api_prompt, s.frames_from);
return `${nodeTitle(wf.api_prompt, s.node_id)} #${s.node_id} frames (feeds ${nodeTitle(wf.api_prompt, s.frames_from)} #${s.frames_from}${typeof fps === "number" ? `, fps ${fps}` : ""})`;
}
return `${nodeTitle(wf.api_prompt, s.node_id)} #${s.node_id} output ${s.output_index}` + (s.picked_from ? ` (feeds ${nodeTitle(wf.api_prompt, s.picked_from)} #${s.picked_from})` : "");
@@ -663,7 +671,7 @@ export function renderWorkflowSweepSection(node, arrayElement, configArray, arra
mk("⏯ Resume", "", () => control("resume"), state !== "paused");
mk("⏹ Cancel", "danger", () => control("cancel"), !active);
box.appendChild(row);
box.appendChild(el("div", { textContent: "Start runs every Workflow Sweep config of this Builder. Or queue the Generator node: it starts the same sweep. Jobs go through ComfyUI's own queue, so you can keep using ComfyUI." }, "font-size: 10px; color: #888;"));
box.appendChild(el("div", { textContent: "Start runs only the Workflow Sweep arrays. Queue the Generator to run the standard configs first and then this sweep, into the same session (Start At Job counts standard jobs first)." }, "font-size: 10px; color: #888;"));
statusLine = el("div", {}, "font-size: 12px; color: #ccc; white-space: pre-wrap;");
box.appendChild(statusLine);
+74 -28
View File
@@ -28,6 +28,13 @@ import random
COLLECTOR_CLASS = "USCGSweepCollect"
COLLECTOR_VIDEO_CLASS = "USCGSweepCollectVideo"
VHS_COMBINE_CLASS = "VHS_VideoCombine"
FRAME_SINKS = { # class -> how the CreateVideo splice reads it (table order = find_video_sources order)
"VHS_VideoCombine": {"fps": "frame_rate", "audio": "audio", "default_fps": 8.0},
"SaveWEBM": {"fps": "fps", "audio": None, "default_fps": 24.0},
"SaveAnimatedWEBP": {"fps": "fps", "audio": None, "default_fps": 6.0},
"SaveAnimatedPNG": {"fps": "fps", "audio": None, "default_fps": 6.0},
}
CREATE_VIDEO_FPS = (1.0, 120.0) # CreateVideo.fps MIN/MAX
MAX_JOBS = 50000
# input name -> manifest field (class-agnostic; see plan section 4.4)
@@ -269,7 +276,8 @@ def find_output_nodes(prompt, class_info=None):
for nid in sorted(prompt, key=_node_sort_key):
ct = prompt[nid].get("class_type", "")
info = _info(class_info, ct)
is_out = bool(info and info.get("output_node")) or ct.startswith(("SaveImage", "PreviewImage", "PreviewAny", "SaveVideo"))
is_out = bool(info and info.get("output_node")) or ct.startswith(
("SaveImage", "PreviewImage", "PreviewAny", "SaveVideo", "SaveWEBM", "SaveAnimated", "VHS_VideoCombine"))
if not is_out:
continue
src = None
@@ -282,8 +290,10 @@ def find_output_nodes(prompt, class_info=None):
return outs
def _vhs_unsupported(node):
"""Why a VHS_VideoCombine node cannot be a frames source, or None when it can."""
def _frames_unsupported(node):
"""Why a frame-sink node cannot be a frames source, or None when it can (only VHS has restrictions)."""
if (node or {}).get("class_type") != VHS_COMBINE_CLASS:
return None
inputs = (node or {}).get("inputs") or {}
if is_link(inputs.get("vae")):
return "it has a `vae` link (latent input): decode the latents before VHS Video Combine"
@@ -292,11 +302,37 @@ def _vhs_unsupported(node):
return None
_vhs_unsupported = _frames_unsupported
def _sink_fps_raw(node):
"""The raw fps input of a frame-sink node (a link, a number or the sink's default)."""
sink = FRAME_SINKS.get((node or {}).get("class_type"), FRAME_SINKS[VHS_COMBINE_CLASS])
fps = ((node or {}).get("inputs") or {}).get(sink["fps"], sink["default_fps"])
if is_link(fps):
return [str(fps[0]), int(fps[1])]
try:
return float(fps)
except (TypeError, ValueError):
return float(sink["default_fps"])
def _sink_fps(node):
"""(value_or_link, clamped) for the CreateVideo fps: a link is forwarded unchanged, a literal is
clamped to CreateVideo's 1-120."""
fps = _sink_fps_raw(node)
if isinstance(fps, list):
return fps, False
lo, hi = CREATE_VIDEO_FPS
clamped = min(max(fps, lo), hi)
return clamped, clamped != fps
def find_video_sources(prompt, class_info=None, warnings=None):
"""
Every socket a video collector could be fed from, best candidate first:
1. feeders of SaveVideo*.video (kind "video", VIDEO socket)
2. feeders of VHS_VideoCombine.images (kind "frames", IMAGE socket, converted with a CreateVideo)
2. feeders of VHS_VideoCombine / SaveWEBM / SaveAnimatedWEBP / SaveAnimatedPNG .images (kind "frames", IMAGE socket, converted with a CreateVideo)
3. any node whose class advertises a VIDEO output (CreateVideo, LoadVideo, ...)
Returns [{node_id, output_index, type, media:"video", kind, frames_from?, picked_from?}].
Unsupported VHS nodes are skipped with an entry in `warnings` (when a list is given).
@@ -320,18 +356,19 @@ def find_video_sources(prompt, class_info=None, warnings=None):
v = (prompt[nid].get("inputs") or {}).get("video")
if ct.startswith("SaveVideo") and is_link(v):
add(v[0], v[1], "VIDEO", "video", picked_from=nid)
for nid in sorted(prompt, key=_node_sort_key):
if prompt[nid].get("class_type") != VHS_COMBINE_CLASS:
continue
v = (prompt[nid].get("inputs") or {}).get("images")
if not is_link(v):
continue
why = _vhs_unsupported(prompt[nid])
if why:
if warnings is not None:
warnings.append(f"{node_title(prompt, nid)} (#{nid}) was skipped as a video source: {why}.")
continue
add(v[0], v[1], "IMAGE", "frames", picked_from=nid, frames_from=nid)
for sink_class in FRAME_SINKS:
for nid in sorted(prompt, key=_node_sort_key):
if prompt[nid].get("class_type") != sink_class:
continue
v = (prompt[nid].get("inputs") or {}).get("images")
if not is_link(v):
continue
why = _frames_unsupported(prompt[nid])
if why:
if warnings is not None:
warnings.append(f"{node_title(prompt, nid)} (#{nid}) was skipped as a video source: {why}.")
continue
add(v[0], v[1], "IMAGE", "frames", picked_from=nid, frames_from=nid)
for nid in sorted(prompt, key=_node_sort_key):
ct = prompt[nid].get("class_type")
info = _info(class_info, ct)
@@ -654,18 +691,13 @@ def build_job_prompt(template, subs, output, collector_inputs, media="image", fr
return prompt, cid
source = [str(output[0]), int(output[1])]
if frames_from is not None and str(frames_from) in prompt:
vhs_inputs = prompt[str(frames_from)].get("inputs") or {}
sink_node = prompt[str(frames_from)]
sink = FRAME_SINKS.get(sink_node.get("class_type"), FRAME_SINKS[VHS_COMBINE_CLASS])
vhs_inputs = sink_node.get("inputs") or {}
images = vhs_inputs.get("images")
cv_inputs = {"images": [str(images[0]), int(images[1])] if is_link(images) else source}
fps = vhs_inputs.get("frame_rate", 8)
if is_link(fps):
cv_inputs["fps"] = [str(fps[0]), int(fps[1])]
else:
try:
cv_inputs["fps"] = float(fps)
except (TypeError, ValueError):
cv_inputs["fps"] = 8.0
audio = vhs_inputs.get("audio")
cv_inputs["fps"] = _sink_fps(sink_node)[0]
audio = vhs_inputs.get(sink["audio"]) if sink["audio"] else None
if is_link(audio):
cv_inputs["audio"] = [str(audio[0]), int(audio[1])]
cv_id = collector_node_id(prompt)
@@ -991,6 +1023,7 @@ def plan_workflow_entry(entry, extra_seeds=None, pos_prompts=None, neg_prompts=N
base["wf_frames_from"] = frames_from
jobs = []
clamped_fps = [] # raw out-of-range literal fps values of a frame sink (a swept fps can give several)
for combo in itertools.product(*[range(len(a.options)) for a in axes]):
subs, fields, params = [], {}, {}
for a, oi in zip(axes, combo):
@@ -1000,6 +1033,12 @@ def plan_workflow_entry(entry, extra_seeds=None, pos_prompts=None, neg_prompts=N
params[a.label] = opt["display"]
params.update(opt.get("extra_params") or {})
substituted = apply_subs(prompt, subs)
if frames_from is not None and frames_from in substituted:
_fps, _was_clamped = _sink_fps(substituted[frames_from])
if _was_clamped:
_raw = _sink_fps_raw(substituted[frames_from])
if _raw not in clamped_fps:
clamped_fps.append(_raw)
conf = dict(base)
conf.update(known_fields(substituted, sources, encoders, lora_nodes))
conf.update(fields)
@@ -1010,6 +1049,13 @@ def plan_workflow_entry(entry, extra_seeds=None, pos_prompts=None, neg_prompts=N
conf["wf_fingerprint"] = fingerprint(substituted, (out_id, out_idx), media, frames_from)
jobs.append(conf)
if clamped_fps:
lo, hi = CREATE_VIDEO_FPS
used = sorted({min(max(f, lo), hi) for f in clamped_fps})
warnings.append(f"{node_title(prompt, frames_from)} (#{frames_from}) fps "
f"{', '.join(f'{f:g}' for f in clamped_fps)} "
f"{'is' if len(clamped_fps) == 1 else 'are'} outside CreateVideo's 1–120; "
f"the collected mp4 uses {' / '.join(f'{u:g}' for u in used)}.")
return {
"jobs": jobs,
"axes": [{"label": a.label, "count": len(a.options), "order": i} for i, a in enumerate(axes)],
@@ -1030,8 +1076,8 @@ def plan_workflow_configs(configs, extra_seeds=None, pos_prompts=None, neg_promp
jobs, templates, axes, warnings = [], {}, [], []
for ci, entry in enumerate(configs):
if not isinstance(entry, dict) or entry.get("model_type") != "workflow":
raise ValueError("Workflow sweeps cannot be mixed with standard configs in one Generator run. "
"Use one Builder for the workflow sweep and another for standard configs.")
raise ValueError("Workflow sweeps cannot be mixed with standard configs in one sweep plan "
"(queue the Generator: it runs the standard configs first, then the sweep).")
plan = plan_workflow_entry(entry, extra_seeds, pos_prompts, neg_prompts, class_info)
jobs.extend(plan["jobs"])
templates[plan["template"]["hash"]] = plan["template"]
+24
View File
@@ -325,6 +325,18 @@ def _same_sweep(busy, session, fingerprints, start_at=0, overwrite=False):
and bool(busy.settings.get("overwrite_existing", False)) == bool(overwrite))
def busy_conflict(session, fingerprints):
"""The active Sweep that would refuse a run of `fingerprints` in `session`, or None.
An identical running sweep (same session + ordered fingerprints) is NOT a conflict: it attaches.
start_at_job / overwrite are ignored here; start_sweep still runs the full check."""
busy = _active_sweep()
if busy is None:
return None
if busy.session == session and [j["fingerprint"] for j in busy.jobs] == list(fingerprints):
return None
return busy
def _find(sweep_id=None, session=None):
with _registry_lock:
if sweep_id:
@@ -518,6 +530,18 @@ def start_sweep(configs, session_settings=None, origin_node=None, client_id=None
return e.sweep
def preflight_sweep(configs, session_settings=None, origin_node=None, bridge=None,
class_info=wfs.comfy_class_info):
"""Plan + validate job 1 + busy check WITHOUT registering or launching anything. Raises ValueError
(invalid workflow) or SweepBusy (another sweep is active). For a NON-loop thread."""
sw = prepare_sweep(configs, session_settings, origin_node, None, bridge, class_info, check_busy=False)
busy = busy_conflict(sw.session, [j["fingerprint"] for j in sw.jobs])
if busy is not None:
raise SweepBusy(busy.id, sweep=busy)
sw.bridge.run_on_loop(validate_first(sw))
return {"total": len(sw.jobs), "to_run": sw.to_run, "warnings": list(sw.warnings)}
# --------------------------------------------------------------------------- feeder thread
def _run(sw):