Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,31 @@ Default models (placed in `models/`):

## Installation

### Fresh install

```bash
cd ComfyUI/custom_nodes
git clone https://github.com/audiohacking/acestep-cpp-comfyui
```

Restart ComfyUI. On startup the node will attempt to build the `ace-qwen3` and `dit-vae` binaries automatically if `git` and `cmake` are available. If the automatic build does not complete, use the **Acestep.cpp Builder** node inside ComfyUI — no manual file editing required.

### Updating an existing installation

```bash
cd ComfyUI/custom_nodes/acestep-cpp-comfyui
git pull
```

Then restart ComfyUI so it picks up the new node code.

> **After updating**: if your existing workflows show validation errors such as
> *"Failed to convert an input value to a INT/FLOAT value"*, the workflow was
> saved with an older version of the node. Simply **delete the Generate node
> from the canvas, re-add it from the node list, and re-connect its inputs** —
> this resets the widget values to the current defaults and clears any stale
> empty-string placeholders.

## Advanced Configuration

`config.json` is **optional** and only needed if you store binaries or models in non-standard locations.
Expand Down
66 changes: 38 additions & 28 deletions nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1012,17 +1012,6 @@ def INPUT_TYPES(cls):
},
),
# ---- Source audio (cover / repaint / lego) ----
"src_audio": (
"STRING",
{
"default": "",
"tooltip": (
"Path to a WAV or MP3 source file. Used for cover, repaint, "
"and lego modes. Prefer connecting 'src_audio_input' from a "
"Load Audio node instead."
),
},
),
"audio_cover_strength": (
"FLOAT",
{
Expand Down Expand Up @@ -1073,6 +1062,18 @@ def INPUT_TYPES(cls):
),
},
),
# ---- Source audio path (string widget) ----
"src_audio": (
"STRING",
{
"default": "",
"tooltip": (
"Path to a WAV or MP3 source file. Used for cover, repaint, "
"and lego modes. Prefer connecting 'src_audio_input' from a "
"Load Audio node instead."
),
},
),
# ---- LoRA adapter (convenience widgets) ----
"lora_path": (
"STRING",
Expand Down Expand Up @@ -1132,26 +1133,35 @@ def INPUT_TYPES(cls):
CATEGORY = "AcestepCPP"

@classmethod
def VALIDATE_INPUTS(cls, lm_top_p=0.9, audio_cover_strength=0.5, **kwargs):
"""Allow older workflows that store lm_top_p / audio_cover_strength as
an empty string instead of a float.

ComfyUI skips its own ``float()`` conversion check for any input whose
name appears in this method's signature, passing the raw value directly
to ``generate()`` instead. ``_coerce_float`` then converts ``""`` to
the numeric default at runtime.

Empty strings are intentionally allowed here — they will be coerced to
the numeric default by ``_coerce_float`` inside ``generate()``. Only
non-empty strings that cannot be parsed as a float are rejected.
def VALIDATE_INPUTS(
cls,
lm_top_p=0.9,
lm_top_k=0,
audio_cover_strength=0.5,
repainting_start=-1.0,
repainting_end=-1.0,
**kwargs,
):
"""Allow older workflows that store numeric inputs as empty strings.

ComfyUI skips its own type-conversion check for any input whose name
appears in this method's signature, passing the raw value directly to
``generate()`` instead. ``_coerce_float`` / ``_coerce_int`` then
convert ``""`` to the numeric default at runtime.

Empty strings are intentionally allowed — only non-empty strings that
cannot be parsed as the expected numeric type are rejected.
"""
for name, val in (
("lm_top_p", lm_top_p),
("audio_cover_strength", audio_cover_strength),
for name, val, coerce in (
("lm_top_p", lm_top_p, float),
("lm_top_k", lm_top_k, int),
("audio_cover_strength", audio_cover_strength, float),
("repainting_start", repainting_start, float),
("repainting_end", repainting_end, float),
):
if isinstance(val, str) and val.strip():
try:
float(val)
coerce(val)
except ValueError:
return f"Invalid value for {name}: {val!r}"
return True
Expand All @@ -1177,11 +1187,11 @@ def generate(
lm_top_k: int = 0,
lm_negative_prompt: str = "",
use_cot_caption: bool = True,
src_audio: str = "",
audio_cover_strength: float = 0.5,
repainting_start: float = -1.0,
repainting_end: float = -1.0,
lego: str = "",
src_audio: str = "",
lora_path: str = "",
lora_scale: float = 1.0,
src_audio_input: Optional[Dict[str, Any]] = None,
Expand Down
106 changes: 106 additions & 0 deletions tests/test_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,27 @@ def test_optional_connections_present(self):
assert "lora" in opt
assert "options" in opt

def test_src_audio_after_lego_in_widget_order(self):
"""src_audio must appear after lego in INPUT_TYPES so the widget
positional index matches the saved workflow files (index 22)."""
opt = nodes.AcestepCPPGenerate.INPUT_TYPES()["optional"]
# Connection-type inputs do not produce widget slots in workflows.
# These types are the ones used for node-to-node connections.
_CONNECTION_TYPES = {"AUDIO", "ACESTEP_MODELS", "ACESTEP_LORA", "ACESTEP_OPTIONS"}
widget_names = []
for name, spec in opt.items():
type_val = spec[0] if isinstance(spec, tuple) else spec
# list/tuple values mean a dropdown — always a widget input
if isinstance(type_val, str) and type_val in _CONNECTION_TYPES:
continue
widget_names.append(name)
lego_idx = widget_names.index("lego")
src_audio_idx = widget_names.index("src_audio")
assert src_audio_idx > lego_idx, (
f"src_audio (index {src_audio_idx}) must come after lego "
f"(index {lego_idx}) in INPUT_TYPES optional section"
)

def test_optional_has_new_params(self):
"""New params added in the redesign must all be present."""
opt = nodes.AcestepCPPGenerate.INPUT_TYPES()["optional"]
Expand Down Expand Up @@ -297,6 +318,91 @@ def test_lego_tracks_list(self):
assert "" in nodes.AcestepCPPGenerate.LEGO_TRACKS


# ===========================================================================
# AcestepCPPGenerate — VALIDATE_INPUTS
# ===========================================================================

class TestValidateInputs:
"""VALIDATE_INPUTS must accept empty strings for all numeric optional fields
so that older workflows serialised with '' instead of the numeric default
pass ComfyUI's prompt-validation stage."""

def _vi(self, **kwargs):
return nodes.AcestepCPPGenerate.VALIDATE_INPUTS(**kwargs)

# ---- lm_top_p (FLOAT) ------------------------------------------------
def test_lm_top_p_empty_string_accepted(self):
assert self._vi(lm_top_p="") is True

def test_lm_top_p_valid_string_accepted(self):
assert self._vi(lm_top_p="0.9") is True

def test_lm_top_p_invalid_string_rejected(self):
result = self._vi(lm_top_p="abc")
assert isinstance(result, str) and "lm_top_p" in result

# ---- lm_top_k (INT) --------------------------------------------------
def test_lm_top_k_empty_string_accepted(self):
assert self._vi(lm_top_k="") is True

def test_lm_top_k_numeric_accepted(self):
assert self._vi(lm_top_k=0) is True

def test_lm_top_k_valid_string_accepted(self):
assert self._vi(lm_top_k="50") is True

def test_lm_top_k_invalid_string_rejected(self):
result = self._vi(lm_top_k="bad")
assert isinstance(result, str) and "lm_top_k" in result

# ---- audio_cover_strength (FLOAT) ------------------------------------
def test_audio_cover_strength_empty_string_accepted(self):
assert self._vi(audio_cover_strength="") is True

# ---- repainting_start (FLOAT) ----------------------------------------
def test_repainting_start_empty_string_accepted(self):
assert self._vi(repainting_start="") is True

def test_repainting_start_numeric_accepted(self):
assert self._vi(repainting_start=-1.0) is True

def test_repainting_start_valid_string_accepted(self):
assert self._vi(repainting_start="10.5") is True

def test_repainting_start_invalid_string_rejected(self):
result = self._vi(repainting_start="bad")
assert isinstance(result, str) and "repainting_start" in result

# ---- repainting_end (FLOAT) ------------------------------------------
def test_repainting_end_empty_string_accepted(self):
assert self._vi(repainting_end="") is True

def test_repainting_end_numeric_accepted(self):
assert self._vi(repainting_end=-1.0) is True

def test_repainting_end_valid_string_accepted(self):
assert self._vi(repainting_end="30.0") is True

def test_repainting_end_invalid_string_rejected(self):
result = self._vi(repainting_end="bad")
assert isinstance(result, str) and "repainting_end" in result

# ---- combined (all five at once) -------------------------------------
def test_all_empty_strings_accepted(self):
assert self._vi(
lm_top_p="", lm_top_k="",
audio_cover_strength="",
repainting_start="", repainting_end="",
) is True

def test_all_numeric_accepted(self):
assert self._vi(
lm_top_p=0.9, lm_top_k=0,
audio_cover_strength=0.5,
repainting_start=-1.0, repainting_end=-1.0,
) is True


# ===========================================================================
# AcestepCPPOptions
# ===========================================================================
Expand Down
66 changes: 59 additions & 7 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,29 @@ def test_model_loader_widget_values_are_known_models(self, workflow_path):
f"Unexpected model name in ModelLoader: {v!r}"

# --- Generate node widget value types --------------------------------
# New widget order (redesigned node, no task_type / reference_audio):
# 0:caption 1:lyrics 2:instrumental 3:vocal_language 4:duration 5:bpm
# 6:keyscale 7:timesignature 8:inference_steps 9:guidance_scale 10:shift
# 11:seed 12:lm_temperature 13:lm_cfg_scale 14:lm_top_p 15:lm_top_k
# 16:lm_negative_prompt 17:use_cot_caption 18:audio_cover_strength
# 19:repainting_start 20:repainting_end 21:lego 22:src_audio
# 23:lora_path 24:lora_scale
# Widget order (must match INPUT_TYPES — connection-type inputs are excluded):
# 0:caption 1:lyrics 2:instrumental 3:vocal_language 4:duration 5:bpm
# 6:keyscale 7:timesignature 8:inference_steps 9:guidance_scale 10:shift
# 11:seed 12:lm_temperature 13:lm_cfg_scale 14:lm_top_p 15:lm_top_k
# 16:lm_negative_prompt 17:use_cot_caption 18:audio_cover_strength
# 19:repainting_start 20:repainting_end 21:lego 22:src_audio
# 23:lora_path 24:lora_scale

def test_generate_widget_count(self, workflow_path):
"""AcestepCPPGenerate must have exactly 25 widget values.

Widget inputs (non-connection-type): caption, lyrics, instrumental,
vocal_language, duration, bpm, keyscale, timesignature, inference_steps,
guidance_scale, shift, seed, lm_temperature, lm_cfg_scale, lm_top_p,
lm_top_k, lm_negative_prompt, use_cot_caption, audio_cover_strength,
repainting_start, repainting_end, lego, src_audio, lora_path, lora_scale.
"""
EXPECTED_WIDGET_COUNT = 25
wf = _load(workflow_path)
for node in _nodes_by_type(wf, "AcestepCPPGenerate"):
wv = node.get("widgets_values", [])
assert len(wv) == EXPECTED_WIDGET_COUNT, \
f"Expected {EXPECTED_WIDGET_COUNT} widget values, got {len(wv)}: {wv}"

def test_generate_lm_top_p_is_numeric(self, workflow_path):
"""Widget index 14 (lm_top_p) must be a number, never an empty string."""
Expand All @@ -127,6 +143,15 @@ def test_generate_lm_top_p_is_numeric(self, workflow_path):
assert isinstance(wv[14], (int, float)), \
f"lm_top_p (index 14) should be numeric, got {type(wv[14])}"

def test_generate_lm_top_k_is_numeric(self, workflow_path):
"""Widget index 15 (lm_top_k) must be an integer, never an empty string."""
wf = _load(workflow_path)
for node in _nodes_by_type(wf, "AcestepCPPGenerate"):
wv = node.get("widgets_values", [])
if len(wv) > 15:
assert isinstance(wv[15], int), \
f"lm_top_k (index 15) should be int, got {type(wv[15])}: {wv[15]!r}"

def test_generate_audio_cover_strength_is_numeric(self, workflow_path):
"""Widget index 18 (audio_cover_strength) must be a number."""
wf = _load(workflow_path)
Expand All @@ -136,6 +161,33 @@ def test_generate_audio_cover_strength_is_numeric(self, workflow_path):
assert isinstance(wv[18], (int, float)), \
f"audio_cover_strength (index 18) should be numeric, got {type(wv[18])}"

def test_generate_repainting_start_is_numeric(self, workflow_path):
"""Widget index 19 (repainting_start) must be a number, never an empty string."""
wf = _load(workflow_path)
for node in _nodes_by_type(wf, "AcestepCPPGenerate"):
wv = node.get("widgets_values", [])
if len(wv) > 19:
assert isinstance(wv[19], (int, float)), \
f"repainting_start (index 19) should be numeric, got {type(wv[19])}: {wv[19]!r}"

def test_generate_repainting_end_is_numeric(self, workflow_path):
"""Widget index 20 (repainting_end) must be a number, never an empty string."""
wf = _load(workflow_path)
for node in _nodes_by_type(wf, "AcestepCPPGenerate"):
wv = node.get("widgets_values", [])
if len(wv) > 20:
assert isinstance(wv[20], (int, float)), \
f"repainting_end (index 20) should be numeric, got {type(wv[20])}: {wv[20]!r}"

def test_generate_src_audio_is_string(self, workflow_path):
"""Widget index 22 (src_audio) must be a string."""
wf = _load(workflow_path)
for node in _nodes_by_type(wf, "AcestepCPPGenerate"):
wv = node.get("widgets_values", [])
if len(wv) > 22:
assert isinstance(wv[22], str), \
f"src_audio (index 22) should be a string, got {type(wv[22])}"

def test_generate_no_task_type(self, workflow_path):
"""task_type is not a valid acestep.cpp field — must not appear in nodes."""
wf = _load(workflow_path)
Expand Down
Loading