ComfyUI custom node plugin: a set of general-purpose utility nodes + frontend enhancements. Zero external dependencies, built on the ComfyUI V3 extension API (comfy_api.latest).
| Node | Node ID | Category | Function |
|---|---|---|---|
| Continue | FallingTSContinue |
FallingTS/Control |
Workflow segmented execution: by default blocks downstream; "Continue" releases one segment, running to the end segment by segment (relies on the execution cache, upstream is not recomputed) |
| Route | FallingTSRoute |
FallingTS/Control |
total-group routing (reference: grouped switch): one switch routes total groups at once; each group = when-false/when-true/output (ANY); total ≥ 1 |
| Table | FallingTSTable |
FallingTS/Table |
Excel-style table (data embedded in the workflow): a "select" dropdown at the top picks a row and outputs that row's A/B/C... column strings; row/column counts are adjustable, and output ports grow/shrink with the column count |
| Markdown data table | FallingTSMarkDownTable |
FallingTS/Table |
Parse data tables from md files: pick a file with the system picker → a popup searches by field + paginates for a single row → render an editable form inside the node by "title (type)" (IMAGE/VIDEO/AUDIO/MASK/STRING/INT/FLOAT/BOOLEAN/TEXT); refresh re-queries the md by ID; outputs the selected row's fields (by type) + the whole row as JSON |
| Many-to-one selector | FallingTSSelector |
FallingTS/Utility |
General ANY node: items comma-separated group names, total group count (≥ 1), left inputs = group count × number of group names (group 1 first, group 2 after, labels are the group names); the dropdown picks one group name, and each group's selected value on the right outputs that group's input for that name (unselected → None, lazy, not executed); fixed outputs at the top: selected item (group-name text) + index (0-based) |
| Fan-out selector | FallingTSFanout |
FallingTS/Utility |
The mirror of many-to-one selection: items comma-separated group names (same source as many-to-one), total group count (≥ 1, ≤ 50) = number of left input ports (one input_i per group); right outputs = group count × number of group names (one per group per group name, label = group name); selection selected item (dropdown, options = group names, can be wired directly to many-to-one's selected item) — selecting the k-th group name → each group's input_i routes to that group's output for that name, the rest None; when unwired, each group is None; on a partial submit, the output nodes downstream of each group's selected group name are actually executed (frontend enhancement) |
| Grouped switch | FallingTSSwitch |
FallingTS/Utility |
One switch boolean toggles total groups at once (each group when-false/when-true → output, ANY); total ≥ 1 |
| Video preview | PreviewVideo |
video |
Preview into the temp directory; clicking "Save" writes to output per filename_prefix+filename_suffix (.mp4, same name overwritten, no sequence number) |
| Image preview save | PreviewImageSave |
FallingTS/Utility |
Always previews (temp, does not write to output); clicking Save writes to output per filename prefix/suffix/format/bit depth/color space, same name overwritten, no sequence number |
| Audio preview save | PreviewAudioSave |
audio |
Preview into the temp directory; clicking Save writes to output per filename_prefix+filename_suffix + format (flac/mp3/opus, same name overwritten, no sequence number) |
| File | Function |
|---|---|
web/js/task_notify.js |
Browser task-complete/fail notification tones (Web Audio, no system sound): success on execution_success, fail on execution_error/execution_interrupted; per-status volume/frequency/decay settings + a Win11-style toast; configured under Settings → FallingTS → 任务提示音 |
web/js/proceed.js |
Continue node button + segmented execution logic |
web/js/route.js |
Route node: dynamically add/remove each group's ports by total + actually execute the false branch: on a partial submit, merge the output nodes downstream of each switch=false group's output into targets, save this segment and stop |
web/js/preview-image.js |
Preview-save node "Save" button + format-linked bit depth/color space |
web/js/preview-video.js |
Video preview node "Save" button |
web/js/preview-audio.js |
Audio preview node "Save" button |
web/js/table_lookup.js |
Table DOM controls (Excel grid + selection dropdown + first-column ID) |
web/js/md_table.js |
Markdown data table DOM controls: system picker for file + data popup (search/pagination/single-select) + type-rendered form + refresh |
web/js/selector.js |
Many-to-one selection: total group count × items group-name count adds/removes input ports (labels are group names) + show/hide each group's selected-value outputs + dropdown option linkage |
web/js/fanout.js |
Fan-out selector (mirror of many-to-one): dynamically add/remove group input ports by total (one input_i per group) + outputs = group count × group-name count (label = group name) + selection selected-item dropdown option linkage (directly wired to many-to-one's selected item) + actually execute the selected group's branch: on a partial submit, merge the output nodes downstream of each group's selected group name into targets |
web/js/switch.js |
Grouped switch dynamically adds/removes input/output ports by total |
web/js/node_image_middleclick.js |
Node image mouse middle-click fullscreen preview (single image centered, multiple images cycle left/right, same layout as the generated-image preview) |
web/js/media_lightbox_zoom.js |
Image lightbox zoom: wheel/drag/double-click/+/−/0 shortcuts |
web/js/assets_tab_rename.js |
Rename the media assets panel "Imported" tab to "Saved" |
web/js/workflow_reload_button.js |
A "reload workflow" button on the run panel; reloads the current workflow from disk |
Purpose: cut the workflow into segments, execute and inspect segment by segment, without recomputing completed segments.
Principle (rebuilt 2026-08, cache-based):
- The node's backend returns
ExecutionBlockerby default, blocking downstream; theanyinput is declared lazy, andcheck_lazy_statusdecides whether to pull upstream; - Run (default): the frontend
POST /proceed/resetresets all continue nodes to blocking and clears the cache, then fully submits the workflow → the generation segment runs, caches at the first continue node and stops; - Clicking Continue: the frontend
POST /proceed/continue/{id}releases that node, then submitspartial_execution_targets(the output nodes after the next continue) → this continue is already released,check_lazy_statusreturns[]and does not pull upstream (the connections are preserved, upstream does not re-run), so only the newly released segment runs from this node, stopping at the next continue node; - Repeatedly clicking "Continue" runs segment by segment to the end;
- Key: "Continue" does not rely on ComfyUI's global node cache to decide where to run from — it relies on lazy gating
check_lazy_status(cache-independent; on backtracking, lazy edges are simply not traversed) + the node's own_data_cache.
Workflow requirement: data between segments is passed only via continue nodes. If some segment has a direct edge that bypasses the continue node to take an earlier upstream node's output directly (e.g. segment 1 uses segment 0's base image directly for ColorMatch/comparison), that part of upstream is still pulled back and re-run by the execution list's backtracking. Fix: rewire such direct edges' source to downstream of a continue node (e.g. #43's output) so it also passes through the lazy gate.
Key difference from the old version: no longer depends on "every segment must have an output node" (the old version collected in-segment output nodes via partial_execution_targets and would get stuck if a segment had no preview/save node). Now it purely relies on ExecutionBlocker gating + the execution cache, so any structure can be segmented.
Behavior:
- Run → executes to the first continue node and pauses (the node shows a "▶ Continue" button);
- Click "Continue" → release this segment, run to the next continue node and stop;
- To restart from the beginning → press Run again (resets all continues to blocking).
Data pass-through: the any input is output as-is to any.
HTTP routes (auto-registered by the backend):
POST /proceed/continue/{node_id}— release this nodePOST /proceed/reset— reset all continue nodes to blockingPOST /proceed/restart/{node_id}— increment the re-run token to break downstream cache, and return to blocking
Purpose: one switch boolean routes total groups at once — each group = two inputs when-false_i/when-true_i + one output output_i (ANY); total is the group count (≥ 1, ≤ 50), and the frontend dynamically adds/removes ports by total. Suited to multi-group branching scenarios like "false = save this segment and stop, true = continue to the next segment".
Inputs:
switch(BOOLEAN, default true)total(INT, default 2, ≥ 1, ≤ 50)- Per group
false_i(when-false, ANY) /true_i(when-true, ANY),totalgroups in total
Outputs:
output_i— group i's output: takestrue_iwhen switch=true,false_iwhen false (unwired → None)
Behavior:
| switch | Each group's output_i |
|---|---|
| true | true_i |
| false | false_i |
Actually execute the false branch (frontend route.js): segmented execution (clicking "Continue") only runs targets + the targets' upstream ancestors, and the false branch's terminal output nodes (save, etc.) are not among them — the engine will not schedule them. web/js/route.js, on a partial submit, merges the output nodes downstream of each route group's output into targets for the switch=false ones — save/preview/comparison nodes after the false output can actually execute and get data; the true output branch is upstream of the next continue by definition and is already covered by the continue node's targets.
Typical usage: route after a continue node, implementing "false = save this segment and stop, true = continue to the next segment":
dataA → when-false_1 → FallingTSRoute ─output_1→ save/preview (false branch)
dataB → when-true_1 → (total groups) └output_1→ original downstream (continue to next segment)
Purpose: Excel-style table; data is embedded in the workflow JSON (no external files), outputting each column's string per row.
Input: only rows (the FALLINGTS_TABLE DOM control). No separate row-index input — row selection is driven by the "select" dropdown at the top of the node.
Controls (one row at the top of the node):
- Select ▾: lists each row's option — with "first-column ID" enabled, shows the first column's content (e.g.
人物-陈落); otherwise "row N"; selecting writesselected_index, and the next Run outputs that row; - Row count / column count: ≥ 1; changing the column count adds/removes the right-side output ports accordingly;
- First-column ID ☑: when enabled, the column-0 header and output ports are named
ID(then A/B/C...); the selection dropdown uses the first column's content as labels.
Outputs: dynamically generated ID/A/B/C...AZ (up to 52) STRING ports by column count; each port outputs the selected row's corresponding cell string.
Features:
- Data is fully preserved on workflow save/load;
- Cell textareas auto-grow to fit content, and the node grows accordingly (only grows, never shrinks; respects manually adjusted sizes);
- Backward compatible with the old "row-object array" data (auto-migrated to an A..E five-column grid);
- Type conversion is done by the downstream node itself (e.g.
ComfyNumberConvertstring→numeric).
Purpose: treat a data table in an md file as a "database": pick a row in a popup → the node renders an editable form by field type, outputting the selected row's field values (by type).
Data format (the md file is the only data source; it is not saved with the workflow):
- Contains one GFM table (header +
---separator row); the first one is taken; the first column is always the ID column, a str Chinese string, commonly joining multiple pieces of info with-(e.g.龙傲天-主角); - Header format
title(type)(the type goes inside the parentheses;[]is special syntax in md, so no square brackets); unmarked types default toSTRING; supported types:IMAGE/VIDEO/AUDIO/MASK/STRING/INT/FLOAT/BOOLEAN/TEXT(case-insensitive, common aliases normalized;TEXTrenders as a multi-line textbox and outputs STRING likewise).
Operation flow:
- 📁 Select md file — the backend
tkinterpops a native system file picker; the node records the absolute path (the path box can also be pasted manually, as a headless fallback); - 🗂 Open data — an embedded HTML popup: fuzzy search per field at the top, pagination at the bottom (10/20/30/50/100 per page + first/prev/next/last), the table has no sequence-number column, single-select radio in the first column;
- After selecting a row, the bottom OK button changes from gray to blue and lights up; clicking closes the popup and the node loads that row's data into the form; cancel closes without effect;
- The form arranges each field vertically, rendering controls by type (INT/FLOAT numeric input, BOOLEAN checkbox, TEXT multi-line textbox auto-growing to fit content, STRING single-line input, IMAGE/VIDEO/AUDIO/MASK path input + embedded preview), editable;
- 🔄 Refresh (bottom of the node) — re-queries the md file by ID and updates the form with the latest disk values (syncs after the md file is changed externally).
Outputs (dynamic ports, following the table node pattern; unused slots are hidden):
[0] ID(STRING);[1..]each non-ID field — by type:INT/FLOAT/BOOLEANoutput native numeric/boolean,STRING/TEXT/IMAGE/VIDEO/AUDIO/MASKoutput strings (TEXT may contain newlines; media is a file path);- Last
whole row data(STRING) — the whole row's{id, values}JSON string.
Example md:
| ID | Name(STRING) | Avatar(IMAGE) | Height(FLOAT) | Enabled(BOOLEAN) | Description(TEXT) |
|----|-------------|-------------|-------------|--------------|-----------|
| 龙傲天-主角 | 龙傲天 | assets/avatar/lt.png | 180.5 | Yes | protagonist, three-view |
| 陈落-配角 | 陈落 | assets/avatar/cl.png | 165 | No | supporting, three-view |HTTP routes (auto-registered by the backend):
POST /fallingts_mdtable/select_file— pop the system file picker, return the absolute pathGET /fallingts_mdtable/read?path=— parse the md, return the field definitions + all data rowsGET /fallingts_mdtable/preview?path=— serve local image/video/audio preview by absolute path (Range supported)
- The
itemstextbox (comma-separated group names) provides the input port labels and the dropdown options; totalgroup count (≥ 1, ≤ 50): left input ports = group count × number of group names, group 1's group-name inputs first, group 2 after (port labels cycle through the group names, slots stable);- The dropdown picks one group name → each group's selected value i (ANY) on the right outputs that group's input value for that group name, unwired is None, and unselected branches are lazy and not executed;
- Fixed at the top-right: selected item (STRING, the selected group-name text) + index (INT, the selected group name's index, 0-based), followed by each group's selected value stacked in order;
- On a selection mismatch, fall back to the first group name.
The mirror of many-to-one selection: many-to-one is "multi-group multi-input → pick one group name → each group outputs that group name's value"; fan-out is "one input per group → pick one group name → each group's output for that group name = that group's input value":
itemscomma-separated group-name list (same source as many-to-one selection'sitems, e.g.右面,后面,左面, M group names): provides each output port's label and theselectiondropdown options;totalgroup count (≥ 1, ≤ 50) = number of left input ports: oneinput_iper group (ANY,input_1first,input_2after), dynamically added/removed bytotal, only the tail moves, existing wired slots never drift;- Right output ports = group count × group-name count (≤ 50): group i = one output per group name (ANY), port labels cycle through the group names (e.g.
右面,后面,左面), only the tail moves, existing wired slots never drift; selectionselected item (dropdown, options = all group names, can be wired directly to many-to-one's selected item, wired value takes priority): selecting the k-th group name → group i'sinput_iroutes to group i's output for that group name, group i's other outputs are None; when unwired and mismatched, default to the first group name;- When unwired, each group's input is None and all groups' outputs are None;
- When
total/itemschange: input ports add/remove + output ports (group count × group-name count) add/remove + port labels (group names) sync + node height reclaims (only shrinks, never grows); - Key (preload): segmented execution (clicking "Continue") only runs targets + targets' upstream ancestors, and each group's selected-group-name terminal output nodes (save/preview) may not be among them.
web/js/fanout.js, on a partial submit, merges the output nodes downstream of each group's selected group name into targets — each group's selected-branch downstream can actually execute and get data (same mechanism asroute.js's false-branch completion); - Unused ports don't enter the prompt;
IS_CHANGEDsignature =(items, total, selection).
- One
switch(BOOLEAN) toggles total groups at once (up to 50 groups); - Each group = two inputs
false_i/true_i(ANY) + one outputoutput_i(ANY); switchtrue → each group outputstrue_i, false → outputsfalse_i;- The frontend dynamically adds/removes ports by
total; unused ports don't enter the prompt.
- Follows the
IO.ComfyNodeV3 spec; - Input
video→ encoded to mp4 and written to the temp directory (not output) → played in the frontend; - Input
filename_prefix(defaultvideo) +filename_suffix(default empty, appended after the prefix) → clicking the "Save" button, the backend writes directly to output from the video cached by execute ({filename_prefix}{filename_suffix}.mp4, same name overwritten, no_sequencesuffix), without re-running the workflow; - HTTP route:
POST /preview-video/save/{node_id}(body:filename_prefix/filename_suffix+filename_prefix_linked/filename_suffix_linked); - The frontend
web/js/preview-video.jsappends a "Save" button;filename_prefix/filename_suffixcan be wired from upstream (e.g. MDTable's ID column), and save uses the value execute actually received.
- Follows the
IO.ComfyNodeV3 spec; - Input
audio→ nativeUI.PreviewAudiowrites a flac to the temp directory for the frontend to play; - Inputs
filename_prefix(defaultaudio) +filename_suffix(default empty, appended after the prefix) +format(flac/mp3/opus, with quality) → clicking the "Save" button, the backend writes directly to output from the audio cached by execute ({filename_prefix}{filename_suffix}.{format}, same name overwritten, no_sequencesuffix), without re-running the workflow; - For multi-segment waveforms,
{prefix}{suffix}_{i}(still no 5-digit zero-padded sequence number);%batch_num%can be substituted; - HTTP route:
POST /preview-audio/save/{node_id}(body:filename_prefix/filename_suffix/filename_prefix_linked/filename_suffix_linked/format/quality); - The frontend
web/js/preview-audio.jsappends a "Save" button.
git clone https://github.com/falling-ts/ComfyUI-FallingTS.git ComfyUI/custom_nodes/ComfyUI-FallingTScd ComfyUI\custom_nodes
mklink /D ComfyUI-FallingTS ..\..\ComfyUI-FallingTSAfter installing, restart ComfyUI (same after code changes; after changing frontend web/js, a hard browser refresh is enough).
- ComfyUI (dev branch / v0.29+, depends on the V3 extension API
comfy_api.latest) - No extra Python dependencies (reuses ComfyUI's built-in
comfy_api_nodes.util)
ComfyUI-FallingTS/
├── __init__.py # entry: WEB_DIRECTORY + NODE_CLASS_MAPPINGS + sys.path
├── plugin.py # node registration (V1 NODE_CLASS_MAPPINGS + V3 ComfyExtension, dual-track)
├── proceed/ # segmented execution control node (block/continue, cache-based)
│ ├── nodes.py # FallingTSContinue + HTTP routes (continue/reset/restart)
│ └── __init__.py
├── route/ # route node (total-group routing)
│ ├── nodes.py # FallingTSRoute
│ └── __init__.py
├── fanout/ # fan-out selector node (mirror of many-to-one: total=group count=number of inputs, outputs=group count × group-name count, selected-item dropdown)
│ ├── nodes.py # FallingTSFanout (each group's input_i routes to that group's selected group name's output, the rest None)
│ └── __init__.py
├── table/ # general table node (Excel-style)
│ ├── nodes.py # FallingTSTable (ID first column + selection dropdown, STRING outputs)
│ └── __init__.py
├── selector/ # dropdown selector node
│ └── nodes.py
├── mdtable/ # Markdown data table super-node
│ ├── nodes.py # FallingTSMarkDownTable + HTTP routes (select_file/read/preview)
│ ├── parser.py # md table parsing + value type conversion (pure functions, unit-testable)
│ └── __init__.py
├── switch/ # grouped switch node
│ ├── nodes.py
│ └── __init__.py
├── preview-video/ # video preview node (preview+save, V3; directory name contains a hyphen, loaded via importlib)
├── preview-audio/ # audio preview node (preview+save, V3; directory name contains a hyphen, loaded via importlib)
│ ├── nodes.py
│ └── __init__.py
├── preview-image/ # image preview-save node (always preview temp + click "Save" to write output, same name overwritten)
│ ├── nodes.py # PreviewImageSave + HTTP route (/preview-image/save)
│ └── __init__.py
├── web/js/ # frontend extensions (loaded at runtime by ComfyUI via /extensions, not part of the frontend build)
├── locales/ # i18n translations (zh/nodeDefs.json, node and control display names)
└── README.md
Notes:
- The plugin's
web/js/*.jsare loaded at runtime by ComfyUI'sGET /extensionsendpoint and do not participate in the frontend build; upgrading/rebuilding the frontend package directory does not affect this plugin; - Node registration is dual-track: V1 via
NODE_CLASS_MAPPINGS(4 nodes), V3 viaDesktopPluginsExtension.get_node_list()(PreviewVideo); - Subpackage
__init__.pyfiles use relative imports and don't depend on sys.path order.