Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4597af8f59 | ||
|
|
896c6c34df | ||
|
|
fcf92a906e | ||
|
|
6d4c82deba |
@@ -4,25 +4,18 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- master
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'wildminder' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
uses: actions/checkout@v7
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@v1
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
# The publish-only workflow next to this one is untouched.
|
||||
#
|
||||
# The point of this job is that it is nearly empty. `conftest.py` mocks the
|
||||
# whole vendored Chatterbox model stack at collection time, so the suite runs
|
||||
# on a clean runner with:
|
||||
#
|
||||
# * no resemble-perth (the watermarking import is defensive)
|
||||
# * no diffusers (not a dependency any more)
|
||||
# * no s3tokenizer (vendored imports are mocked)
|
||||
# * no conformer (vendored imports are mocked)
|
||||
#
|
||||
# That is the Phase 0 mock paying for itself: if a future change ever makes the
|
||||
# suite depend on one of those, this workflow fails. So do not "fix" it by
|
||||
# adding them to the install list below -- that would delete the signal.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
name: pytest (mocked vendor, real ComfyUI core)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out the node package
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# A real ComfyUI checkout, laid out as a sibling of this repository so
|
||||
# conftest's own resolution logic finds it even without COMFYUI_ROOT.
|
||||
# `comfy` and `folder_paths` are imported FOR REAL: these are ComfyUI
|
||||
# nodes, and testing them against a fake core would test nothing.
|
||||
- name: Check out ComfyUI core
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: comfyanonymous/ComfyUI
|
||||
path: ComfyUI
|
||||
|
||||
# Only what the suite actually touches from the host environment, which
|
||||
# a real user already has. Deliberately not this repository's own
|
||||
# requirements.txt -- see the header.
|
||||
- name: Install the test runner
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install pytest setuptools
|
||||
python -m pip install --index-url https://download.pytorch.org/whl/cpu torch
|
||||
python -m pip install numpy soundfile huggingface_hub
|
||||
|
||||
- name: Report the resolved environment
|
||||
run: |
|
||||
python --version
|
||||
python -c "import torch; print('torch', torch.__version__)"
|
||||
python -m pytest --version
|
||||
|
||||
- name: Run the suite
|
||||
env:
|
||||
COMFYUI_ROOT: ${{ github.workspace }}/ComfyUI
|
||||
# -rs so every skip prints its reason: a passing suite whose skips are
|
||||
# unexplained is not a green suite.
|
||||
run: python -m pytest tests/ -q -rs
|
||||
@@ -127,6 +127,11 @@ from ComfyUI itself.
|
||||
Every widget is documented in its node tooltip. Load a graph from
|
||||
[`example_workflows/`](./example_workflows) to see the nodes wired up.
|
||||
|
||||
When a node fails at execution — a missing reference clip, a pack with no
|
||||
conversion model — the message shows up as a toast in the web UI, naming the
|
||||
node and the error. Add `Chatterbox.ErrorToasts` to the `Comfy.Extension.Disabled`
|
||||
list in ComfyUI's settings to turn the toasts off.
|
||||
|
||||
<p align="right"><a href="#readme-top">⟔ ▲ ⟓ back to top</a></p>
|
||||
|
||||
<p id="good-to-know" align="center">◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆</p>
|
||||
|
||||
+11
-5
@@ -3,10 +3,11 @@
|
||||
ComfyUI imports this module for one reason: to find ``comfy_entrypoint``, the
|
||||
async factory that hands back the :class:`~comfy_api.latest.ComfyExtension`
|
||||
holding this package's nodes. That is all a V3 custom node exposes -- the V1
|
||||
``NODE_CLASS_MAPPINGS`` / ``NODE_DISPLAY_NAME_MAPPINGS`` pair is gone, and
|
||||
there is deliberately **no ``WEB_DIRECTORY``**: this package ships no frontend
|
||||
assets, and pointing ComfyUI at a missing directory only produces a warning on
|
||||
every startup.
|
||||
``NODE_CLASS_MAPPINGS`` / ``NODE_DISPLAY_NAME_MAPPINGS`` pair is gone. The one
|
||||
other name it does expose is ``WEB_DIRECTORY``: ``js/`` ships ``extension.js``,
|
||||
a plain-ESM extension that turns a failed Chatterbox node execution into a
|
||||
web-UI toast, and ComfyUI serves that directory at
|
||||
``/extensions/ComfyUI-Chatterbox/`` for its loader to import at boot.
|
||||
|
||||
What happens here, in order, each step degrading rather than raising:
|
||||
|
||||
@@ -43,7 +44,12 @@ else:
|
||||
from .modules.model_discovery import get_chatterbox_model_pack_names
|
||||
from .modules.vendor_loader import ensure_vendor_on_path
|
||||
|
||||
logging.info("[Chatterbox TTS] Registering the Chatterbox custom node.")
|
||||
# Declared with the bootstrap, never above the pytest guard: the guard is
|
||||
# what makes this module importable during collection, and a web directory
|
||||
# bound there would be a claim the test process cannot back up. ComfyUI
|
||||
# resolves it against this module's own directory and serves whatever it
|
||||
# finds at /extensions/ComfyUI-Chatterbox/.
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
# 1. The vendored `chatterbox` package lives in this repository's `src/`.
|
||||
ensure_vendor_on_path()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 159 KiB |
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* node:test suite for the shipped js/extension.js. Zero dependencies: the
|
||||
* built-in runner, the standard library, nothing else.
|
||||
*
|
||||
* The extension imports ComfyUI's frontend `app` module, which does not exist
|
||||
* outside a browser. So every case stages a throwaway tree in the OS temp dir
|
||||
* shaped like a real deployment:
|
||||
*
|
||||
* <stage>/scripts/app.js <- the stub below, configured by stub.json
|
||||
* <stage>/ComfyUI-Chatterbox/js/extension.js <- a copy of the shipped file
|
||||
*
|
||||
* The extension is imported *from the staged package path*, so its
|
||||
* `import { app } from "../../scripts/app.js"` resolves to the stub two
|
||||
* directories up -- the same relative shape the core extension loader serves it
|
||||
* under. Each case gets a fresh stage, hence a fresh module instance, hence a
|
||||
* fresh listener.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, "..");
|
||||
const SHIPPED_EXTENSION = path.join(REPO_ROOT, "js", "extension.js");
|
||||
|
||||
const EXTENSION_NAME = "Chatterbox.ErrorToasts";
|
||||
const DISABLED_SETTING = "Comfy.Extension.Disabled";
|
||||
const TOAST_DURATION = 10000;
|
||||
const DEDUPE_WINDOW = 3000;
|
||||
|
||||
// The two messages the ask names, verbatim. Anything that reformats them --
|
||||
// a rewrap, a dropped article -- breaks the toast the user reads.
|
||||
const FLASH_MESSAGE =
|
||||
"Chatterbox Flash ships no built-in voice -- connect a reference " +
|
||||
"audio clip to the node's audio prompt input.";
|
||||
const VC_MESSAGE =
|
||||
"The Chatterbox voice pack 'chatterbox-flash' loaded without a " +
|
||||
"conversion model. Use the Chatterbox TTS node for it.";
|
||||
|
||||
/**
|
||||
* A minimal stand-in for the frontend app module. Its shape comes from
|
||||
* stub.json, written next to this file's parent directory by stage(), so one
|
||||
* stub plays every delivery branch the extension feature-detects.
|
||||
*/
|
||||
const STUB_SOURCE = `import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const config = JSON.parse(readFileSync(join(here, "..", "stub.json"), "utf8"));
|
||||
|
||||
const state = {
|
||||
listeners: [],
|
||||
registered: [],
|
||||
modern: [],
|
||||
legacy: [],
|
||||
settingReads: [],
|
||||
};
|
||||
|
||||
const manager = {};
|
||||
|
||||
if (config.toast === "modern") {
|
||||
manager.toast = {
|
||||
error(title, options) {
|
||||
state.modern.push({ title, options });
|
||||
},
|
||||
};
|
||||
} else if (config.toast === "legacy") {
|
||||
manager.toast = {
|
||||
add(options) {
|
||||
state.legacy.push(options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (config.disabled === true) {
|
||||
manager.setting = {
|
||||
get(key) {
|
||||
state.settingReads.push(key);
|
||||
return config.disabledEntries;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const app = {
|
||||
api: {
|
||||
addEventListener(type, handler) {
|
||||
state.listeners.push({ type, handler });
|
||||
},
|
||||
},
|
||||
registerExtension(extension) {
|
||||
state.registered.push(extension);
|
||||
},
|
||||
extensionManager: manager,
|
||||
};
|
||||
|
||||
export { state };
|
||||
`;
|
||||
|
||||
/** Stage a throwaway deployment tree and return its paths. */
|
||||
function stage(config) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "chatterbox-toast-"));
|
||||
const packageJs = path.join(root, "ComfyUI-Chatterbox", "js");
|
||||
fs.mkdirSync(packageJs, { recursive: true });
|
||||
const extensionPath = path.join(packageJs, "extension.js");
|
||||
fs.copyFileSync(SHIPPED_EXTENSION, extensionPath);
|
||||
|
||||
const scriptsDir = path.join(root, "scripts");
|
||||
fs.mkdirSync(scriptsDir, { recursive: true });
|
||||
const stubPath = path.join(scriptsDir, "app.js");
|
||||
fs.writeFileSync(stubPath, STUB_SOURCE, "utf8");
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(root, "stub.json"),
|
||||
JSON.stringify(config),
|
||||
"utf8"
|
||||
);
|
||||
return { root, stubPath, extensionPath };
|
||||
}
|
||||
|
||||
function discard(staged) {
|
||||
fs.rmSync(staged.root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Import both modules from the staged tree. The extension's own import of the
|
||||
* app stub resolves to the same file URL, so the two calls share one module
|
||||
* instance and the test sees exactly what the extension saw.
|
||||
*/
|
||||
async function load(staged) {
|
||||
await import(pathToFileURL(staged.extensionPath).href);
|
||||
const stub = await import(pathToFileURL(staged.stubPath).href);
|
||||
return { stub };
|
||||
}
|
||||
|
||||
/** Fire a real execution_error event at the recorded listener. */
|
||||
function dispatch(state, detail) {
|
||||
assert.equal(state.listeners.length, 1, "exactly one listener registered");
|
||||
const [listener] = state.listeners;
|
||||
assert.equal(listener.type, "execution_error");
|
||||
listener.handler(new CustomEvent("execution_error", { detail }));
|
||||
}
|
||||
|
||||
/** The payload ComfyUI's execution layer broadcasts for a failed node. */
|
||||
function chatterboxError(overrides = {}) {
|
||||
return {
|
||||
prompt_id: "prompt-1",
|
||||
node_id: "7",
|
||||
node_type: "ChatterboxTTS",
|
||||
executed: ["7"],
|
||||
exception_message: FLASH_MESSAGE,
|
||||
exception_type: "ValueError",
|
||||
traceback: ["Traceback (most recent call last):", "ValueError: ..."],
|
||||
current_inputs: { text: "Hello.", audio_prompt: null },
|
||||
current_outputs: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function frozenClock(start) {
|
||||
const real = Date.now;
|
||||
let now = start;
|
||||
Date.now = () => now;
|
||||
return {
|
||||
advance(ms) {
|
||||
now += ms;
|
||||
},
|
||||
restore() {
|
||||
Date.now = real;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("1. the execution_error listener is registered at module load", async (t) => {
|
||||
const staged = stage({ toast: "modern" });
|
||||
t.after(() => discard(staged));
|
||||
const { stub } = await load(staged);
|
||||
|
||||
// Nothing has called setup() -- nothing in this harness can -- yet the
|
||||
// listener is already there, and a dispatch right now already toasts. A
|
||||
// listener registered lazily would miss every event that fired first.
|
||||
assert.equal(stub.state.listeners.length, 1);
|
||||
assert.equal(stub.state.listeners[0].type, "execution_error");
|
||||
assert.equal(stub.state.modern.length, 0, "importing must not toast");
|
||||
|
||||
dispatch(stub.state, chatterboxError());
|
||||
assert.equal(stub.state.modern.length, 1);
|
||||
});
|
||||
|
||||
test("2. a Chatterbox failure toasts the message verbatim with node and type", async (t) => {
|
||||
const staged = stage({ toast: "modern" });
|
||||
t.after(() => discard(staged));
|
||||
const { stub } = await load(staged);
|
||||
|
||||
dispatch(stub.state, chatterboxError());
|
||||
|
||||
assert.equal(stub.state.legacy.length, 0, "the deprecated branch must not fire");
|
||||
assert.equal(stub.state.modern.length, 1);
|
||||
const { title, options } = stub.state.modern[0];
|
||||
assert.equal(title, FLASH_MESSAGE);
|
||||
assert.ok(options.description.includes("ChatterboxTTS"), options.description);
|
||||
assert.ok(options.description.includes("ValueError"), options.description);
|
||||
assert.equal(options.closable, true);
|
||||
assert.equal(options.duration, TOAST_DURATION);
|
||||
});
|
||||
|
||||
test("3. a non-Chatterbox node_type produces no toast", async (t) => {
|
||||
const staged = stage({ toast: "modern" });
|
||||
t.after(() => discard(staged));
|
||||
const { stub } = await load(staged);
|
||||
|
||||
dispatch(
|
||||
stub.state,
|
||||
chatterboxError({ node_type: "SomeOtherPackNode", node_id: "99" })
|
||||
);
|
||||
|
||||
assert.equal(stub.state.modern.length, 0, "another pack's failure is not ours");
|
||||
assert.equal(stub.state.legacy.length, 0);
|
||||
});
|
||||
|
||||
test("4. an identical message is deduped inside the window and re-toasts outside it", async (t) => {
|
||||
const staged = stage({ toast: "modern" });
|
||||
t.after(() => discard(staged));
|
||||
const { stub } = await load(staged);
|
||||
|
||||
const clock = frozenClock(0);
|
||||
try {
|
||||
dispatch(stub.state, chatterboxError());
|
||||
assert.equal(stub.state.modern.length, 1);
|
||||
|
||||
dispatch(stub.state, chatterboxError());
|
||||
assert.equal(stub.state.modern.length, 1, "the same fault twice in a row");
|
||||
|
||||
clock.advance(DEDUPE_WINDOW - 1);
|
||||
dispatch(stub.state, chatterboxError());
|
||||
assert.equal(stub.state.modern.length, 1, "still inside the window");
|
||||
|
||||
clock.advance(1); // exactly at the boundary: not inside it
|
||||
dispatch(stub.state, chatterboxError());
|
||||
assert.equal(stub.state.modern.length, 2, "past the window it toasts again");
|
||||
|
||||
// The key names the node, so the same message on a different node is a
|
||||
// different fault and is never suppressed.
|
||||
dispatch(stub.state, chatterboxError({ node_type: "ChatterboxVC" }));
|
||||
assert.equal(stub.state.modern.length, 3, "a different node is not a duplicate");
|
||||
} finally {
|
||||
clock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("5. without toast.error the deprecated toast.add is used", async (t) => {
|
||||
const staged = stage({ toast: "legacy" });
|
||||
t.after(() => discard(staged));
|
||||
const { stub } = await load(staged);
|
||||
|
||||
dispatch(stub.state, chatterboxError());
|
||||
|
||||
assert.equal(stub.state.modern.length, 0, "the modern branch must not fire");
|
||||
assert.equal(stub.state.legacy.length, 1);
|
||||
const call = stub.state.legacy[0];
|
||||
assert.equal(call.severity, "error");
|
||||
assert.equal(call.summary, FLASH_MESSAGE);
|
||||
assert.ok(call.detail.includes("ChatterboxTTS"), call.detail);
|
||||
assert.ok(call.detail.includes("ValueError"), call.detail);
|
||||
assert.equal(call.life, TOAST_DURATION);
|
||||
assert.equal(call.closable, true);
|
||||
});
|
||||
|
||||
test("6. without a toast manager it logs to the console and never throws", async (t) => {
|
||||
const staged = stage({ toast: "none" });
|
||||
t.after(() => discard(staged));
|
||||
const { stub } = await load(staged);
|
||||
|
||||
const logged = [];
|
||||
const realError = console.error;
|
||||
console.error = (...args) => logged.push(args);
|
||||
try {
|
||||
dispatch(stub.state, chatterboxError());
|
||||
// Not our node: filtered out before any delivery, so no log either.
|
||||
dispatch(stub.state, chatterboxError({ node_type: "SomeOtherPackNode" }));
|
||||
// A malformed event must not throw either.
|
||||
dispatch(stub.state, null);
|
||||
} finally {
|
||||
console.error = realError;
|
||||
}
|
||||
|
||||
assert.equal(logged.length, 1, "only the Chatterbox failure logs");
|
||||
assert.match(String(logged[0][0]), /\[Chatterbox TTS\] Node error:/);
|
||||
assert.ok(String(logged[0][1]).includes(FLASH_MESSAGE));
|
||||
assert.ok(String(logged[0][2]).includes("ChatterboxTTS"));
|
||||
});
|
||||
|
||||
test("7. the extension registers under its own name", async (t) => {
|
||||
const staged = stage({ toast: "modern" });
|
||||
t.after(() => discard(staged));
|
||||
const { stub } = await load(staged);
|
||||
|
||||
assert.deepEqual(stub.state.registered, [{ name: EXTENSION_NAME }]);
|
||||
});
|
||||
|
||||
test("8. both named messages pass through verbatim as the toast title", async (t) => {
|
||||
const staged = stage({ toast: "modern" });
|
||||
t.after(() => discard(staged));
|
||||
const { stub } = await load(staged);
|
||||
|
||||
dispatch(stub.state, chatterboxError());
|
||||
dispatch(
|
||||
stub.state,
|
||||
chatterboxError({
|
||||
node_type: "ChatterboxVC",
|
||||
exception_message: VC_MESSAGE,
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
stub.state.modern.map((entry) => entry.title),
|
||||
[FLASH_MESSAGE, VC_MESSAGE]
|
||||
);
|
||||
});
|
||||
|
||||
test("9. naming the extension in Comfy.Extension.Disabled skips the listener", async (t) => {
|
||||
const staged = stage({
|
||||
toast: "modern",
|
||||
disabled: true,
|
||||
disabledEntries: [EXTENSION_NAME],
|
||||
});
|
||||
t.after(() => discard(staged));
|
||||
const { stub } = await load(staged);
|
||||
|
||||
// The setting was really read -- the skip is the opt-out, not a shortcut.
|
||||
assert.deepEqual(stub.state.settingReads, [DISABLED_SETTING]);
|
||||
assert.equal(stub.state.listeners.length, 0, "a disabled extension registers nothing");
|
||||
assert.deepEqual(
|
||||
stub.state.registered,
|
||||
[{ name: EXTENSION_NAME }],
|
||||
"the name still registers, so it appears in the core disabled list"
|
||||
);
|
||||
});
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* ComfyUI-Chatterbox frontend extension: node-error toasts.
|
||||
*
|
||||
* Served from the package's WEB_DIRECTORY at /extensions/ComfyUI-Chatterbox/
|
||||
* and dynamic-imported at boot by the core extension loader. Plain ESM: no
|
||||
* build step, no dependencies.
|
||||
*/
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
/** Node ids whose execution failures deserve a toast. Kept in sync with the
|
||||
* Python schema by tests/test_frontend_node_set.py. */
|
||||
const CHATTERBOX_NODE_TYPES = new Set([
|
||||
"ChatterboxTTS",
|
||||
"ChatterboxMTLTTS",
|
||||
"ChatterboxSRT",
|
||||
"ChatterboxDialog",
|
||||
"ChatterboxVC",
|
||||
]);
|
||||
|
||||
const EXTENSION_NAME = "Chatterbox.ErrorToasts";
|
||||
|
||||
/** Toast lifetime and dedupe window, both in milliseconds. */
|
||||
const TOAST_DURATION = 10000;
|
||||
const DEDUPE_WINDOW = 3000;
|
||||
|
||||
/**
|
||||
* Opt-out via the core `Comfy.Extension.Disabled` setting.
|
||||
*
|
||||
* That setting gates extension *callbacks* (the store invokes only enabled
|
||||
* extensions); it does not gate side effects performed at module load. So the
|
||||
* list is consulted here, at load. A missing store, a missing key or a throwing
|
||||
* read fails open: toasts stay on. Silencing every node error with the same
|
||||
* broken read that skipped the registration would be the worse failure.
|
||||
*/
|
||||
function isDisabledByName() {
|
||||
try {
|
||||
const setting = app.extensionManager && app.extensionManager.setting;
|
||||
if (!setting || typeof setting.get !== "function") return false;
|
||||
const disabled = setting.get("Comfy.Extension.Disabled");
|
||||
return Array.isArray(disabled) && disabled.includes(EXTENSION_NAME);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let shownKey = null;
|
||||
let shownAt = 0;
|
||||
|
||||
/**
|
||||
* Show one toast for one execution_error event, honouring the dedupe window.
|
||||
* Never throws: a broken toast must not take core error rendering with it.
|
||||
*/
|
||||
function showErrorToast(detail) {
|
||||
const summary = detail.exception_message || "Chatterbox node failed.";
|
||||
const nodeType = detail.node_type || "unknown node";
|
||||
const description =
|
||||
"Node: " + nodeType +
|
||||
(detail.exception_type ? " (" + detail.exception_type + ")" : "");
|
||||
|
||||
const now = Date.now();
|
||||
const key = nodeType + "\n" + summary;
|
||||
if (key === shownKey && now - shownAt < DEDUPE_WINDOW) {
|
||||
return; // the same fault is already on screen
|
||||
}
|
||||
shownKey = key;
|
||||
shownAt = now;
|
||||
|
||||
const toast = app.extensionManager?.toast;
|
||||
if (toast && typeof toast.error === "function") {
|
||||
// Current API: error(title, options).
|
||||
toast.error(summary, {
|
||||
description: description,
|
||||
duration: TOAST_DURATION,
|
||||
closable: true,
|
||||
});
|
||||
} else if (toast && typeof toast.add === "function") {
|
||||
// Deprecated API, still present in older frontends.
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: summary,
|
||||
detail: description,
|
||||
life: TOAST_DURATION,
|
||||
closable: true,
|
||||
});
|
||||
} else {
|
||||
console.error("[Chatterbox TTS] Node error:", summary, description);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDisabledByName()) {
|
||||
// Registered at module load, never in setup(): a lazily loaded extension
|
||||
// may never get setup() called, and a listener registered late silently
|
||||
// misses every event that fired before it.
|
||||
app.api.addEventListener("execution_error", (event) => {
|
||||
try {
|
||||
const detail = event && event.detail;
|
||||
if (!detail) return;
|
||||
if (!CHATTERBOX_NODE_TYPES.has(detail.node_type)) return; // not ours
|
||||
showErrorToast(detail);
|
||||
} catch (error) {
|
||||
console.error("[Chatterbox TTS] Node-error toast failed:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.registerExtension({ name: EXTENSION_NAME });
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "ComfyUI-ChatterboxTTS"
|
||||
description = "Chatterbox TTS and voice conversion nodes for ComfyUI"
|
||||
version = "2.0.0"
|
||||
description = "Super fast TTS and voice clonning tool"
|
||||
version = "2.0.3"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
@@ -37,6 +37,6 @@ Repository = "https://github.com/wildminder/ComfyUI-Chatterbox"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "wildai"
|
||||
DisplayName = "Chatterbox TTS Revised"
|
||||
DisplayName = "WM Chatterbox TTS Revised"
|
||||
Icon = ""
|
||||
requires-comfyui = ">=0.37.0"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""The frontend node filter has to name exactly the Python schema's nodes.
|
||||
|
||||
``js/extension.js`` listens for ``execution_error`` and toasts only the events
|
||||
whose ``node_type`` it recognises. That list is hand-maintained, so it can drift:
|
||||
a node renamed in ``nodes/`` leaves every Python test green -- the ids are pinned
|
||||
there, against the frozen snapshot -- and the browser simply stops toasting for
|
||||
that node. Nothing reports it. The graph keeps running, the node keeps failing,
|
||||
and the operator sees nothing.
|
||||
|
||||
So the ids are parsed back out of the file itself and compared against the live
|
||||
schema: the same registry ComfyUI reads the node list from. A match here is a
|
||||
fact, not a comment promising the two sides agree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import ROOT_DIR
|
||||
|
||||
from ComfyUI_Chatterbox.chatterbox_nodes import comfy_entrypoint
|
||||
|
||||
EXTENSION_JS = os.path.join(ROOT_DIR, "js", "extension.js")
|
||||
|
||||
#: The opening expression of the filter's declaration. Anchoring on the whole
|
||||
#: thing, not on the bare const name, is what keeps the slice off the comment
|
||||
#: above it -- that comment names this very test, and a name-only anchor would
|
||||
#: slice starting at the wrong place.
|
||||
SET_ANCHOR = 'CHATTERBOX_NODE_TYPES = new Set(['
|
||||
|
||||
|
||||
def frontend_node_types() -> list[str]:
|
||||
"""Every node id the frontend filters on, in the order the JS lists them.
|
||||
|
||||
The span is sliced between the anchor and its ``])`` rather than matched
|
||||
across the whole file: the extension names node ids in prose comments too,
|
||||
and a file-wide pattern would quietly compare the documentation against
|
||||
the schema instead of the code.
|
||||
"""
|
||||
with open(EXTENSION_JS, "r", encoding="utf-8") as handle:
|
||||
source = handle.read()
|
||||
|
||||
if SET_ANCHOR not in source:
|
||||
raise AssertionError(
|
||||
f"js/extension.js no longer declares {SET_ANCHOR!r}; the node "
|
||||
"filter this module compares against cannot be found"
|
||||
)
|
||||
|
||||
start = source.index(SET_ANCHOR) + len(SET_ANCHOR)
|
||||
span = source[start : source.index("])", start)]
|
||||
return re.findall(r'"([^"]*)"', span)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def schema_node_ids() -> set[str]:
|
||||
"""The ids the live Python schema exposes, straight from the registry."""
|
||||
extension = asyncio.run(comfy_entrypoint())
|
||||
return {
|
||||
node_cls.GET_SCHEMA().node_id
|
||||
for node_cls in asyncio.run(extension.get_node_list())
|
||||
}
|
||||
|
||||
|
||||
class TestFrontendNodeSet:
|
||||
def test_the_js_list_is_found_and_not_empty(self) -> None:
|
||||
"""A renamed anchor must fail loudly, not compare two empty sets."""
|
||||
frontend = frontend_node_types()
|
||||
assert frontend, (
|
||||
"no node ids were parsed out of js/extension.js, so the comparison "
|
||||
"below could not tell a drift from an unreadable file"
|
||||
)
|
||||
|
||||
def test_the_js_list_matches_the_live_schema(
|
||||
self, schema_node_ids: set[str]
|
||||
) -> None:
|
||||
"""Both directions: an id dropped from the JS and one invented in it.
|
||||
|
||||
The direction that matters most is the second. A JS id the schema does
|
||||
not define toasts for a node type no graph can ever produce, which is
|
||||
the sound of a node having been renamed on the Python side and the
|
||||
filter left behind -- and the first direction, the silent one this
|
||||
guard exists for.
|
||||
"""
|
||||
frontend = set(frontend_node_types())
|
||||
assert frontend == schema_node_ids
|
||||
assert schema_node_ids, (
|
||||
"the schema exposes no nodes at all; both sides of the comparison "
|
||||
"would be empty and nothing would be guarded"
|
||||
)
|
||||
+116
-8
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
@@ -101,16 +102,35 @@ class TestExports:
|
||||
assert "NODE_CLASS_MAPPINGS" not in referenced
|
||||
assert "NODE_DISPLAY_NAME_MAPPINGS" not in referenced
|
||||
|
||||
def test_no_web_directory(self, tree: ast.Module) -> None:
|
||||
"""The package ships no frontend assets.
|
||||
def test_web_directory_is_declared_and_shipped(self, tree: ast.Module) -> None:
|
||||
"""The web directory the host is told to serve is there and holds the JS.
|
||||
|
||||
VibeVoice deliberately declares none. Pointing ComfyUI at a directory
|
||||
that does not exist produces a startup warning on every boot and, worse,
|
||||
makes "we have a web extension" a claim nothing backs up.
|
||||
The guard this replaces was its mirror image: the package deliberately
|
||||
declared no ``WEB_DIRECTORY``, because pointing the host at a directory
|
||||
that does not exist warns on every boot and makes "we ship a frontend"
|
||||
a claim nothing backs up. Both halves still have to hold -- declared
|
||||
**and** present -- so the check stays, inverted. Resolving the way the
|
||||
host does, against this module's own directory rather than the process
|
||||
working directory, is what makes the ``isdir`` half a real assertion
|
||||
instead of a guess about where the server was started.
|
||||
|
||||
The attribute belongs to the module, not to the extension object;
|
||||
``tests/test_extension.py`` is what keeps the object clean of it.
|
||||
"""
|
||||
referenced = _referenced_names(tree)
|
||||
assert "WEB_DIRECTORY" not in referenced
|
||||
assert "web_directory" not in referenced
|
||||
assigned = _assigned_names(tree)
|
||||
assert "WEB_DIRECTORY" in assigned, (
|
||||
"the entry point declares no web directory, so js/extension.js is "
|
||||
"never served and no node error is ever toasted"
|
||||
)
|
||||
web_dir = ast.literal_eval(assigned["WEB_DIRECTORY"])
|
||||
assert web_dir == "./js"
|
||||
|
||||
resolved = os.path.abspath(os.path.join(os.path.dirname(INIT_PATH), web_dir))
|
||||
assert os.path.isdir(resolved), f"{web_dir} does not resolve to a directory: {resolved}"
|
||||
assert os.path.isfile(os.path.join(resolved, "extension.js")), (
|
||||
"the served directory has no extension.js, so the browser imports "
|
||||
"nothing and the toast listener never registers"
|
||||
)
|
||||
|
||||
|
||||
class TestPytestGuard:
|
||||
@@ -206,6 +226,94 @@ class TestBootstrapRunsForReal:
|
||||
assert "sys.path.insert" not in source
|
||||
|
||||
|
||||
class TestStartupIsQuiet:
|
||||
"""The bootstrap says nothing at INFO or above.
|
||||
|
||||
The pack used to log ``[Chatterbox TTS] Registering the Chatterbox custom
|
||||
node.`` on every boot. Other extensions chat just as loudly, so the host's
|
||||
own startup output was buried under a wall of "look at me" -- and the note
|
||||
told the operator nothing: a silent import succeeded exactly as well as a
|
||||
chatty one. What survives is one ``debug`` pack-count line, invisible at the
|
||||
default level.
|
||||
|
||||
The re-exec is the same one :class:`TestBootstrapRunsForReal` performs:
|
||||
pytest out of ``sys.modules`` so the guard opens, fake ``folder_paths`` /
|
||||
``server`` so nothing real is registered. ``caplog`` is attached to the root
|
||||
logger first, so the records this fixture collects are exactly the ones the
|
||||
bootstrap emitted while being imported -- and they are captured explicitly,
|
||||
because ``caplog`` resets its handler when the *call* phase begins, after
|
||||
this fixture has already run.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def loaded(self, monkeypatch, caplog):
|
||||
fake_folder_paths = SimpleNamespace(
|
||||
models_dir=os.path.join(ROOT_DIR, ".test-models"),
|
||||
folder_names_and_paths={},
|
||||
supported_pt_extensions={".safetensors"},
|
||||
get_folder_paths=lambda name: [],
|
||||
add_model_folder_path=lambda *a, **k: None,
|
||||
get_user_directory=lambda: os.path.join(ROOT_DIR, ".test-user"),
|
||||
)
|
||||
fake_server = SimpleNamespace(
|
||||
PromptServer=SimpleNamespace(instance=SimpleNamespace(routes=MagicMock()))
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
||||
monkeypatch.setitem(sys.modules, "server", fake_server)
|
||||
# The guard keys off pytest being importable; hide it for this exec only.
|
||||
monkeypatch.delitem(sys.modules, "pytest")
|
||||
# Before the exec, so the caller can recount what the bootstrap emitted.
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"{PKG_ALIAS}._entrypoint_probe_quiet",
|
||||
INIT_PATH,
|
||||
submodule_search_locations=[ROOT_DIR],
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, spec.name, module)
|
||||
path_before = list(sys.path)
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
captured = list(caplog.get_records("setup"))
|
||||
yield module, fake_folder_paths, fake_server, captured
|
||||
finally:
|
||||
for entry in list(sys.path):
|
||||
if entry not in path_before:
|
||||
sys.path.remove(entry)
|
||||
|
||||
def test_importing_the_entry_point_emits_no_info_or_above(self, loaded) -> None:
|
||||
"""A startup note is a regression; this is where it comes back to fail.
|
||||
|
||||
The silence has to be pinned by an assertion that can fail. The removed
|
||||
``logging.info`` call was one line in this module, and nothing else in
|
||||
the suite looks at what the bootstrap says on the console.
|
||||
"""
|
||||
_module, _folder_paths, _server, captured = loaded
|
||||
offenders = [
|
||||
f"{record.name}:{record.levelname}: {record.getMessage()}"
|
||||
for record in captured
|
||||
if record.levelno >= logging.INFO
|
||||
]
|
||||
assert offenders == [], (
|
||||
"the bootstrap still speaks at INFO or above; an install-time note "
|
||||
"is exactly what the quiet start removed:\n " + "\n ".join(offenders)
|
||||
)
|
||||
|
||||
def test_the_bootstrap_still_does_its_job(self, loaded) -> None:
|
||||
"""Silence is not the goal; a silent, working import is.
|
||||
|
||||
The quiet fixture replays the real bootstrap, so the registration it
|
||||
performs has to be as real as in :class:`TestBootstrapRunsForReal` --
|
||||
otherwise the silence above could be the sound of a bootstrap that broke
|
||||
early and logged nothing because it did nothing.
|
||||
"""
|
||||
_module, folder_paths_module, _server, _captured = loaded
|
||||
registered = folder_paths_module.folder_names_and_paths
|
||||
assert "tts" in registered
|
||||
assert "chatterbox" in registered
|
||||
|
||||
|
||||
class TestAbsentServerDegrades:
|
||||
"""A process with no ``PromptServer`` still has to import cleanly."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user