diff --git a/AI-CONTEXT.md b/AI-CONTEXT.md index 48f147c..9631a25 100644 --- a/AI-CONTEXT.md +++ b/AI-CONTEXT.md @@ -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 | diff --git a/ProjectStructure.md b/ProjectStructure.md index b81f7a6..8f84df2 100644 --- a/ProjectStructure.md +++ b/ProjectStructure.md @@ -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: `; 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 `.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:")` 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. diff --git a/README.md b/README.md index 6822742..fc6bf07 100644 --- a/README.md +++ b/README.md @@ -883,7 +883,8 @@ How it behaves: * Each sweep writes `benchmarks//workflow_sweeps/.json` (templates, bindings, per-job status) so any executed prompt can be reconstructed. * **Re-running the same sweep attaches to it.** Queueing the Generator again (or pressing Start) while the identical sweep is active does not fail and does not queue anything twice: it attaches to the running sweep (console: "Same workflow sweep already running ... attaching") and a paused sweep stays paused (Resume is the Builder's button). "Identical" means the same session name, the same ordered job fingerprints, the same *Start at job* and the same *Overwrite existing*. Anything else gets a clear refusal ("A workflow sweep is already active (session ..., sweep ..., n/m done, state). This run was NOT started. Pause or cancel it ..."; ComfyUI shows it in its error dialog, the route answers 409 with `session_name`). *Add random seeds* are deterministic (derived from the base seed, 0 like the Generator), so the same sweep always produces the same jobs: re-queueing attaches, and a resume after a finished or interrupted run skips the jobs already in the session. * **Loading a sweep session in the Builder** (Load Session) restores the Workflow Sweep arrays (workflow, output, bindings, custom prompts) from the `configs_json` that sweep sessions now store in their manifest meta. Sessions made before this change have no saved configs: the Builder shows a notice ("Load the workflow JSON again into a Workflow Sweep array; finished jobs are skipped by fingerprint") and leaves the arrays alone. -* 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_.preview.png` into `benchmarks//images`, with the same manifest fields as LTX videos (`media_type: "video"`, `video_path`, `preview_path`, `poster`, `frames`, `frame_rate`, `duration_seconds`). Cards play the video; **JOB** stays available and the Compare view plays both videos. Resume and *Overwrite existing* work as for images (the old mp4 and its poster are deleted); a video and an image collection of the same socket never skip each other. Limits: VHS `format`, `loop_count` and `pingpong` are ignored (always mp4/h264); a VHS node fed with latents (`vae` input) or a `meta_batch` is not offered as a source; one video per job; the workflow's own SaveVideo/VHS node only runs with *Also run the workflow's own outputs*; collecting a file-backed VIDEO (e.g. `LoadVideo`) decodes all its frames into RAM once to make the poster. +* v1 limits: one image or video output per job, one sweep at a time, a workflow sweep cannot share a Generator run with standard configs (use two Builders), no upscale/distribution/trigger-word features, and Revise does not apply. **Dashboard for sweeps.** Items carry `wf_params` (the bound values, numbers or strings). The **Quick Filter** gets one extra type per *varied* key (`WF cfg`, `WF sampler_name`, ...; picking a value filters and the facet panel lists them), the **Logic Filter** gets a *Workflow params* group of the numeric keys (`wf:KSampler.cfg >= 5`), and the **Labels** panel has a *Workflow params* checkbox that tags each card with its varied values (`cfg:4`). Standard sessions show none of this. The **JOB** button opens a read-only *Workflow job* panel (source, output, sweep id, job number, parameter table) with **Download job prompt (API JSON)** and **Copy job prompt**: `GET /config_tester/sweep/job_prompt?session_name=&sweep_id=&job_index=` rebuilds the prompt exactly as it ran (template + substitutions) minus the USCG collector node, so queueing it in ComfyUI reproduces the image. If the browser blocks the download inside the dashboard iframe, use Copy. diff --git a/Roadmap.md b/Roadmap.md index fa87e3c..303359c 100644 --- a/Roadmap.md +++ b/Roadmap.md @@ -129,7 +129,7 @@ A button in the builder UI to pack short sha256 into config with an explanation * **Problem:** Can only load full sessions, not merge snippets. -#### **23. Pseudo-JSON Nodes (Recursion)** (low priority) - STARTED: v1 is the **Workflow Sweep** (Builder: Add Workflow Sweep): any API-format workflow runs as-is with chosen inputs swept (see README, 'Workflow Sweep'). Still open: inserting nodes into the USCG pipeline itself, 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. 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/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/manifest_utils.py b/manifest_utils.py index 422f477..3d344e0 100644 --- a/manifest_utils.py +++ b/manifest_utils.py @@ -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 (.preview.png) + try: + os.remove(os.path.splitext(path)[0] + ".preview.png") + except OSError: + pass else: kept.append(it) data["items"] = kept diff --git a/resources/logic_ui.js b/resources/logic_ui.js index 8ab75ad..1fe0595 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'); @@ -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); diff --git a/tests/fixtures/video_core_api.json b/tests/fixtures/video_core_api.json new file mode 100644 index 0000000..1c53420 --- /dev/null +++ b/tests/fixtures/video_core_api.json @@ -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"}} +} diff --git a/tests/fixtures/video_vhs_api.json b/tests/fixtures/video_vhs_api.json new file mode 100644 index 0000000..7200205 --- /dev/null +++ b/tests/fixtures/video_vhs_api.json @@ -0,0 +1,4 @@ +{ + "1": {"class_type": "EmptyImage", "inputs": {"width": 64, "height": 64, "batch_size": 8, "color": 0}, "_meta": {"title": "EmptyImage"}}, + "2": {"class_type": "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"}} +} diff --git a/tests/js/workflow-output-key.test.js b/tests/js/workflow-output-key.test.js new file mode 100644 index 0000000..8fe2e79 --- /dev/null +++ b/tests/js/workflow-output-key.test.js @@ -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); +}); diff --git a/tests/test_workflow_sweep_collector_node.py b/tests/test_workflow_sweep_collector_node.py index 8974113..70cf4c9 100644 --- a/tests/test_workflow_sweep_collector_node.py +++ b/tests/test_workflow_sweep_collector_node.py @@ -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 diff --git a/tests/test_workflow_sweep_video.py b/tests/test_workflow_sweep_video.py new file mode 100644 index 0000000..47ddac2 --- /dev/null +++ b/tests/test_workflow_sweep_video.py @@ -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"] == {} 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