diff --git a/README.md b/README.md index 07641df..2d12eae 100644 --- a/README.md +++ b/README.md @@ -923,8 +923,9 @@ How it behaves: * Each sweep writes `benchmarks//workflow_sweeps/.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_.preview.png` into `benchmarks//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_.preview.png` into `benchmarks//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. diff --git a/Roadmap.md b/Roadmap.md index c0402b1..f533808 100644 --- a/Roadmap.md +++ b/Roadmap.md @@ -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. diff --git a/generation_orchestrator.py b/generation_orchestrator.py index 5749cdd..eeb5ae0 100644 --- a/generation_orchestrator.py +++ b/generation_orchestrator.py @@ -1000,9 +1000,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 @@ -1154,6 +1161,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 @@ -1237,7 +1247,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, @@ -1246,7 +1256,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 @@ -1613,20 +1624,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 @@ -1968,20 +1969,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 @@ -2809,20 +2800,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 @@ -2919,20 +2900,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...") @@ -2962,20 +2933,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 @@ -3394,6 +3354,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): @@ -3425,7 +3428,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. @@ -4121,20 +4125,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 diff --git a/resources/logic_events.js b/resources/logic_events.js index 2655746..d321bcc 100644 --- a/resources/logic_events.js +++ b/resources/logic_events.js @@ -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; diff --git a/sampler_node.py b/sampler_node.py index a604245..4d8e769 100644 --- a/sampler_node.py +++ b/sampler_node.py @@ -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 = { diff --git a/tests/e2e/test_sweep_progress.py b/tests/e2e/test_sweep_progress.py new file mode 100644 index 0000000..bcfa7dc --- /dev/null +++ b/tests/e2e/test_sweep_progress.py @@ -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") diff --git a/tests/fixtures/video_animated_png_api.json b/tests/fixtures/video_animated_png_api.json new file mode 100644 index 0000000..cb9640e --- /dev/null +++ b/tests/fixtures/video_animated_png_api.json @@ -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"}} +} diff --git a/tests/fixtures/video_animated_webp_api.json b/tests/fixtures/video_animated_webp_api.json new file mode 100644 index 0000000..345c256 --- /dev/null +++ b/tests/fixtures/video_animated_webp_api.json @@ -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"}} +} diff --git a/tests/fixtures/video_webm_api.json b/tests/fixtures/video_webm_api.json new file mode 100644 index 0000000..78749ba --- /dev/null +++ b/tests/fixtures/video_webm_api.json @@ -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"}} +} diff --git a/tests/js/workflow-frames-fps.test.js b/tests/js/workflow-frames-fps.test.js new file mode 100644 index 0000000..1ca264c --- /dev/null +++ b/tests/js/workflow-frames-fps.test.js @@ -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); +}); diff --git a/tests/test_dist_meta.py b/tests/test_dist_meta.py index 098ea3f..c6d6eda 100644 --- a/tests/test_dist_meta.py +++ b/tests/test_dist_meta.py @@ -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": []})) diff --git a/tests/test_run_meta_extras.py b/tests/test_run_meta_extras.py index c378091..b0aec0a 100644 --- a/tests/test_run_meta_extras.py +++ b/tests/test_run_meta_extras.py @@ -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 diff --git a/tests/test_workflow_sweep_frame_sinks.py b/tests/test_workflow_sweep_frame_sinks.py new file mode 100644 index 0000000..ee55761 --- /dev/null +++ b/tests/test_workflow_sweep_frame_sinks.py @@ -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"]) diff --git a/tests/test_workflow_sweep_generator.py b/tests/test_workflow_sweep_generator.py index 834aa8b..c34a59d 100644 --- a/tests/test_workflow_sweep_generator.py +++ b/tests/test_workflow_sweep_generator.py @@ -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 ("",) + + 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") == ("",) + 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() diff --git a/tests/test_workflow_sweep_mixed.py b/tests/test_workflow_sweep_mixed.py new file mode 100644 index 0000000..7dd2a65 --- /dev/null +++ b/tests/test_workflow_sweep_mixed.py @@ -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: "") + 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 ("",) + + 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 == ("",) + 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" diff --git a/web/conf_builder/conf-builder-workflow-sweep.js b/web/conf_builder/conf-builder-workflow-sweep.js index 76b617a..ec5f3b0 100644 --- a/web/conf_builder/conf-builder-workflow-sweep.js +++ b/web/conf_builder/conf-builder-workflow-sweep.js @@ -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