From dd0c12ec4dcc4809a131d0efd104746036544cd6 Mon Sep 17 00:00:00 2001 From: Jason Hoku Date: Wed, 4 Nov 2026 02:58:49 -0800 Subject: [PATCH 1/7] feat(sweep): video output sources, media-aware fingerprint and collector prompt find_video_sources (SaveVideo / VHS frames / VIDEO-returning nodes), default_output rule (SaveImage feeder still wins), normalize_output returns media + frames_from, video-only fingerprint/template_hash payload additions (image hashes byte-identical), CreateVideo splice in build_job_prompt. No new Builder field (workflow_output gains optional media keys). Co-Authored-By: Claude Fable 5.1 --- workflow_sweep.py | 205 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 171 insertions(+), 34 deletions(-) diff --git a/workflow_sweep.py b/workflow_sweep.py index 1f75c2e..8a42891 100644 --- a/workflow_sweep.py +++ b/workflow_sweep.py @@ -26,6 +26,8 @@ import random COLLECTOR_CLASS = "USCGSweepCollect" +COLLECTOR_VIDEO_CLASS = "USCGSweepCollectVideo" +VHS_COMBINE_CLASS = "VHS_VideoCombine" MAX_JOBS = 50000 # input name -> manifest field (class-agnostic; see plan section 4.4) @@ -280,10 +282,93 @@ 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.""" + 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" + if is_link(inputs.get("meta_batch")): + return "it has a `meta_batch` link (batch manager is not supported)" + return None + + +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) + 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). + """ + sources, seen = [], set() + + def add(nid, idx, rtype, kind, picked_from=None, frames_from=None): + key = (str(nid), int(idx), kind, frames_from) + if key in seen or str(nid) not in prompt: + return + seen.add(key) + entry = {"node_id": key[0], "output_index": key[1], "type": rtype, "media": "video", "kind": kind} + if frames_from is not None: + entry["frames_from"] = str(frames_from) + if picked_from is not None: + entry["picked_from"] = str(picked_from) + sources.append(entry) + + for nid in sorted(prompt, key=_node_sort_key): + ct = prompt[nid].get("class_type", "") + 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 nid in sorted(prompt, key=_node_sort_key): + ct = prompt[nid].get("class_type") + info = _info(class_info, ct) + if info is None: + if ct == "CreateVideo": # no registry (tests): the one core node we know produces VIDEO + add(nid, 0, "VIDEO", "video") + continue + if not info.get("missing"): + for idx, rt in enumerate(info.get("return_types") or []): + if rt == "VIDEO": + add(nid, idx, "VIDEO", "video") + return sources + + +def source_to_output(src): + """A source entry -> the stored `workflow_output` shape. Image outputs stay {node_id, output_index} only.""" + out = {"node_id": str(src["node_id"]), "output_index": int(src.get("output_index", 0) or 0)} + if src.get("media") == "video": + out["media"] = "video" + if src.get("frames_from") is not None: + out["frames_from"] = str(src["frames_from"]) + return out + + def default_output(prompt, class_info=None): - """The socket that should feed the collector: {node_id, output_index, picked_from?} or None.""" - sources = find_image_sources(prompt, class_info) - return sources[0] if sources else None + """ + The socket that should feed the collector, in `workflow_output` shape, or None. + A SaveImage / PreviewImage feeder wins (image workflows behave as they always did); otherwise the + best video source; otherwise any IMAGE socket. + """ + images = find_image_sources(prompt, class_info) + if images and images[0].get("picked_from") is not None: + return images[0] + videos = find_video_sources(prompt, class_info) + if videos: + return source_to_output(videos[0]) + return images[0] if images else None def describe_nodes(prompt, class_info=None): @@ -510,9 +595,11 @@ def coerce_like(value, template_value): return value -def template_hash(prompt, output, run_other_outputs=False): - payload = json.dumps({"p": prompt, "o": [str(output[0]), int(output[1])], "r": bool(run_other_outputs)}, - sort_keys=True, separators=(",", ":"), default=str) +def template_hash(prompt, output, run_other_outputs=False, media="image", frames_from=None): + body = {"p": prompt, "o": [str(output[0]), int(output[1])], "r": bool(run_other_outputs)} + if media == "video": # image templates keep the exact payload they always had (resume of old sessions) + body["m"], body["f"] = "video", (None if frames_from is None else str(frames_from)) + payload = json.dumps(body, sort_keys=True, separators=(",", ":"), default=str) return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] @@ -520,10 +607,14 @@ def _strip_meta(prompt): return {nid: {k: v for k, v in node.items() if k != "_meta"} for nid, node in prompt.items()} -def fingerprint(substituted_prompt, output): - """Resume key of one job: sha256 of the substituted template (no _meta, no collector) + output socket.""" - payload = json.dumps({"p": _strip_meta(substituted_prompt), "o": [str(output[0]), int(output[1])]}, - sort_keys=True, separators=(",", ":"), default=str) +def fingerprint(substituted_prompt, output, media="image", frames_from=None): + """Resume key of one job: sha256 of the substituted template (no _meta, no collector) + output socket. + Video collections add the media kind (and the VHS node for frames), so a video and an image + collection of the same socket never skip each other; image fingerprints are unchanged.""" + body = {"p": _strip_meta(substituted_prompt), "o": [str(output[0]), int(output[1])]} + if media == "video": + body["m"], body["f"] = "video", (None if frames_from is None else str(frames_from)) + payload = json.dumps(body, sort_keys=True, separators=(",", ":"), default=str) return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:20] @@ -547,16 +638,43 @@ def collector_node_id(prompt): return str(cid) -def build_job_prompt(template, subs, output, collector_inputs): +def build_job_prompt(template, subs, output, collector_inputs, media="image", frames_from=None): """ Substitute `subs` into a copy of the template and append the collector node. - Nothing else is touched. Returns (prompt, collector_id). + Nothing else is touched, except for a frames-sourced video (VHS), where a core `CreateVideo` node is + spliced in front of the collector (images / fps / audio copied from the VHS node of the substituted + prompt), so the collector only ever sees a VIDEO. Returns (prompt, collector_id). """ prompt = apply_subs(template, subs) + if media != "video": + cid = collector_node_id(prompt) + inputs = {"images": [str(output[0]), int(output[1])]} + inputs.update(collector_inputs or {}) + prompt[cid] = {"class_type": COLLECTOR_CLASS, "inputs": inputs, "_meta": {"title": "USCG Sweep Collect"}} + 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 {} + 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") + if is_link(audio): + cv_inputs["audio"] = [str(audio[0]), int(audio[1])] + cv_id = collector_node_id(prompt) + prompt[cv_id] = {"class_type": "CreateVideo", "inputs": cv_inputs, "_meta": {"title": "USCG Sweep Create Video"}} + source = [cv_id, 0] cid = collector_node_id(prompt) - inputs = {"images": [str(output[0]), int(output[1])]} + inputs = {"video": source} inputs.update(collector_inputs or {}) - prompt[cid] = {"class_type": COLLECTOR_CLASS, "inputs": inputs, "_meta": {"title": "USCG Sweep Collect"}} + prompt[cid] = {"class_type": COLLECTOR_VIDEO_CLASS, "inputs": inputs, "_meta": {"title": "USCG Sweep Collect Video"}} return prompt, cid @@ -808,17 +926,27 @@ def order_axes(prompt, axes, anc_set): def normalize_output(entry, prompt, class_info=None): + """(node_id, output_index, media, frames_from) of the socket to collect. media is "image" or "video".""" wo = entry.get("workflow_output") - if isinstance(wo, dict) and wo.get("node_id") is not None: - nid, idx = str(wo["node_id"]), int(wo.get("output_index", 0) or 0) - else: - d = default_output(prompt, class_info) - if not d: - raise ValueError("No image output found. Pick the image socket to collect in the workflow section.") - nid, idx = d["node_id"], d["output_index"] + if not (isinstance(wo, dict) and wo.get("node_id") is not None): + wo = default_output(prompt, class_info) + if not wo: + raise ValueError("No image or video output found. Pick the socket to collect in the workflow section.") + nid, idx = str(wo["node_id"]), int(wo.get("output_index", 0) or 0) + media = str(wo.get("media") or "image").lower() + if media not in ("image", "video"): + raise ValueError(f"Unknown output media '{media}' (expected image or video).") + frames_from = wo.get("frames_from") + frames_from = None if (frames_from is None or media != "video") else str(frames_from) if nid not in prompt: raise ValueError(f"The chosen output node {nid} is not in the workflow.") - return nid, idx + if frames_from is not None: + if frames_from not in prompt: + raise ValueError(f"The video frames node {frames_from} is not in the workflow.") + why = _vhs_unsupported(prompt[frames_from]) + if why: + raise ValueError(f"{node_title(prompt, frames_from)} (#{frames_from}) cannot be used as a video source: {why}.") + return nid, idx, media, frames_from def plan_workflow_entry(entry, extra_seeds=None, pos_prompts=None, neg_prompts=None, class_info=None): @@ -830,10 +958,11 @@ def plan_workflow_entry(entry, extra_seeds=None, pos_prompts=None, neg_prompts=N Each job conf is flat (4.3 in the plan) and carries wf_subs; the template is NOT copied into confs. """ prompt = parse_api_workflow(entry.get("workflow")) - out_id, out_idx = normalize_output(entry, prompt, class_info) + out_id, out_idx, media, frames_from = normalize_output(entry, prompt, class_info) run_other = bool(entry.get("workflow_run_other_outputs", False)) warnings = [] - anc = ancestors_ordered(prompt, out_id) + anchor = frames_from or out_id # frames mode: the VHS node, so its own inputs (frame_rate) count as upstream + anc = ancestors_ordered(prompt, anchor) anc_set = set(anc) bindings = entry.get("workflow_bindings") or [] @@ -851,11 +980,15 @@ def plan_workflow_entry(entry, extra_seeds=None, pos_prompts=None, neg_prompts=N if total > MAX_JOBS: raise ValueError(f"This sweep would create more than {MAX_JOBS} jobs; narrow the axes.") - sources, encoders, lora_nodes = _field_sources(prompt, out_id) - thash = template_hash(prompt, (out_id, out_idx), run_other) + sources, encoders, lora_nodes = _field_sources(prompt, anchor) + thash = template_hash(prompt, (out_id, out_idx), run_other, media, frames_from) source_name = str(entry.get("workflow_source") or "") - base = {"model_type": "workflow", "media_type": "image", "wf_template_hash": thash, + base = {"model_type": "workflow", "media_type": media, "wf_template_hash": thash, "wf_source": source_name, "wf_output": f"{out_id}:{out_idx}"} + if media == "video": + base["wf_output_media"] = "video" + if frames_from is not None: + base["wf_frames_from"] = frames_from jobs = [] for combo in itertools.product(*[range(len(a.options)) for a in axes]): @@ -874,14 +1007,15 @@ def plan_workflow_entry(entry, extra_seeds=None, pos_prompts=None, neg_prompts=N conf["negative"] = conf.get("negative", "") if isinstance(conf.get("negative", ""), str) else "" conf["wf_subs"] = [[n, i, v] for n, i, v in subs] conf["wf_params"] = params - conf["wf_fingerprint"] = fingerprint(substituted, (out_id, out_idx)) + conf["wf_fingerprint"] = fingerprint(substituted, (out_id, out_idx), media, frames_from) jobs.append(conf) return { "jobs": jobs, "axes": [{"label": a.label, "count": len(a.options), "order": i} for i, a in enumerate(axes)], "warnings": warnings, - "template": {"hash": thash, "prompt": prompt, "output": [out_id, out_idx], "run_other_outputs": run_other}, + "template": {"hash": thash, "prompt": prompt, "output": [out_id, out_idx], "run_other_outputs": run_other, + "media": media, "frames_from": frames_from}, } @@ -956,14 +1090,17 @@ def parse_for_ui(api_json, class_info=comfy_class_info): """Response body of POST /config_tester/sweep/parse. Raises ValueError for unusable input.""" prompt = parse_api_workflow(api_json) sources = find_image_sources(prompt, class_info) - default = sources[0] if sources else None - suggestions = suggest_bindings(prompt, default, class_info) if default else [] warnings = [] + video_sources = find_video_sources(prompt, class_info, warnings) + default = default_output(prompt, class_info) + anchor = {"node_id": default.get("frames_from") or default["node_id"]} if default else None + suggestions = suggest_bindings(prompt, anchor, class_info) if default else [] nodes_ = describe_nodes(prompt, class_info) missing = sorted({n["class_type"] for n in nodes_ if n["missing_class"]}) if missing: warnings.append("These node types are not installed in this ComfyUI: " + ", ".join(missing)) - if not sources: - warnings.append("No IMAGE output was found; pick the image socket to collect manually.") + if not sources and not video_sources: + warnings.append("No image or video output was found; pick the socket to collect manually.") return {"ok": True, "nodes": nodes_, "outputs": find_output_nodes(prompt, class_info), - "image_sources": sources, "suggestions": suggestions, "warnings": warnings, "errors": []} + "image_sources": sources, "video_sources": video_sources, "default_output": default, + "suggestions": suggestions, "warnings": warnings, "errors": []} From 9aad5ad6dae5be4a832bef8305272349c71edaf0 Mon Sep 17 00:00:00 2001 From: Jason Hoku Date: Wed, 4 Nov 2026 03:01:11 -0800 Subject: [PATCH 2/7] feat(sweep): runner passes template media to build_job_prompt, spec records it Co-Authored-By: Claude Fable 5.1 --- workflow_sweep_runner.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/workflow_sweep_runner.py b/workflow_sweep_runner.py index ff7181e..3dbb38e 100644 --- a/workflow_sweep_runner.py +++ b/workflow_sweep_runner.py @@ -420,7 +420,8 @@ def build_job_request(sw, job): "image_format": sw.settings.get("image_format", "webp")} prompt, cid = wfs.build_job_prompt(tpl["prompt"], conf["wf_subs"], tpl["output"], {"session_name": sw.session, - "job_json": json.dumps(job_json, ensure_ascii=False)}) + "job_json": json.dumps(job_json, ensure_ascii=False)}, + tpl.get("media", "image"), tpl.get("frames_from")) partial = None if tpl["run_other_outputs"] else [cid] return prompt, partial @@ -599,7 +600,7 @@ def _poll(sw, inflight): if job["collected"]: _finish_done(sw, job, detail, now) elif now - job["success_seen_at"] > COLLECT_GRACE: - _fail_job(sw, job, "error", "the job ran but the collector produced no image") + _fail_job(sw, job, "error", "the job ran but the collector produced no output") _after_terminal(sw) elif kind == "error": _fail_job(sw, job, "error", detail["error"]) @@ -940,7 +941,9 @@ def _write_spec(sw): "sweep_id": sw.id, "session_name": sw.session, "state": sw.state, "reason": sw.reason, "created_at": sw.created_at, "settings": sw.settings, "axes": [a for c in sw.axes for a in c["axes"]], "warnings": sw.warnings, "templates": {h: {"prompt": t["prompt"], "output": t["output"], - "run_other_outputs": t["run_other_outputs"]} for h, t in sw.templates.items()}, + "run_other_outputs": t["run_other_outputs"], + "media": t.get("media", "image"), "frames_from": t.get("frames_from")} + for h, t in sw.templates.items()}, "jobs": [{"index": j["index"], "fingerprint": j["fingerprint"], "status": j["status"], "prompt_id": j["prompt_id"], "error": j["error"], "duration": j["duration"], "template": j["conf"].get("wf_template_hash"), "subs": j["conf"].get("wf_subs"), From 930874f068b429b635c90e85108edf289188cfaf Mon Sep 17 00:00:00 2001 From: Jason Hoku Date: Wed, 4 Nov 2026 03:01:52 -0800 Subject: [PATCH 3/7] feat(sweep): USCGSweepCollectVideo collector node (mp4 via VideoInput.save_to, poster, LTX-shaped item) Co-Authored-By: Claude Fable 5.1 --- __init__.py | 4 +- workflow_sweep_collect.py | 145 +++++++++++++++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/__init__.py b/__init__.py index adfd570..bd300a3 100644 --- a/__init__.py +++ b/__init__.py @@ -21,7 +21,7 @@ from .run_api import ( RunTracker, sanitize_session_name_strict, normalize_configs_payload, apply_run_overrides, build_run_prompt, resolve_saved_config, saved_config_to_configs_obj, summarize_history, find_in_queue, ) -from .workflow_sweep_collect import USCGSweepCollect +from .workflow_sweep_collect import USCGSweepCollect, USCGSweepCollectVideo import sys # Register a sys.modules alias for cross-companion imports. @@ -1576,6 +1576,7 @@ NODE_CLASS_MAPPINGS = { "UltimateConfigBuilder": UltimateConfigBuilder, "SmartJSONText": SmartJSONTextNode, "USCGSweepCollect": USCGSweepCollect, + "USCGSweepCollectVideo": USCGSweepCollectVideo, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -1584,6 +1585,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "UltimateConfigBuilder": "Ultimate Config Builder", "SmartJSONText": "Smart JSON Text", "USCGSweepCollect": "USCG Sweep Collect", + "USCGSweepCollectVideo": "USCG Sweep Collect Video", } WEB_DIRECTORY = "./web" \ No newline at end of file diff --git a/workflow_sweep_collect.py b/workflow_sweep_collect.py index f206a86..a202048 100644 --- a/workflow_sweep_collect.py +++ b/workflow_sweep_collect.py @@ -136,5 +136,146 @@ class USCGSweepCollect: return {"ui": {"images": files}} -NODE_CLASS_MAPPINGS = {"USCGSweepCollect": USCGSweepCollect} -NODE_DISPLAY_NAME_MAPPINGS = {"USCGSweepCollect": "USCG Sweep Collect"} +def _save_mp4(video, path, prompt=None): + """Write a VIDEO to `path` as mp4/h264 with the core `VideoInput.save_to` (what SaveVideo.execute calls; + SaveVideo itself needs executor-populated hidden inputs, so it cannot be invoked from a node body).""" + try: + from comfy_api.latest._input_impl import VideoContainer as _Container + except ImportError: + try: + from comfy_api.latest._input.video_types import VideoContainer as _Container + except ImportError: + _Container = None + fmt = "mp4" + if _Container is not None: + try: + fmt = _Container("mp4") + except Exception: + fmt = "mp4" + metadata = {"prompt": prompt} if prompt else None + try: + video.save_to(path, format=fmt, codec="auto", metadata=metadata) + except TypeError: + video.save_to(path) # older Comfy: differently named kwargs + + +class USCGSweepCollectVideo: + """Video twin of USCGSweepCollect: one VIDEO per job, saved as mp4 with a middle-frame poster.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "video": ("VIDEO",), + "session_name": ("STRING", {"default": "workflow_sweep"}), + "job_json": ("STRING", {"multiline": True, "default": "{}"}), + }, + "hidden": {"prompt": "PROMPT", "unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = () + OUTPUT_NODE = True + FUNCTION = "collect" + CATEGORY = "sampling/testing" + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("NaN") + + def collect(self, video, session_name="workflow_sweep", job_json="{}", prompt=None, unique_id=None): + paths = wfs.session_paths(session_name) + session = paths["session"] + os.makedirs(paths["images"], exist_ok=True) + + try: + job = json.loads(job_json or "{}") + job = job if isinstance(job, dict) else {} + except (TypeError, ValueError): + job = {} + conf = job.get("conf") if isinstance(job.get("conf"), dict) else {} + sweep_id, job_index = job.get("sweep_id"), job.get("job_index") + + started = None + if sweep_id is not None and job_index is not None: + started = _runner().job_started_at(sweep_id, job_index) + duration = round(time.time() - started, 2) if started else 0 + + item_id = wfs.new_item_id() + stem = f"vid_{item_id}" + subfolder = f"benchmarks/{session}/images" + filename = f"{stem}.mp4" + filepath = os.path.join(paths["images"], filename) + _save_mp4(video, filepath, prompt) + + components = None + try: # frames + fps + poster; decoding a file-backed VIDEO into RAM is the known cost of this + components = video.get_components() + except Exception as e: + print(f"[GridTester] โš ๏ธ Sweep video: could not read the frames back ({e}); poster skipped") + frames_t = getattr(components, "images", None) + try: + frames = int(video.get_frame_count()) + except Exception: + frames = int(len(frames_t)) if frames_t is not None else 0 + try: + fps = float(video.get_frame_rate()) + except Exception: + fps = float(getattr(components, "frame_rate", 0) or 0) + try: + width, height = (int(x) for x in video.get_dimensions()) + except Exception: + width, height = 0, 0 + if frames_t is not None and len(frames_t): + first = _tensor_to_array(frames_t, 0) + height, width = first.shape[0], first.shape[1] + + preview_path = None + if frames_t is not None and len(frames_t): + try: # middle frame, like the LTX previews + arr = _tensor_to_array(frames_t, len(frames_t) // 2) + preview_path = os.path.join(paths["images"], f"{stem}.preview.png") + Image.fromarray(arr).convert("RGB").save(preview_path) + except Exception as e: + preview_path = None + print(f"[GridTester] โš ๏ธ Sweep video: poster could not be written ({e})") + + item = wfs.build_collect_item(conf, width, height, duration, 0, job.get("gen_index")) + item["id"] = item_id + item["media_type"] = "video" + item["file"] = f"/view?filename={filename}&type=output&subfolder={subfolder}" + item["image_path"] = filepath + item["video_path"] = filepath + if preview_path: + item["preview_path"] = preview_path + item["poster"] = f"/view?filename={stem}.preview.png&type=output&subfolder={subfolder}" + item["frames"] = frames + item["frame_rate"] = int(fps) if fps and float(fps).is_integer() else (round(fps, 3) if fps else 0) + item["duration_seconds"] = round(frames / fps, 2) if fps else 0 + if sweep_id is not None: + item["wf_sweep_id"] = sweep_id + if job_index is not None: + item["wf_job_index"] = job_index + items = [item] + + manifest = _append_items(paths["manifest"], items, remove_ids=job.get("replace_item_ids") or (), + session_name=session) + try: + from server import PromptServer + PromptServer.instance.send_sync("ultimate_grid.update", { + "node": job.get("origin_node"), "session_name": session, "new_items": items, + "meta": (manifest or {}).get("meta", {})}) + except Exception: + pass + if sweep_id is not None: + try: + _runner().on_collected(sweep_id, job_index, items) + except Exception: + pass + print(f"[GridTester] ๐Ÿงช Sweep collected 1 video into '{session}'") + return {"ui": {"images": [{"filename": filename, "subfolder": subfolder, "type": "output"}], + "animated": (True,)}} + + +NODE_CLASS_MAPPINGS = {"USCGSweepCollect": USCGSweepCollect, "USCGSweepCollectVideo": USCGSweepCollectVideo} +NODE_DISPLAY_NAME_MAPPINGS = {"USCGSweepCollect": "USCG Sweep Collect", + "USCGSweepCollectVideo": "USCG Sweep Collect Video"} From 7fd1a6474125622e8f60a02c075d389553785244 Mon Sep 17 00:00:00 2001 From: Jason Hoku Date: Wed, 4 Nov 2026 03:02:38 -0800 Subject: [PATCH 4/7] feat(sweep): Builder output picker lists video sources; dashboard keeps JOB for sweep videos No new top-level Builder field: workflow_output gains optional media/frames_from keys, emitted only for video, so the rule-5 checklist does not apply and image configs JSON is unchanged. Co-Authored-By: Claude Fable 5.1 --- config_builder_node.py | 9 ++- resources/logic_ui.js | 5 +- .../conf-builder-workflow-sweep.js | 62 ++++++++++++++----- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/config_builder_node.py b/config_builder_node.py index fa3f051..be4ada8 100644 --- a/config_builder_node.py +++ b/config_builder_node.py @@ -575,13 +575,18 @@ class UltimateConfigBuilder: The job count is the product of the bindings' axes, expanded by config_utils.expand_configs.""" wf = config_array.get("workflow") or {} out = wf.get("output") or {} + workflow_output = ({"node_id": str(out["node_id"]), "output_index": int(out.get("output_index", 0) or 0)} + if out.get("node_id") is not None else {}) + if workflow_output and out.get("media") == "video": # video-only keys: image sweeps emit the same JSON as ever + workflow_output["media"] = "video" + if out.get("frames_from") is not None: + workflow_output["frames_from"] = str(out["frames_from"]) config = { "model_type": "workflow", "name": str(config_array.get("name", "")), "workflow": wf.get("api_prompt") or {}, "workflow_source": str(wf.get("source_name", "") or ""), - "workflow_output": {"node_id": str(out["node_id"]), "output_index": int(out.get("output_index", 0) or 0)} - if out.get("node_id") is not None else {}, + "workflow_output": workflow_output, "workflow_run_other_outputs": bool(wf.get("run_other_outputs", False)), "workflow_bindings": list(wf.get("bindings") or []), } diff --git a/resources/logic_ui.js b/resources/logic_ui.js index 8ab75ad..d0b63c4 100644 --- a/resources/logic_ui.js +++ b/resources/logic_ui.js @@ -1380,7 +1380,7 @@ function createCard(d) { : `Image ${d.id}`; // Workflow Sweep items: the Revise modal builds a standard config and would lose the workflow template // (they get a JOB button instead: openM shows the read-only Workflow job panel) - const reviseBtn = isVideo ? '' : ``; + const reviseBtn = (isVideo && d.model_type !== 'workflow') ? '' : ``; const upscaleBtn = isVideo ? '' : ``; const videoBadge = isVideo ? '
\u25B6 VIDEO
' : ''; const previewBadge = d.preview @@ -1558,7 +1558,8 @@ function openM(id) { window.currentModalId = id; const d = activeData.find(x => x.id === id); if (!d) return; - document.getElementById('m-img').src = d.file; + // a video item opens its poster (middle frame) in the modal; the card itself plays the video + document.getElementById('m-img').src = (d.media_type === 'video' && d.poster) ? d.poster : d.file; _setModalWorkflowMode(d); _uscgSyncModalDrag(d); const previewNote = document.getElementById('m-preview-note'); diff --git a/web/conf_builder/conf-builder-workflow-sweep.js b/web/conf_builder/conf-builder-workflow-sweep.js index d6ef7e1..148ecd0 100644 --- a/web/conf_builder/conf-builder-workflow-sweep.js +++ b/web/conf_builder/conf-builder-workflow-sweep.js @@ -2,7 +2,7 @@ * Config Builder Workflow Sweep Module * * UI for a config array of kind "workflow": load an API-format ComfyUI workflow (Export (API)), - * pick the image socket to collect, turn literal inputs into sweep axes ("bindings"), dry-run the + * pick the image or video socket to collect, turn literal inputs into sweep axes ("bindings"), dry-run the * generated prompts and start / pause / resume / cancel the sweep. Python side: workflow_sweep.py * (expansion), workflow_sweep_runner.py (queue feeder), routes /config_tester/sweep/*. * @@ -100,6 +100,23 @@ function nodeTitle(prompt, id) { return (n && ((n._meta && n._meta.title) || n.class_type)) || String(id); } +/** Stable