Big audit + fix release driven by two full-codebase Gemini Pro reviews and first-party verification against the live ElevenLabs API spec. Security - Scrub API keys from every raised error (URL params, JSON detail, conn errors). Defense against leaking into ComfyUI logs / workflow PNGs. - AccountInfo redacts PII (email, name, billing IDs) by default and now uses /v1/user/subscription (no PII). - Download size cap (500MB default) — was unbounded BytesIO. - Multipart file pointers reset between retries (was sending empty body on attempt 2). - SaveAudio sanitizes filename + subdir against ../ traversal. Fixed — Credit-burning bugs - IS_CHANGED=NaN removed from paid nodes; replaced with InputCacheMixin that hashes (inputs minus api_key) so identical re-queues don't re-bill. - VoiceClone / VoiceCreate require explicit create=True (was uploading on every queue, burning voice slots). Fixed — API contract bugs (silent failures vs current spec) - Music: uses music_length_ms (was duration_seconds), 600s max (was 300), exposes composition_plan, respect_sections_durations, store_for_inpainting, sign_with_c2pa, model_id=music_v1. - SFX: model_id=eleven_text_to_sound_v2 (was eleven_sfx_v2 — invalid). - TTS: forces apply_text_normalization=off for eleven_v3 (only valid value per spec); raises early if turbo/flash_v2_5 lacks language_code. - STT: actually sends timestamps_granularity=none when chosen. - TTS: malformed pronunciation_dictionary_locators raises instead of silently dropping. Fixed — Audio I/O - PCM decode pads odd byte count so truncated streams don't ValueError. - audio_to_comfy returns 1s silence on empty body (was crashing). - Fallback silence is 1s @ 44.1kHz, not [1,1,1] (downstream STFT was asserting window_size). - comfy_audio_to_bytes accepts 1-D / 2-D / 3-D waveforms. - NamedTemporaryFile replaced with BytesIO (Windows handle leak + reopen). - parse_error_response sniffs Content-Type, returns <binary response> for non-text bodies (was UnicodeDecodeError on binary 500s). Fixed — Retry hardening - Retry-After honors HTTP-date format (RFC 7231). - Exponential backoff clamped to 60s + ±25% jitter. - Voice cache bounded LRU (32 entries) — was unbounded growth. - Pagination hard-cap 100 pages (belt-and-braces). Added — 11 new utility nodes GetVoiceByName, VoiceTagInserter, SubtitleExport (SRT/VTT), AudioConcat (resample + broadcast), AudioNormalize (peak/RMS dBFS), AudioMetadata, AudioChannels (mono/stereo/strip), AudioTrim, CostEstimator, VoiceSettingsPreset, SaveAudio (sanitized). Added — Quality-of-life - enable_logging toggle (zero-retention mode) on TTS/TTSTimestamps/ STS/Dialogue. - FetchVoices: gender filter + count output. - VoiceDesign: returns all 3 previews (was throwing away the 2 you paid for); preview_index selector. - Dialogue: validates total text length, explicit model dropdown, defaults normalization=off (required for v3). - STT: adds scribe_v1. - SFX: live duration validation (0.5-30s). - previous_text/next_text tooltips clarified (context, not output). Tests: 101/101 passing (was 0). All HTTP mocked — no real API calls. - test_audio_conversions.py (13) — AUDIO dict shape, PCM round-trip - test_errors_scrubbing.py (15) — secret-leakage regression, parser - test_retry.py (18) — backoff, file-pointer reset, size cap - test_pagination.py (7) — termination + LRU bound - test_nodes.py (48) — node validation, credit safety, utility nodes Node count: 15 -> 26. Audit notes in CHANGELOG.md.
90 lines
3.3 KiB
Python
90 lines
3.3 KiB
Python
"""Shared node utilities and mixins for ComfyUI nodes."""
|
|
|
|
import hashlib
|
|
import json
|
|
|
|
|
|
class AlwaysExecuteMixin:
|
|
"""Mixin that forces re-execution on every queue (no stale cache).
|
|
|
|
Use SPARINGLY — for paid API nodes, prefer InputCacheMixin so identical
|
|
inputs are cached and don't burn credits on every queue.
|
|
"""
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs):
|
|
return float("nan")
|
|
|
|
|
|
class InputCacheMixin:
|
|
"""Cache node execution by input hash so identical inputs don't re-run.
|
|
|
|
Critical for paid API nodes (TTS, STS, Music, SFX, STT, AudioIsolation,
|
|
VoiceClone, VoiceDesign): without this, ComfyUI re-executes on every
|
|
queue prompt, burning credits.
|
|
|
|
Audio tensors are not hashable by value cheaply — we hash shape+sample_rate
|
|
instead, which means re-running with a different tensor of the same shape
|
|
will still be re-cached. Acceptable trade-off for audio inputs.
|
|
"""
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs):
|
|
return _hash_inputs(kwargs)
|
|
|
|
|
|
def _hash_inputs(kwargs: dict) -> str:
|
|
"""Stable hash of kwargs, with audio/tensor inputs hashed by shape+rate."""
|
|
parts = []
|
|
for k in sorted(kwargs.keys()):
|
|
v = kwargs[k]
|
|
# Skip the api_key entirely from the hash — never include secrets
|
|
# in IS_CHANGED return values (they propagate to workflow PNG metadata).
|
|
if k == "api_key" or k.endswith("_key") or k.endswith("api_key"):
|
|
continue
|
|
if isinstance(v, dict) and "waveform" in v and "sample_rate" in v:
|
|
# AUDIO dict — hash shape + sample_rate + a few sample values
|
|
try:
|
|
wf = v["waveform"]
|
|
sr = v["sample_rate"]
|
|
# Tuple of shape + sr + first/last/mean value for content fingerprint
|
|
shape = tuple(wf.shape)
|
|
fp = (
|
|
shape, int(sr),
|
|
float(wf.flatten()[0].item()) if wf.numel() else 0.0,
|
|
float(wf.flatten()[-1].item()) if wf.numel() else 0.0,
|
|
float(wf.float().mean().item()) if wf.numel() else 0.0,
|
|
)
|
|
parts.append(f"{k}=audio:{fp}")
|
|
except Exception:
|
|
parts.append(f"{k}=audio:opaque")
|
|
elif hasattr(v, "shape") and hasattr(v, "numel"):
|
|
# Plain tensor
|
|
try:
|
|
fp = (tuple(v.shape), float(v.float().mean().item()) if v.numel() else 0.0)
|
|
parts.append(f"{k}=tensor:{fp}")
|
|
except Exception:
|
|
parts.append(f"{k}=tensor:opaque")
|
|
else:
|
|
try:
|
|
parts.append(f"{k}={json.dumps(v, sort_keys=True, default=str)}")
|
|
except Exception:
|
|
parts.append(f"{k}={type(v).__name__}")
|
|
raw = "|".join(parts)
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def redact_secrets(text: str, secrets: list) -> str:
|
|
"""Redact any of `secrets` (non-empty strings) from `text`.
|
|
|
|
Use before logging error bodies / printing — paranoid defense in case
|
|
a key ended up in a response body or exception message.
|
|
"""
|
|
if not text:
|
|
return text
|
|
out = text
|
|
for s in secrets:
|
|
if s and isinstance(s, str) and len(s) >= 8:
|
|
out = out.replace(s, "***REDACTED***")
|
|
return out
|