diff --git a/js/speech_length_calculator.js b/js/speech_length_calculator.js
index 4ae44cd..93a51de 100644
--- a/js/speech_length_calculator.js
+++ b/js/speech_length_calculator.js
@@ -1,6 +1,164 @@
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
+// 1. DEFINE CSS STYLES GLOBALLY ONCE
+// We use DOM elements instead of Canvas drawing so it works flawlessly in ComfyUI V3
+const cssStyles = `
+ .slc-ui-container {
+ width: 100%;
+ height: 100%;
+ min-height: 260px; /* Increased from 220px to prevent V1 cropping */
+ background-color: rgba(15, 15, 19, 0.7);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ padding: 12px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ font-family: sans-serif;
+ color: #ffffff;
+ overflow: hidden;
+ pointer-events: auto; /* allows text selection */
+ }
+ .slc-empty {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ }
+ .slc-empty-title { color: #aaaaaa; font-size: 14px; font-weight: 500; margin-bottom: 4px; }
+ .slc-empty-sub { color: #777777; font-size: 11px; }
+ .slc-headers {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 12px;
+ }
+ .slc-header-block {
+ flex: 1;
+ background-color: rgba(0, 0, 0, 0.4);
+ border-radius: 6px;
+ padding: 8px 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+ .slc-header-title { color: #888888; font-size: 9px; font-weight: bold; margin-bottom: 4px; }
+ .slc-header-value { color: #ffffff; font-size: 16px; font-weight: bold; }
+ .slc-cards {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .slc-card {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background-color: rgba(0, 0, 0, 0.25);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ border-radius: 4px;
+ padding: 8px 12px 8px 16px;
+ position: relative;
+ overflow: hidden;
+ }
+ .slc-card::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 4px;
+ }
+ .slc-card.slow::before { background-color: #93c5fd; }
+ .slc-card.avg::before { background-color: #86efac; }
+ .slc-card.fast::before { background-color: #fca5a5; }
+
+ .slc-card-left { display: flex; flex-direction: column; }
+ .slc-card-speed { font-size: 12px; font-weight: bold; margin-bottom: 2px; }
+ .slc-card.slow .slc-card-speed { color: #93c5fd; }
+ .slc-card.avg .slc-card-speed { color: #86efac; }
+ .slc-card.fast .slc-card-speed { color: #fca5a5; }
+ .slc-card-wpm { font-size: 10px; color: #aaaaaa; }
+
+ .slc-card-right { display: flex; flex-direction: column; align-items: flex-end; }
+ .slc-card-time { font-size: 13px; font-weight: bold; color: #ffffff; margin-bottom: 2px; }
+ .slc-card-frames { font-size: 11px; font-family: monospace; color: #888888; }
+
+ .slc-legend {
+ margin-top: auto;
+ padding-top: 8px;
+ color: #999999;
+ font-size: 10px;
+ font-weight: 500;
+ }
+`;
+
+// Inject styles to document head safely
+if (!document.getElementById("speech-length-calculator-styles")) {
+ const styleEl = document.createElement("style");
+ styleEl.id = "speech-length-calculator-styles";
+ styleEl.textContent = cssStyles;
+ document.head.appendChild(styleEl);
+}
+
+// 2. HELPER TO GENERATE THE HTML CONTENT
+function buildUIHTML(statsData) {
+ if (!statsData || statsData.empty) {
+ return `
+
+
Awaiting Script
+
Wrap spoken text inside "quotes"
+
+ `;
+ }
+
+ return `
+
+
+
+
+
+
${statsData.slow.time}
+
${statsData.slow.frames} frames
+
+
+
+
+
+
${statsData.avg.time}
+
${statsData.avg.frames} frames
+
+
+
+
+
+
${statsData.fast.time}
+
${statsData.fast.frames} frames
+
+
+
+ WPM = Words Per Minute
+ `;
+}
+
app.registerExtension({
name: "Comfy.SpeechLengthCalculator",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
@@ -10,74 +168,75 @@ app.registerExtension({
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
- // Add a read-only multiline widget to display the results natively in the node
- const statsWidget = ComfyWidgets["STRING"](this, "Stats", ["STRING", { multiline: true }], app).widget;
-
- // Style the widget so it looks like a stats display and prevent editing
- if (statsWidget.inputEl) {
- statsWidget.inputEl.readOnly = true;
- statsWidget.inputEl.style.opacity = "0.9";
- statsWidget.inputEl.style.backgroundColor = "rgba(0,0,0,0.3)";
- statsWidget.inputEl.style.fontFamily = "monospace";
- statsWidget.inputEl.style.pointerEvents = "none";
- statsWidget.inputEl.style.height = "150px"; // Increased to fit new line
- statsWidget.inputEl.style.minHeight = "150px";
- statsWidget.inputEl.style.overflow = "hidden";
+ // 3. CREATE THE DOM WIDGET
+ const uiContainer = document.createElement("div");
+ uiContainer.className = "slc-ui-container";
+ uiContainer.innerHTML = buildUIHTML({ empty: true });
+
+ // Attach to node: standard addDOMWidget works natively in both V1 and V3 Frontends
+ let statsWidget;
+ if (typeof this.addDOMWidget === "function") {
+ statsWidget = this.addDOMWidget("Stats", "HTML", uiContainer, {
+ serialize: false,
+ hideOnZoom: false
+ });
+ } else {
+ // Fallback for very old unpatched V1 installations
+ statsWidget = ComfyWidgets["STRING"](this, "Stats", ["STRING", { multiline: true }], app).widget;
+ if (statsWidget.inputEl) {
+ statsWidget.inputEl.style.display = "none";
+ if (statsWidget.inputEl.parentNode) {
+ statsWidget.inputEl.parentNode.appendChild(uiContainer);
+ }
+ }
}
- // Explicitly tell LiteGraph how much space this custom widget requires
+ // Tell LiteGraph exactly how much vertical space to hold for our UI
statsWidget.computeSize = function() {
- return [0, 155]; // 150px for the height + 5px padding
+ return [0, 280]; // Increased from 240 to prevent bottom cropping in V1
};
- // Force the node to recalculate its dimensions to perfectly fit all widgets.
+ // Ensure the node is wide enough on initial creation
requestAnimationFrame(() => {
- const targetWidth = 400;
- const targetHeight = 400;
+ const minWidth = 340;
+ if (this.size[0] < minWidth) this.size[0] = minWidth;
+
+ // Fine tune initial height to account for the larger computeSize and make text box taller
+ if (this.size[1] < 500) this.size[1] = 500;
- if (this.computeSize) {
- const minSize = this.computeSize([targetWidth, targetHeight]);
- this.size = [Math.max(targetWidth, minSize[0]), Math.max(targetHeight, minSize[1])];
- } else {
- this.size = [targetWidth, targetHeight];
- }
this.setDirtyCanvas(true, true);
});
- // Helper to fetch the current active text (from connected node OR internal widget)
+ // Fetch text from input link OR widget
this._getCurrentText = () => {
- // Check if text_input (or a converted text widget) is connected via link
const inputSlot = this.inputs && this.inputs.find(i => i.name === "text_input" || (i.name === "text" && i.link));
if (inputSlot && inputSlot.link) {
const link = app.graph.links[inputSlot.link];
if (link) {
const sourceNode = app.graph.getNodeById(link.origin_id);
if (sourceNode && sourceNode.widgets) {
- // Search for a text/value widget on the connected node
const w = sourceNode.widgets.find(w => w.name === "value" || w.name === "text" || w.name === "Text" || w.type === "customtext" || w.type === "STRING");
if (w && typeof w.value === "string") return w.value;
}
}
}
- // Fallback to our internal text widget
- const textWidget = this.widgets.find(w => w.name === "text");
+ const textWidget = this.widgets && this.widgets.find(w => w.name === "text");
return textWidget ? (textWidget.value || "") : "";
};
- // Track the last state so we only recalculate when values actually change
this._lastState = { text: null, fps: null, addTime: null };
const updateStats = () => {
- const fpsWidget = this.widgets.find(w => w.name === "fps");
- const additionalTimeWidget = this.widgets.find(w => w.name === "additional_time");
+ const fpsWidget = this.widgets && this.widgets.find(w => w.name === "fps");
+ const additionalTimeWidget = this.widgets && this.widgets.find(w => w.name === "additional_time");
- if (!fpsWidget || !statsWidget) return;
+ if (!fpsWidget) return;
const text = this._getCurrentText();
const fps = fpsWidget.value || 24;
const additionalTime = additionalTimeWidget ? parseFloat(additionalTimeWidget.value) || 0 : 0;
- // Prevent unnecessary recalculations if nothing has changed
+ // Skip expensive calculations if nothing changed
if (this._lastState.text === text &&
this._lastState.fps === fps &&
this._lastState.addTime === additionalTime) {
@@ -88,7 +247,6 @@ app.registerExtension({
this._lastState.fps = fps;
this._lastState.addTime = additionalTime;
- // Regex to find words inside standard or smart quotes
const regex = /"([^"]*)"|'([^']*)'|“([^”]*)”|‘([^’]*)’/g;
let match;
let quotedText = "";
@@ -96,71 +254,64 @@ app.registerExtension({
quotedText += (match[1] || match[2] || match[3] || match[4] || "") + " ";
}
- // Count words
const words = quotedText.trim().split(/\s+/).filter(w => w.length > 0);
const wordCount = words.length;
- if (wordCount === 0 && additionalTime === 0) {
- statsWidget.value = `Spoken Words: 0\nAdditional Time: 0s\nWPM = Words Per Minute\n(No text inside quotes found.)\n(Wrap spoken text in "quotes" to calculate)`;
- return;
- }
-
const formatTime = (wpm) => {
const baseMins = wordCount / wpm;
const totalSecs = (baseMins * 60) + additionalTime;
const mins = Math.floor(totalSecs / 60);
- const secs = Math.round(totalSecs % 60);
+ let secs = totalSecs % 60;
+ secs = Math.ceil(secs * 10) / 10;
const frames = Math.ceil(totalSecs * fps);
- const secsStr = secs.toString().padStart(2, '0');
+ const secsStr = secs.toFixed(1);
+ const timeStr = mins > 0 ? `${mins}m ${secsStr}s` : `${secsStr}s`;
+
return {
- time: `${mins}m ${secsStr}s`,
+ time: timeStr,
frames: frames.toString()
};
};
- const slow = formatTime(100);
- const avg = formatTime(130);
- const fast = formatTime(160);
-
- statsWidget.value =
-`Spoken Words: ${wordCount}
-Additional Time: ${additionalTime}s
-WPM = Words Per Minute
---------------------------------------------
-Speech Speed Length Frames
-Slow (100 WPM) ${slow.time.padEnd(10)} ${slow.frames}
-Avg (130 WPM) ${avg.time.padEnd(10)} ${avg.frames}
-Fast (160 WPM) ${fast.time.padEnd(10)} ${fast.frames}`;
+ const statsData = {
+ empty: (wordCount === 0 && additionalTime === 0),
+ wordCount: wordCount,
+ additionalTime: additionalTime,
+ slow: formatTime(100),
+ avg: formatTime(130),
+ fast: formatTime(160)
+ };
+
+ // 4. INJECT HTML TO THE DOM
+ // Replaces the heavy canvas redrawing!
+ uiContainer.innerHTML = buildUIHTML(statsData);
+
+ this.setDirtyCanvas(true, false);
};
- // Hook into LiteGraph's drawing cycle to actively poll the upstream node
- // This guarantees the display updates if a connected node's text changes
+ // We still use onDrawBackground as an efficient silent trigger
+ // to auto-update in case upstream nodes silently change their text
const onDrawBackground = this.onDrawBackground;
this.onDrawBackground = function(ctx) {
if (onDrawBackground) onDrawBackground.apply(this, arguments);
updateStats();
};
- // Bind initial events to update the stats in real-time natively
+ // Bind events to update in real time based on node interactions
setTimeout(() => {
- const textWidget = this.widgets.find(w => w.name === "text");
- const fpsWidget = this.widgets.find(w => w.name === "fps");
- const additionalTimeWidget = this.widgets.find(w => w.name === "additional_time");
+ const textWidget = this.widgets && this.widgets.find(w => w.name === "text");
+ const fpsWidget = this.widgets && this.widgets.find(w => w.name === "fps");
+ const additionalTimeWidget = this.widgets && this.widgets.find(w => w.name === "additional_time");
- // Update on text typing natively
- if (textWidget && textWidget.inputEl) {
- textWidget.inputEl.addEventListener("input", updateStats);
- } else if (textWidget) {
+ if (textWidget) {
const origCallback = textWidget.callback;
textWidget.callback = function() {
if (origCallback) origCallback.apply(this, arguments);
updateStats();
}
}
-
- // Update on FPS changing
if (fpsWidget) {
const origFpsCallback = fpsWidget.callback;
fpsWidget.callback = function() {
@@ -168,8 +319,6 @@ Fast (160 WPM) ${fast.time.padEnd(10)} ${fast.frames}`;
updateStats();
}
}
-
- // Update on Additional Time changing
if (additionalTimeWidget) {
const origAddCallback = additionalTimeWidget.callback;
additionalTimeWidget.callback = function() {
@@ -177,8 +326,6 @@ Fast (160 WPM) ${fast.time.padEnd(10)} ${fast.frames}`;
updateStats();
}
}
-
- // Initial calculation
updateStats();
}, 100);
diff --git a/load_audio_ui.py b/load_audio_ui.py
index ebebf5d..cd7fe8e 100644
--- a/load_audio_ui.py
+++ b/load_audio_ui.py
@@ -72,7 +72,8 @@ class LoadAudioUI:
}
CATEGORY = "audio"
- RETURN_TYPES = ("AUDIO", )
+ RETURN_TYPES = ("AUDIO", "FLOAT")
+ RETURN_NAMES = ("audio", "duration")
FUNCTION = "load_audio"
@classmethod
@@ -134,4 +135,7 @@ class LoadAudioUI:
# Format for ComfyUI's standard AUDIO type: [batch, channels, time]
audio_output = {"waveform": trimmed_waveform.unsqueeze(0), "sample_rate": sample_rate}
- return (audio_output, )
\ No newline at end of file
+ # Calculate the final trimmed duration in seconds as a float
+ final_duration = float(trimmed_waveform.shape[1] / sample_rate)
+
+ return (audio_output, final_duration)
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index cceabab..49bc272 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "WhatDreamsCost-ComfyUI"
description = "A variety of custom ComfyUI nodes and workflows for creatives."
-version = "1.2.5"
+version = "1.2.6"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)