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
+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 = {