Merge branch 'feat/sweep-video' into int/wave3
This commit is contained in:
+2
-2
@@ -40,11 +40,11 @@ Read `ProjectStructure.md` in this directory for the full reference (file struct
|
||||
| `metadata_packer.py` | 561 | PNG metadata embedding for CivitAI. `pack_metadata_into_image()` accepts `workflow_data` kwarg |
|
||||
| `run_api.py` | 144 | Pure helpers behind `POST /config_tester/run` and `GET /config_tester/run_status` |
|
||||
| `image_metrics.py`, `metrics_runner.py` | 668, 167 | Optional per-image metrics at save time; background job that backfills them for old sessions |
|
||||
| `workflow_sweep.py`, `workflow_sweep_runner.py`, `workflow_sweep_collect.py` | 969, 912, 140 | Workflow Sweep: pure expansion core, in-process queue feeder (never blocks the executor), `USCGSweepCollect` output node |
|
||||
| `workflow_sweep.py`, `workflow_sweep_runner.py`, `workflow_sweep_collect.py` | 1106, 1027, 281 | Workflow Sweep: pure expansion core, in-process queue feeder (never blocks the executor), `USCGSweepCollect` / `USCGSweepCollectVideo` output nodes |
|
||||
| `web/conf_builder/conf-builder-main.js` | 779 | Node registration. Default state and its migration blocks define ALL fields including upscaling/cooldown |
|
||||
| `web/conf_builder/conf-builder-utilities.js` | 866 | Model-list cache (`LS_CACHE_VERSION`), `getIterationCount()`, LoRA string helpers. No config converter: the preview calls `/configbuilder/preview` |
|
||||
| `web/conf_builder/conf-builder-config-management.js` | 7736 | All config builder UI rendering. Upscaling section, preview fetch |
|
||||
| `web/conf_builder/conf-builder-workflow-sweep.js` | 744 | Workflow Sweep UI (load API workflow, axes, dry run, start/pause/cancel) |
|
||||
| `web/conf_builder/conf-builder-workflow-sweep.js` | 806 | Workflow Sweep UI (load API workflow, axes, dry run, start/pause/cancel) |
|
||||
| `resources/logic_utils.js` | 1073 | Dashboard helpers: `exportFavorites()`, `deleteNonFavorites()`, `loadSession()`, `scanDirectory()` |
|
||||
| `resources/logic_ui.js` | 2907 | Dashboard DOM: cards, modals, `buildComfyNodesWorkflow()`, analytics |
|
||||
| `resources/logic_virtual.js` | 1092 | Virtual scroller: renders only visible cards from thousands |
|
||||
|
||||
+8
-6
@@ -59,9 +59,9 @@ ComfyUI-Ultimate-Sampler-Grid/
|
||||
├── upscale_runner.py # Dashboard async upscaling background thread (642 lines)
|
||||
├── civitai.py # Facade for the ComfyUI-USCG-CivitAI companion, no network code here (40 lines)
|
||||
├── distribution.py # Facade for the ComfyUI-USCG-Distributed companion, no network code here (107 lines)
|
||||
├── workflow_sweep.py # Workflow Sweep core: parse API workflow, bindings -> jobs, prompt substitution, fingerprints (pure, no torch/server) (969 lines)
|
||||
├── workflow_sweep_runner.py # Workflow Sweep runner: ComfyBridge (in-process post_prompt mirror), windowed feeder thread, pause/resume/cancel, route helpers (912 lines)
|
||||
├── workflow_sweep_collect.py # USCGSweepCollect output node: saves each job's image + manifest item into the USCG session (140 lines)
|
||||
├── workflow_sweep.py # Workflow Sweep core: parse API workflow, bindings -> jobs, prompt substitution, fingerprints (pure, no torch/server) (1106 lines)
|
||||
├── workflow_sweep_runner.py # Workflow Sweep runner: ComfyBridge (in-process post_prompt mirror), windowed feeder thread, pause/resume/cancel, route helpers (1027 lines)
|
||||
├── workflow_sweep_collect.py # USCGSweepCollect / USCGSweepCollectVideo output nodes: save each job's image (or mp4 + poster) + manifest item into the USCG session (281 lines)
|
||||
├── html_generator.py # Reads /resources/ to build the Dashboard HTML (66 lines)
|
||||
├── run_api.py # Pure helpers for POST /config_tester/run + run_status, no server imports (144 lines)
|
||||
├── conftest.py # Registry import stub: lets pytest import this package without ComfyUI (213 lines)
|
||||
@@ -77,7 +77,7 @@ ComfyUI-Ultimate-Sampler-Grid/
|
||||
│ ├── conf-builder-config-management.js # Save/Load/Duplicate config logic, all Builder rendering (7736 lines)
|
||||
│ ├── conf-builder-utilities.js # Model-list cache, iteration counting, LoRA string helpers (866 lines)
|
||||
│ ├── conf-builder-distribution.js # Distribution settings UI (workers, toggles) (620 lines)
|
||||
│ ├── conf-builder-workflow-sweep.js # Workflow Sweep UI (load API workflow, axes, dry run, start/pause/cancel) (744 lines)
|
||||
│ ├── conf-builder-workflow-sweep.js # Workflow Sweep UI (load API workflow, axes, dry run, start/pause/cancel, image/video output picker) (806 lines)
|
||||
│ └── conf-builder-readme.md # Notes on the Builder module split (134 lines)
|
||||
│
|
||||
├── resources/ # [The Dashboard SPA Core] (listed in html_generator.js_files order)
|
||||
@@ -195,6 +195,7 @@ Defined at the end of `__init__.py`:
|
||||
| `UltimateConfigBuilder` | `UltimateConfigBuilder` | Visual config builder — outputs `configs_json` string |
|
||||
| `SmartJSONText` | `SmartJSONTextNode` | JSON text widget with syntax highlighting |
|
||||
| `USCGSweepCollect` | `USCGSweepCollect` | Workflow Sweep output node (`workflow_sweep_collect.py`): saves each job's image + manifest item into the USCG session |
|
||||
| `USCGSweepCollectVideo` | `USCGSweepCollectVideo` | Workflow Sweep video output node (same file): takes a VIDEO, saves one mp4 via `VideoInput.save_to` + a middle-frame poster, writes an LTX-shaped manifest item. VHS frames are converted by a core `CreateVideo` node the sweep splices into the job prompt (`workflow_sweep.build_job_prompt`) |
|
||||
|
||||
`WEB_DIRECTORY = "./web"` — tells ComfyUI to serve `web/` files at `/extensions/`.
|
||||
|
||||
@@ -254,7 +255,7 @@ All routes registered via `PromptServer.instance.routes`. One row per route this
|
||||
| POST | `/config_tester/compute_metrics` | `__init__.py` | `{session_name, overwrite?}` | Start the background metrics job for a session; returns `{job_id, total_images}` |
|
||||
| GET | `/config_tester/metrics_status` | `__init__.py` | `?job_id=` | Metrics job progress |
|
||||
| POST | `/config_tester/cancel_metrics` | `__init__.py` | `{job_id}` | Cancel a metrics job; returns `{cancelled}` |
|
||||
| POST | `/config_tester/sweep/parse` | `__init__.py` | `{api_json}` | Workflow Sweep: describe an API-format workflow (nodes, outputs, image sources, suggested bindings) |
|
||||
| POST | `/config_tester/sweep/parse` | `__init__.py` | `{api_json}` | Workflow Sweep: describe an API-format workflow (nodes, outputs, `image_sources`, `video_sources`, `default_output`, suggested bindings) |
|
||||
| POST | `/config_tester/sweep/dry_run` | `__init__.py` | `{state or configs_json, limit?}` | Workflow Sweep: first N concrete prompts + validation of job 1; queues nothing |
|
||||
| GET | `/config_tester/sweep/job_prompt` | `__init__.py` | `?session_name=&sweep_id=&job_index=` | Workflow Sweep: `{prompt, params, fingerprint, output, ...}`, the job's API prompt exactly as it ran (template + substitutions) minus the USCG collector node; ids only, no paths (400 bad id/index, 404 unknown) |
|
||||
| POST | `/config_tester/sweep/start` | `__init__.py` | `{state or configs_json, node_id?, client_id?}` | Workflow Sweep: start a sweep `{state or configs_json, node_id?}` (400 invalid; 200 `attached:true` when the IDENTICAL sweep is already active; 409 `{error, sweep_id, session_name}` for a different one) |
|
||||
@@ -876,7 +877,6 @@ __init__.py
|
||||
29. **Image Metrics Run Context** - `image_metrics.begin_run(spec)` (wrapped around `run_generation_loop` in `sampler_node.py`) returns a token and `end_run(token)` closes only that context, so a second run or the dashboard upscale thread can never clobber it. Generation-side hooks read the active context (`decode_latent_with_vae(..., metrics_sink=)`, `flush_batch_with_vae`, orchestrator upscale inserts via `image_metrics.finalize`); the dashboard upscale thread (`upscale_runner.py`) must NOT read generation-run state, so it passes an explicit spec derived from the source item (`spec_from_item`). Every place that copies a source item into an upscaled entry excludes `"metrics"` (never inherit); the new entry is re-measured. `metrics` is session-level (`_session_settings.metrics`), not a per-config key.
|
||||
|
||||
30. **Workflow Sweep never blocks the executor.** `USCGSweepCollect` and the Generator in workflow mode only start/record work; the runner feeds sweep prompts through `PromptServer.instance.prompt_queue` from its own thread (an in-process mirror of `server.py post_prompt`, loop-bound calls via `ComfyBridge.run_on_loop`, never from the loop thread, no lock held across it). A node that waited on a prompt it queued would deadlock ComfyUI's single executor thread. The expansion/substitution core (`workflow_sweep.py`) is pure; only `ComfyBridge` touches ComfyUI, so tests use a fake bridge.
|
||||
|
||||
31. **pytest package-name trap**: pytest runs with `--import-mode=importlib` and a root `pytest.ini`. A parent folder named `integration`, `metrics` etc. (no hyphen) is imported as a package and the stub conftest breaks. Worktree folder names need a hyphen; never add `tests/__init__.py`.
|
||||
|
||||
32. **Manifest writers preserve disk state**: every writer (generation saves, the remote VAE worker, the `save_manifest` route) keeps the on-disk `comparisons` (`manifest_utils.preserve_disk_comparisons`) and the per-item `metrics` written by `metrics_runner` (an item's on-disk `metrics` survives when the in-memory copy has none, in `manifest_utils.merge_manifest_user_changes`). Only `update_manifest_comparisons` writes votes; only `metrics_runner` rewrites `metrics` in place.
|
||||
@@ -889,6 +889,8 @@ __init__.py
|
||||
|
||||
36. **`html_generator.js_files` order is load-bearing** (one shared script scope): `logic_ratings.js` (pure) before `logic_ui.js`, `logic_compare.js` after `logic_pipeline.js`, `logic_standalone.js` LAST (top-level-page overrides). CI assembles the page and syntax-checks it.
|
||||
|
||||
37. **Workflow Sweep video outputs.** `workflow_output` carries optional `media: "video"` and (VHS frames) `frames_from: <VHS node id>`; both are emitted only for video, and `fingerprint()` / `template_hash()` add `m`/`f` to the hashed payload only for video, so image fingerprints stay byte-identical (pinned by golden values in `tests/test_workflow_sweep_video.py`; changing the image payload would orphan every old session's resume). VHS frames are converted IN THE GRAPH by a spliced core `CreateVideo` (`build_job_prompt`), never in the collector, so the executor handles cache/hidden inputs. `USCGSweepCollectVideo` saves with `VideoInput.save_to` (SaveVideo itself needs executor-populated hidden inputs). `manifest_utils.append_items(remove_ids=...)` deletes a removed video's `<stem>.preview.png` along with the mp4. No new top-level Builder field, so the rule-5 checklist does not apply.
|
||||
|
||||
---
|
||||
|
||||
- **Item id ranges:** generated ids are `int(time.time()*100000)+rand` (below 2^52 until the year ~3400); scanned ids are `manifest_utils.stable_item_id("scan:<relpath>")` in `[2^52, 2^53)` (JS-safe integers). A re-scan of an existing session reuses old ids (legacy random ids included) via `merge_rescan`/`write_rescan_manifest` and carries favorited/rejected/note/notes/metrics/comparisons.
|
||||
|
||||
@@ -883,7 +883,8 @@ 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.
|
||||
* v1 limits: one image 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 are not collected yet** (planned: core `SaveVideo.video` / `CreateVideo` VIDEO sockets and the frames feeding `VHS_VideoCombine.images`, through a `USCGSweepCollectVideo` node).
|
||||
* * **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.
|
||||
|
||||
**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
-1
@@ -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, video outputs, mixed workflow + standard runs. (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, mixed workflow + standard runs (video outputs 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)
|
||||
|
||||
@@ -189,3 +189,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.
|
||||
|
||||
+3
-1
@@ -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"
|
||||
@@ -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 []),
|
||||
}
|
||||
|
||||
@@ -325,6 +325,11 @@ def append_items(manifest_path, items, remove_ids=(), session_name=None, default
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
if path and it.get("media_type") == "video": # a video's poster sits next to it (<stem>.preview.png)
|
||||
try:
|
||||
os.remove(os.path.splitext(path)[0] + ".preview.png")
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
kept.append(it)
|
||||
data["items"] = kept
|
||||
|
||||
@@ -1380,7 +1380,7 @@ function createCard(d) {
|
||||
: `<img ondblclick="toggleFavorite(this)" data-src="${d.file}" alt="Image ${d.id}" draggable="false">`;
|
||||
// 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 ? '' : `<button class="revise-btn" onclick="event.stopPropagation(); openM(${d.id})">${d.model_type === 'workflow' ? 'JOB' : 'REVISE'}</button>`;
|
||||
const reviseBtn = (isVideo && d.model_type !== 'workflow') ? '' : `<button class="revise-btn" onclick="event.stopPropagation(); openM(${d.id})">${d.model_type === 'workflow' ? 'JOB' : 'REVISE'}</button>`;
|
||||
const upscaleBtn = isVideo ? '' : `<button class="upscale-btn" onclick="openUpscaleModal(${d.id})" title="Upscale this image">\u2B06</button>`;
|
||||
const videoBadge = isVideo ? '<div class="video-badge">\u25B6 VIDEO</div>' : '';
|
||||
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');
|
||||
@@ -1611,7 +1612,7 @@ function openM(id) {
|
||||
if (x.rejected) return;
|
||||
if (x.seed === d.seed) {
|
||||
const i = document.createElement('img');
|
||||
i.src = x.file;
|
||||
i.src = (x.media_type === 'video' && x.poster) ? x.poster : x.file;
|
||||
i.onclick = () => openM(x.id);
|
||||
if (x.id === id) i.style.borderColor = "var(--accent)";
|
||||
r.appendChild(i);
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"1": {"class_type": "EmptyImage", "inputs": {"width": 64, "height": 64, "batch_size": 8, "color": 0}, "_meta": {"title": "EmptyImage"}},
|
||||
"2": {"class_type": "CreateVideo", "inputs": {"images": ["1", 0], "fps": 8.0}, "_meta": {"title": "Create Video"}},
|
||||
"3": {"class_type": "SaveVideo", "inputs": {"video": ["2", 0], "filename_prefix": "video/USCG-test", "format": "mp4", "format.codec": "auto"}, "_meta": {"title": "Save Video"}}
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"1": {"class_type": "EmptyImage", "inputs": {"width": 64, "height": 64, "batch_size": 8, "color": 0}, "_meta": {"title": "EmptyImage"}},
|
||||
"2": {"class_type": "VHS_VideoCombine", "inputs": {"images": ["1", 0], "frame_rate": 8, "loop_count": 0, "filename_prefix": "USCG-test", "format": "video/h264-mp4", "pingpong": false, "save_output": true}, "_meta": {"title": "Video Combine"}}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// outputKey / outputFromKey are pure helpers inside an ES module that imports ComfyUI's frontend, so this test
|
||||
// slices them out of the source (same trick as workflow-config-to-array.test.js).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, '../../web/conf_builder/conf-builder-workflow-sweep.js'), 'utf8');
|
||||
|
||||
function grab(name) {
|
||||
const re = new RegExp('(?:export )?function ' + name + '\\(');
|
||||
const m = re.exec(src);
|
||||
assert.ok(m, 'function ' + 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 { outputKey, outputFromKey } = new Function(['outputKey', 'outputFromKey'].map(grab).join('\n') +
|
||||
'\nreturn { outputKey, outputFromKey };')();
|
||||
|
||||
test('image output round-trips and keeps the stored shape (no media keys)', () => {
|
||||
const o = { node_id: '8', output_index: 0 };
|
||||
assert.strictEqual(outputKey(o), '8:0:image:');
|
||||
assert.deepStrictEqual(outputFromKey(outputKey(o)), { node_id: '8', output_index: 0 });
|
||||
// a picked_from helper key never leaks into the stored output
|
||||
assert.deepStrictEqual(outputFromKey(outputKey({ node_id: '8', output_index: 1, picked_from: '9' })),
|
||||
{ node_id: '8', output_index: 1 });
|
||||
});
|
||||
|
||||
test('video (VIDEO socket) output round-trips', () => {
|
||||
const o = { node_id: '2', output_index: 0, media: 'video' };
|
||||
assert.strictEqual(outputKey(o), '2:0:video:');
|
||||
assert.deepStrictEqual(outputFromKey(outputKey(o)), o);
|
||||
});
|
||||
|
||||
test('video frames (VHS) output round-trips with frames_from', () => {
|
||||
const o = { node_id: '1', output_index: 0, media: 'video', frames_from: '2' };
|
||||
assert.strictEqual(outputKey(o), '1:0:video:2');
|
||||
assert.deepStrictEqual(outputFromKey(outputKey(o)), o);
|
||||
});
|
||||
|
||||
test('image and video keys of the same socket differ, and bad keys give null', () => {
|
||||
assert.notStrictEqual(outputKey({ node_id: '2', output_index: 0 }), outputKey({ node_id: '2', output_index: 0, media: 'video' }));
|
||||
assert.strictEqual(outputFromKey(''), null);
|
||||
assert.strictEqual(outputFromKey(':0:image:'), null);
|
||||
});
|
||||
@@ -189,3 +189,121 @@ def test_package_registers_collector_node_and_sweep_routes():
|
||||
assert pkg.NODE_DISPLAY_NAME_MAPPINGS["USCGSweepCollect"] == "USCG Sweep Collect"
|
||||
for fn in ("sweep_parse", "sweep_dry_run", "sweep_start", "sweep_status", "sweep_pause", "sweep_resume", "sweep_cancel"):
|
||||
assert callable(getattr(pkg, fn)), fn
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- USCGSweepCollectVideo
|
||||
|
||||
class FakeVideo:
|
||||
"""Quacks like comfy_api VideoInput: get_components / get_dimensions / get_frame_count / save_to."""
|
||||
|
||||
def __init__(self, frames=8, h=16, w=24, fps=8, kwargs_ok=True):
|
||||
from fractions import Fraction
|
||||
self.frames, self.h, self.w, self.fps = frames, h, w, Fraction(fps)
|
||||
self.kwargs_ok = kwargs_ok
|
||||
self.saved = []
|
||||
|
||||
def get_components(self):
|
||||
class C:
|
||||
pass
|
||||
c = C()
|
||||
c.images = images(self.frames, self.h, self.w)
|
||||
c.frame_rate = self.fps
|
||||
return c
|
||||
|
||||
def get_dimensions(self):
|
||||
return self.w, self.h
|
||||
|
||||
def get_frame_count(self):
|
||||
return self.frames
|
||||
|
||||
def get_frame_rate(self):
|
||||
return self.fps
|
||||
|
||||
def save_to(self, path, **kwargs):
|
||||
if kwargs and not self.kwargs_ok:
|
||||
raise TypeError("old comfy: no kwargs")
|
||||
self.saved.append((path, kwargs))
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"\x00\x00\x00\x18ftypmp42fakevideo")
|
||||
|
||||
|
||||
def test_video_node_definition():
|
||||
cls = collect_mod.USCGSweepCollectVideo
|
||||
spec = cls.INPUT_TYPES()
|
||||
assert spec["required"]["video"] == ("VIDEO",) and "session_name" in spec["required"]
|
||||
assert spec["hidden"] == {"prompt": "PROMPT", "unique_id": "UNIQUE_ID"}
|
||||
assert cls.RETURN_TYPES == () and cls.OUTPUT_NODE is True and cls.FUNCTION == "collect"
|
||||
assert cls.IS_CHANGED() != cls.IS_CHANGED()
|
||||
assert collect_mod.NODE_CLASS_MAPPINGS["USCGSweepCollectVideo"] is cls
|
||||
assert collect_mod.NODE_DISPLAY_NAME_MAPPINGS["USCGSweepCollectVideo"] == "USCG Sweep Collect Video"
|
||||
|
||||
|
||||
def test_video_collect_saves_mp4_poster_and_ltx_shaped_item(env):
|
||||
video = FakeVideo(frames=8, fps=8)
|
||||
out = collect_mod.USCGSweepCollectVideo().collect(video, "vsession", job_json(), prompt={"1": {"class_type": "X"}})
|
||||
base = env / "benchmarks" / "vsession"
|
||||
manifest = json.loads((base / "manifest.json").read_text())
|
||||
assert len(manifest["items"]) == 1
|
||||
item = manifest["items"][0]
|
||||
assert item["media_type"] == "video" and item["model_type"] == "workflow"
|
||||
assert item["file"].startswith("/view?filename=vid_") and item["file"].endswith(
|
||||
".mp4&type=output&subfolder=benchmarks/vsession/images")
|
||||
mp4 = base / "images" / f"vid_{item['id']}.mp4"
|
||||
assert mp4.read_bytes().startswith(b"\x00\x00\x00\x18ftyp")
|
||||
assert item["image_path"] == item["video_path"] == str(mp4)
|
||||
preview = base / "images" / f"vid_{item['id']}.preview.png"
|
||||
assert item["preview_path"] == str(preview) and Image.open(preview).size == (24, 16)
|
||||
assert item["poster"] == f"/view?filename=vid_{item['id']}.preview.png&type=output&subfolder=benchmarks/vsession/images"
|
||||
assert item["frames"] == 8 and item["frame_rate"] == 8 and item["duration_seconds"] == 1.0
|
||||
assert item["width"] == 24 and item["height"] == 16
|
||||
assert item["wf_sweep_id"] == "sw1" and item["wf_job_index"] == 0 and item["gen_index"] == 7
|
||||
assert item["seed"] == 3 and item["favorited"] is False and item["rejected"] is False
|
||||
# core save_to is called with mp4 + the workflow as metadata
|
||||
(path, kwargs), = video.saved
|
||||
assert path == str(mp4) and str(kwargs["format"]).lower().endswith("mp4") and kwargs["codec"] == "auto"
|
||||
assert kwargs["metadata"] == {"prompt": {"1": {"class_type": "X"}}}
|
||||
assert out["ui"]["images"] == [{"filename": mp4.name, "subfolder": "benchmarks/vsession/images", "type": "output"}]
|
||||
assert out["ui"]["animated"] == (True,)
|
||||
|
||||
|
||||
def test_video_collect_fractional_fps_and_old_comfy_save_fallback(env):
|
||||
video = FakeVideo(frames=12, fps=12, kwargs_ok=False)
|
||||
collect_mod.USCGSweepCollectVideo().collect(video, "vold", job_json(), prompt=None)
|
||||
item = json.loads((env / "benchmarks" / "vold" / "manifest.json").read_text())["items"][0]
|
||||
assert item["frame_rate"] == 12 and item["duration_seconds"] == 1.0
|
||||
assert video.saved[0][1] == {} # fell back to save_to(path)
|
||||
video = FakeVideo(frames=10, fps=0)
|
||||
video.get_frame_rate = lambda: 7.5
|
||||
collect_mod.USCGSweepCollectVideo().collect(video, "vold", job_json(job_index=1))
|
||||
item = json.loads((env / "benchmarks" / "vold" / "manifest.json").read_text())["items"][0]
|
||||
assert item["frame_rate"] == 7.5 and item["duration_seconds"] == 1.33
|
||||
|
||||
|
||||
def test_video_collect_sanitises_session_and_stays_in_benchmarks(env):
|
||||
collect_mod.USCGSweepCollectVideo().collect(FakeVideo(), "../../escape/..\\x", job_json())
|
||||
assert (env / "benchmarks" / "escapex" / "manifest.json").exists()
|
||||
written = [p for p in env.rglob("*") if p.is_file()]
|
||||
assert written and all(str(p).startswith(str(env / "benchmarks")) for p in written)
|
||||
|
||||
|
||||
def test_video_collect_notifies_runner_and_replaces_old_item(env, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(runner, "on_collected", lambda sid, idx, items: calls.append((sid, idx, items)))
|
||||
collect_mod.USCGSweepCollectVideo().collect(FakeVideo(), "vrep", job_json())
|
||||
manifest = json.loads((env / "benchmarks" / "vrep" / "manifest.json").read_text())
|
||||
first = manifest["items"][0]
|
||||
assert calls and calls[0][0] == "sw1" and calls[0][2][0]["id"] == first["id"]
|
||||
old_mp4 = env / "benchmarks" / "vrep" / "images" / f"vid_{first['id']}.mp4"
|
||||
assert old_mp4.exists()
|
||||
assert old_mp4.with_suffix(".preview.png").exists()
|
||||
collect_mod.USCGSweepCollectVideo().collect(FakeVideo(), "vrep", job_json(replace_item_ids=[first["id"]]))
|
||||
items = json.loads((env / "benchmarks" / "vrep" / "manifest.json").read_text())["items"]
|
||||
assert len(items) == 1 and items[0]["id"] != first["id"]
|
||||
assert not old_mp4.exists() # the previous mp4 is deleted with its item
|
||||
assert not old_mp4.with_suffix(".preview.png").exists() # ...and so is its poster
|
||||
|
||||
|
||||
def test_video_collect_without_a_sweep_and_with_garbage_job_json(env):
|
||||
collect_mod.USCGSweepCollectVideo().collect(FakeVideo(), "vman", "not json")
|
||||
item = json.loads((env / "benchmarks" / "vman" / "manifest.json").read_text())["items"][0]
|
||||
assert item["media_type"] == "video" and "wf_sweep_id" not in item
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Workflow Sweep video outputs: parse (sources, default), plan (media, fingerprints), job prompt (collector, CreateVideo splice)."""
|
||||
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
|
||||
import test_workflow_sweep_runner as rt # noqa: E402 (FakeBridge)
|
||||
from config_builder_node import UltimateConfigBuilder # noqa: E402
|
||||
|
||||
|
||||
def load(name):
|
||||
with open(os.path.join(ROOT, "tests", "fixtures", name), "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def vhs_entry(bindings=(), prompt=None, **out):
|
||||
output = {"node_id": "1", "output_index": 0, "media": "video", "frames_from": "2"}
|
||||
output.update(out)
|
||||
return {"model_type": "workflow", "name": "v", "workflow": prompt or load("video_vhs_api.json"),
|
||||
"workflow_output": output, "workflow_bindings": list(bindings)}
|
||||
|
||||
|
||||
def core_entry(bindings=(), **out):
|
||||
output = {"node_id": "2", "output_index": 0, "media": "video"}
|
||||
output.update(out)
|
||||
return {"model_type": "workflow", "name": "v", "workflow": load("video_core_api.json"),
|
||||
"workflow_output": output, "workflow_bindings": list(bindings)}
|
||||
|
||||
|
||||
def lit(bid, nid, inp, values):
|
||||
return {"id": bid, "kind": "literal", "targets": [{"node_id": nid, "input": inp}], "values": list(values)}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- parse
|
||||
|
||||
def test_core_fixture_video_sources_and_default():
|
||||
r = ws.parse_for_ui(load("video_core_api.json"), class_info=None)
|
||||
v = r["video_sources"]
|
||||
assert v[0] == {"node_id": "2", "output_index": 0, "type": "VIDEO", "media": "video", "kind": "video", "picked_from": "3"}
|
||||
assert r["image_sources"] == []
|
||||
assert r["default_output"] == {"node_id": "2", "output_index": 0, "media": "video"}
|
||||
assert not any("No image" in w for w in r["warnings"])
|
||||
|
||||
|
||||
def test_vhs_fixture_is_a_frames_source():
|
||||
r = ws.parse_for_ui(load("video_vhs_api.json"), 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"}
|
||||
assert isinstance(r["suggestions"], list)
|
||||
|
||||
|
||||
def test_savevideo_outranks_vhs_and_other_video_returners():
|
||||
p = load("video_core_api.json")
|
||||
p["4"] = {"class_type": "VHS_VideoCombine", "inputs": {"images": ["1", 0], "frame_rate": 8}}
|
||||
order = [(s["kind"], s["node_id"]) for s in ws.find_video_sources(p)]
|
||||
assert order == [("video", "2"), ("frames", "1")]
|
||||
|
||||
|
||||
def test_image_workflows_keep_image_default():
|
||||
for name in ("sdxl_default_api.json", "image_moss_image_t2i.json"):
|
||||
r = ws.parse_for_ui(load(name), class_info=None)
|
||||
assert r["video_sources"] == []
|
||||
assert r["default_output"] == r["image_sources"][0]
|
||||
assert "media" not in r["default_output"]
|
||||
# a SaveImage feeder wins even when the graph also has a video output
|
||||
p = load("sdxl_default_api.json")
|
||||
p["20"] = {"class_type": "CreateVideo", "inputs": {"images": ["8", 0], "fps": 8}}
|
||||
p["21"] = {"class_type": "SaveVideo", "inputs": {"video": ["20", 0]}}
|
||||
r = ws.parse_for_ui(p, class_info=None)
|
||||
assert r["default_output"]["node_id"] == "8" and "media" not in r["default_output"]
|
||||
assert r["video_sources"][0]["node_id"] == "20"
|
||||
|
||||
|
||||
def test_vhs_latent_and_meta_batch_are_refused_with_a_warning():
|
||||
for key, link in (("vae", ["4", 0]), ("meta_batch", ["5", 0])):
|
||||
p = load("video_vhs_api.json")
|
||||
p["2"]["inputs"][key] = link
|
||||
r = ws.parse_for_ui(p, class_info=None)
|
||||
assert r["video_sources"] == []
|
||||
assert any("skipped as a video source" in w and key in w for w in r["warnings"]), r["warnings"]
|
||||
with pytest.raises(ValueError, match="cannot be used as a video source"):
|
||||
ws.plan_workflow_entry(vhs_entry(prompt=p))
|
||||
|
||||
|
||||
def test_registry_video_return_types_are_listed():
|
||||
p = {"1": {"class_type": "LoadVideo", "inputs": {"file": "x.mp4"}}}
|
||||
|
||||
def info(ct):
|
||||
return {"missing": False, "return_types": ["VIDEO", "AUDIO"], "output_node": False, "inputs": {}}
|
||||
|
||||
r = ws.parse_for_ui(p, class_info=info)
|
||||
assert r["video_sources"] == [{"node_id": "1", "output_index": 0, "type": "VIDEO", "media": "video", "kind": "video"}]
|
||||
assert r["default_output"]["media"] == "video"
|
||||
|
||||
|
||||
def test_no_output_at_all_warns():
|
||||
r = ws.parse_for_ui({"1": {"class_type": "Foo", "inputs": {}}}, class_info=None)
|
||||
assert r["default_output"] is None and any("No image or video output" in w for w in r["warnings"])
|
||||
|
||||
|
||||
def test_normalize_output_default_and_validation():
|
||||
p = ws.parse_api_workflow(load("video_core_api.json"))
|
||||
assert ws.normalize_output({}, p) == ("2", 0, "video", None)
|
||||
assert ws.normalize_output({"workflow_output": {"node_id": "2", "output_index": 0}}, p) == ("2", 0, "image", None)
|
||||
with pytest.raises(ValueError, match="Unknown output media"):
|
||||
ws.normalize_output({"workflow_output": {"node_id": "2", "media": "gif"}}, p)
|
||||
with pytest.raises(ValueError, match="frames node 9"):
|
||||
ws.normalize_output({"workflow_output": {"node_id": "1", "media": "video", "frames_from": "9"}}, p)
|
||||
# frames_from is meaningless for images and is dropped
|
||||
assert ws.normalize_output({"workflow_output": {"node_id": "1", "frames_from": "2"}}, p) == ("1", 0, "image", None)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- plan / fingerprints
|
||||
|
||||
def test_plan_marks_jobs_as_video():
|
||||
plan = ws.plan_workflow_entry(core_entry([lit("b", "2", "fps", [8, 12])]))
|
||||
assert len(plan["jobs"]) == 2
|
||||
for j in plan["jobs"]:
|
||||
assert j["media_type"] == "video" and j["wf_output_media"] == "video" and "wf_frames_from" not in j
|
||||
assert plan["template"]["media"] == "video" and plan["template"]["frames_from"] is None
|
||||
plan = ws.plan_workflow_entry(vhs_entry())
|
||||
j = plan["jobs"][0]
|
||||
assert j["media_type"] == "video" and j["wf_frames_from"] == "2" and j["wf_output"] == "1:0"
|
||||
assert plan["template"]["frames_from"] == "2"
|
||||
|
||||
|
||||
def test_image_plan_has_no_video_keys():
|
||||
e = {"model_type": "workflow", "name": "c", "workflow": load("sdxl_default_api.json"), "workflow_bindings": []}
|
||||
j = ws.plan_workflow_entry(e)["jobs"][0]
|
||||
assert j["media_type"] == "image" and "wf_output_media" not in j and "wf_frames_from" not in j
|
||||
|
||||
|
||||
def test_golden_image_hashes_unchanged():
|
||||
"""Pinned on main 6b60163: image templates/fingerprints must stay byte-identical (resume of old sessions)."""
|
||||
wf = load("sdxl_default_api.json")
|
||||
plan = ws.plan_workflow_entry({"model_type": "workflow", "name": "c", "workflow": wf, "workflow_bindings": []})
|
||||
assert plan["template"]["output"] == ["8", 0]
|
||||
assert plan["template"]["hash"] == "7a10272fe5cd263c"
|
||||
assert plan["jobs"][0]["wf_fingerprint"] == "e829b0b70d514835454e"
|
||||
plan = ws.plan_workflow_entry({"model_type": "workflow", "name": "c", "workflow": wf,
|
||||
"workflow_bindings": [lit("b1", "3", "cfg", [2, 3])]})
|
||||
assert [j["wf_fingerprint"] for j in plan["jobs"]] == ["121321289f16ba908470", "ac9174b0bdd5d8ac1900"]
|
||||
|
||||
|
||||
def test_video_and_image_fingerprints_differ_for_the_same_socket():
|
||||
p = load("video_core_api.json")
|
||||
img = ws.fingerprint(p, ("2", 0))
|
||||
vid = ws.fingerprint(p, ("2", 0), "video")
|
||||
assert img != vid and len(vid) == 20
|
||||
assert ws.fingerprint(p, ("2", 0), "video", "2") != vid # frames_from is part of it
|
||||
assert ws.template_hash(p, ("2", 0), False, "video") != ws.template_hash(p, ("2", 0), False)
|
||||
assert ws.fingerprint(p, ("2", 0), "image", "2") == img # ignored for images
|
||||
|
||||
|
||||
def test_fps_axis_changes_the_fingerprint_and_vhs_frame_rate_is_upstream():
|
||||
plan = ws.plan_workflow_entry(core_entry([lit("b", "2", "fps", [8, 12])]))
|
||||
assert len({j["wf_fingerprint"] for j in plan["jobs"]}) == 2 and not plan["warnings"]
|
||||
plan = ws.plan_workflow_entry(vhs_entry([lit("b", "2", "frame_rate", [8, 16])]))
|
||||
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"]
|
||||
plan = ws.plan_workflow_entry(vhs_entry([lit("b", "1", "color", [0, 255])]))
|
||||
assert not any("not upstream" in w for w in plan["warnings"])
|
||||
|
||||
|
||||
def test_image_and_video_entries_plan_together_but_not_with_standard_configs():
|
||||
img = {"model_type": "workflow", "name": "i", "workflow": load("sdxl_default_api.json"), "workflow_bindings": []}
|
||||
plan = ws.plan_workflow_configs([img, core_entry()])
|
||||
assert {t["media"] for t in plan["templates"].values()} == {"image", "video"}
|
||||
with pytest.raises(ValueError, match="cannot be mixed"):
|
||||
ws.plan_workflow_configs([core_entry(), {"model_type": "sdxl"}])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- build_job_prompt
|
||||
|
||||
def test_core_video_collector_is_fed_directly():
|
||||
t = ws.plan_workflow_entry(core_entry())["template"]
|
||||
p, cid = ws.build_job_prompt(t["prompt"], [], t["output"], {"session_name": "s"}, t["media"], t["frames_from"])
|
||||
assert p[cid]["class_type"] == "USCGSweepCollectVideo" and p[cid]["inputs"]["video"] == ["2", 0]
|
||||
assert p[cid]["inputs"]["session_name"] == "s" and "images" not in p[cid]["inputs"]
|
||||
assert set(p) - set(t["prompt"]) == {cid}
|
||||
|
||||
|
||||
def test_vhs_frames_get_a_createvideo_splice():
|
||||
plan = ws.plan_workflow_entry(vhs_entry([lit("b", "2", "frame_rate", [8, 16])]))
|
||||
t = plan["template"]
|
||||
seen = set()
|
||||
for job in plan["jobs"]:
|
||||
p, cid = ws.build_job_prompt(t["prompt"], job["wf_subs"], t["output"], {}, t["media"], t["frames_from"])
|
||||
added = set(p) - set(t["prompt"])
|
||||
assert len(added) == 2 and cid in added
|
||||
cv = (added - {cid}).pop()
|
||||
assert p[cv]["class_type"] == "CreateVideo" and int(cv) < int(cid)
|
||||
assert p[cv]["inputs"]["images"] == ["1", 0] and "audio" not in p[cv]["inputs"]
|
||||
assert p[cid]["class_type"] == "USCGSweepCollectVideo" and p[cid]["inputs"]["video"] == [cv, 0]
|
||||
assert isinstance(p[cv]["inputs"]["fps"], float)
|
||||
seen.add(p[cv]["inputs"]["fps"])
|
||||
# the VHS node itself is untouched
|
||||
assert p["2"]["inputs"]["frame_rate"] == float(p[cv]["inputs"]["fps"])
|
||||
assert seen == {8.0, 16.0}
|
||||
|
||||
|
||||
def test_vhs_splice_forwards_link_fps_and_audio():
|
||||
p = load("video_vhs_api.json")
|
||||
p["7"] = {"class_type": "PrimitiveInt", "inputs": {"value": 12}}
|
||||
p["8"] = {"class_type": "LoadAudio", "inputs": {"audio": "a.wav"}}
|
||||
p["2"]["inputs"]["frame_rate"] = ["7", 0]
|
||||
p["2"]["inputs"]["audio"] = ["8", 0]
|
||||
t = ws.plan_workflow_entry(vhs_entry(prompt=p))["template"]
|
||||
out, cid = ws.build_job_prompt(t["prompt"], [], t["output"], {}, "video", "2")
|
||||
cv = out[out[cid]["inputs"]["video"][0]]
|
||||
assert cv["inputs"]["fps"] == ["7", 0] and cv["inputs"]["audio"] == ["8", 0]
|
||||
|
||||
|
||||
def test_collector_ids_are_unique_and_numeric():
|
||||
t = ws.plan_workflow_entry(vhs_entry())["template"]
|
||||
p, cid = ws.build_job_prompt(t["prompt"], [], t["output"], {}, "video", "2")
|
||||
assert cid.isdigit() and len(p) == 4
|
||||
|
||||
|
||||
def test_image_path_of_build_job_prompt_is_unchanged():
|
||||
t = load("sdxl_default_api.json")
|
||||
p, cid = ws.build_job_prompt(t, [], ("8", 0), {})
|
||||
assert p[cid]["class_type"] == "USCGSweepCollect" and p[cid]["inputs"] == {"images": ["8", 0]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- runner integration
|
||||
|
||||
class VideoBridge(rt.FakeBridge):
|
||||
async def enqueue(self, prompt, partial, client_id):
|
||||
ok, error, node_errors = await self.validate(prompt, partial)
|
||||
if not ok:
|
||||
return None, error, node_errors
|
||||
self.n += 1
|
||||
pid = f"pid-{self.n}"
|
||||
collector = [n for n in prompt.values() if n["class_type"].startswith("USCGSweepCollect")][0]
|
||||
self.meta[pid] = json.loads(collector["inputs"]["job_json"])
|
||||
self.queue.append(pid)
|
||||
self.enqueued.append((pid, prompt, partial, client_id))
|
||||
return pid, None, {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vbridge(tmp_path, monkeypatch):
|
||||
import folder_paths
|
||||
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()
|
||||
|
||||
|
||||
def test_runner_queues_video_collector_and_job_prompt_route_excludes_it(vbridge):
|
||||
sw = runner.prepare_sweep([vhs_entry([lit("b", "2", "frame_rate", [8, 16])])], {"session_name": "wf_vid"},
|
||||
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
|
||||
cid = [k for k, n in prompt.items() if n["class_type"] == "USCGSweepCollectVideo"][0]
|
||||
assert partial == [cid]
|
||||
status, body = runner.job_prompt("wf_vid", sw.id, 0)
|
||||
assert status == 200
|
||||
assert "USCGSweepCollect" not in json.dumps(body["prompt"]) and "CreateVideo" not in json.dumps(body["prompt"])
|
||||
tpl = sw.templates[sw.jobs[0]["conf"]["wf_template_hash"]]
|
||||
assert body["prompt"] == ws.apply_subs(tpl["prompt"], sw.jobs[0]["conf"]["wf_subs"])
|
||||
with open(os.path.join(ws.session_paths("wf_vid")["sweeps"], sw.id + ".json")) as f:
|
||||
spec = json.load(f)
|
||||
assert {t["media"] for t in spec["templates"].values()} == {"video"}
|
||||
assert {t["frames_from"] for t in spec["templates"].values()} == {"2"}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- Builder emission
|
||||
|
||||
def _array(output):
|
||||
return {"name": "WF", "kind": "workflow",
|
||||
"workflow": {"source_name": "t.json", "api_prompt": {"3": {"class_type": "KSampler", "inputs": {}}},
|
||||
"output": output, "run_other_outputs": False, "bindings": []},
|
||||
"positive_prompt_groups": [], "negative_prompt": "", "use_custom_prompts": False}
|
||||
|
||||
|
||||
def _emit(output):
|
||||
state = {"session_name": "s", "include_none": False, "global_positive_groups": [], "global_negative": "",
|
||||
"config_arrays": [_array(output)]}
|
||||
return json.loads(UltimateConfigBuilder.state_to_configs_json(state))["configs"][0]
|
||||
|
||||
|
||||
def test_builder_keeps_media_keys_only_for_video():
|
||||
assert _emit({"node_id": "8", "output_index": 0})["workflow_output"] == {"node_id": "8", "output_index": 0}
|
||||
assert _emit({"node_id": "8", "output_index": 0, "picked_from": "9"})["workflow_output"] == {"node_id": "8", "output_index": 0}
|
||||
assert _emit({"node_id": "8", "output_index": 0, "media": "image", "frames_from": "2"})["workflow_output"] == {"node_id": "8", "output_index": 0}
|
||||
assert _emit({"node_id": "1", "output_index": 0, "media": "video", "frames_from": 2})["workflow_output"] == \
|
||||
{"node_id": "1", "output_index": 0, "media": "video", "frames_from": "2"}
|
||||
assert _emit({"node_id": "2", "media": "video"})["workflow_output"] == {"node_id": "2", "output_index": 0, "media": "video"}
|
||||
assert _emit({})["workflow_output"] == {}
|
||||
@@ -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 <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 || ""}`;
|
||||
}
|
||||
|
||||
/** Inverse of outputKey -> the stored `wf.output` shape. Image outputs carry only node_id/output_index. */
|
||||
export function outputFromKey(key) {
|
||||
const [n, i, media, frames] = String(key).split(":");
|
||||
if (n === undefined || n === "") return null;
|
||||
const out = { node_id: n, output_index: parseInt(i, 10) || 0 };
|
||||
if (media === "video") {
|
||||
out.media = "video";
|
||||
if (frames) out.frames_from = frames;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Node ids upstream of (and including) the output socket's node. */
|
||||
function ancestorsOf(prompt, outId) {
|
||||
const seen = new Set();
|
||||
@@ -317,8 +334,14 @@ export function renderWorkflowSweepSection(node, arrayElement, configArray, arra
|
||||
Object.assign(configArray.workflow, { api_prompt: obj, source_name: name || "" });
|
||||
parseCache.set(configArray.workflow.api_prompt, Promise.resolve(parsed));
|
||||
info = parsed;
|
||||
const best = (parsed.image_sources || [])[0];
|
||||
if (best) configArray.workflow.output = { node_id: best.node_id, output_index: best.output_index, picked_from: best.picked_from };
|
||||
const best = parsed.default_output || (parsed.image_sources || [])[0];
|
||||
if (best) {
|
||||
configArray.workflow.output = { node_id: best.node_id, output_index: best.output_index, picked_from: best.picked_from };
|
||||
if (best.media === "video") {
|
||||
configArray.workflow.output.media = "video";
|
||||
if (best.frames_from) configArray.workflow.output.frames_from = best.frames_from;
|
||||
}
|
||||
}
|
||||
save();
|
||||
} catch (e) {
|
||||
parseError = String(e.message || e);
|
||||
@@ -330,26 +353,37 @@ export function renderWorkflowSweepSection(node, arrayElement, configArray, arra
|
||||
// ---- output picker ------------------------------------------------
|
||||
function renderOutput(container) {
|
||||
const row = el("div", {}, "display: flex; gap: 8px; align-items: center; flex-wrap: wrap;");
|
||||
row.appendChild(el("label", { className: "cb-label", textContent: "Image to collect" }, "min-width: 110px;"));
|
||||
row.appendChild(el("label", { className: "cb-label", textContent: "Output to collect" }, "min-width: 110px;"));
|
||||
const sel = el("select", { className: "cb-select" }, "flex: 1; min-width: 220px;");
|
||||
const sources = (info && info.image_sources) || [];
|
||||
const cur = wf.output && wf.output.node_id !== undefined ? `${wf.output.node_id}:${wf.output.output_index || 0}` : "";
|
||||
if (!sources.length) sel.appendChild(el("option", { value: "", textContent: "(no IMAGE output found)" }));
|
||||
sources.forEach(s => {
|
||||
const label = `${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})` : "");
|
||||
sel.appendChild(el("option", { value: `${s.node_id}:${s.output_index}`, textContent: label, selected: `${s.node_id}:${s.output_index}` === cur }));
|
||||
const videos = (info && info.video_sources) || [];
|
||||
const cur = wf.output && wf.output.node_id !== undefined ? outputKey(wf.output) : "";
|
||||
if (!sources.length && !videos.length) sel.appendChild(el("option", { value: "", textContent: "(no image or video output found)" }));
|
||||
const addGroup = (title, list, labelOf) => {
|
||||
if (!list.length) return;
|
||||
const group = el("optgroup", { label: title });
|
||||
list.forEach(s => group.appendChild(el("option", { value: outputKey(s), textContent: labelOf(s), selected: outputKey(s) === cur })));
|
||||
sel.appendChild(group);
|
||||
};
|
||||
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;
|
||||
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})` : "");
|
||||
});
|
||||
sel.onchange = () => {
|
||||
const [n, i] = sel.value.split(":");
|
||||
if (n === undefined || n === "") return;
|
||||
wf.output = { node_id: n, output_index: parseInt(i, 10) || 0 };
|
||||
const out = outputFromKey(sel.value);
|
||||
if (!out) return;
|
||||
wf.output = out;
|
||||
save(); rerender();
|
||||
};
|
||||
row.appendChild(sel);
|
||||
const cb = el("input", { type: "checkbox", checked: !!wf.run_other_outputs });
|
||||
cb.onchange = () => { wf.run_other_outputs = cb.checked; save(); };
|
||||
const lab = el("label", {}, "font-size: 11px; color: #aaa; display: flex; gap: 4px; align-items: center;");
|
||||
lab.append(cb, document.createTextNode("Also run the workflow's own outputs (SaveImage etc.)"));
|
||||
lab.append(cb, document.createTextNode("Also run the workflow's own outputs (SaveImage / SaveVideo etc.)"));
|
||||
row.appendChild(lab);
|
||||
container.appendChild(row);
|
||||
}
|
||||
@@ -403,7 +437,7 @@ export function renderWorkflowSweepSection(node, arrayElement, configArray, arra
|
||||
if (!info || !wf.api_prompt) return;
|
||||
const used = boundTargets(wf);
|
||||
const prompt = wf.api_prompt;
|
||||
const upstream = wf.output && wf.output.node_id !== undefined ? ancestorsOf(prompt, wf.output.node_id) : null;
|
||||
const upstream = wf.output && wf.output.node_id !== undefined ? ancestorsOf(prompt, wf.output.frames_from || wf.output.node_id) : null;
|
||||
|
||||
const head = el("div", {}, "display: flex; gap: 8px; align-items: center; flex-wrap: wrap;");
|
||||
head.appendChild(el("strong", { textContent: "Workflow nodes" }, "font-size: 12px;"));
|
||||
|
||||
+171
-34
@@ -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": []}
|
||||
|
||||
+143
-2
@@ -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"}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user