Updated Speech Length Calculator Node UI and added duration output to Load Audio UI node

Updated Speech Length Calculator Node UI and added duration output to Load Audio UI node
This commit is contained in:
WhatDreamsCost
2026-05-03 01:26:56 -05:00
parent d9845a7245
commit c719ce25d3
3 changed files with 227 additions and 76 deletions

View File

@@ -1,6 +1,164 @@
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.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 `
<div class="slc-empty">
<div class="slc-empty-title">Awaiting Script</div>
<div class="slc-empty-sub">Wrap spoken text inside "quotes"</div>
</div>
`;
}
return `
<div class="slc-headers">
<div class="slc-header-block">
<div class="slc-header-title">SPOKEN WORDS</div>
<div class="slc-header-value">${statsData.wordCount}</div>
</div>
<div class="slc-header-block">
<div class="slc-header-title">ADDED TIME</div>
<div class="slc-header-value">${statsData.additionalTime}s</div>
</div>
</div>
<div class="slc-cards">
<div class="slc-card slow">
<div class="slc-card-left">
<div class="slc-card-speed">SLOW</div>
<div class="slc-card-wpm">100 WPM</div>
</div>
<div class="slc-card-right">
<div class="slc-card-time">${statsData.slow.time}</div>
<div class="slc-card-frames">${statsData.slow.frames} frames</div>
</div>
</div>
<div class="slc-card avg">
<div class="slc-card-left">
<div class="slc-card-speed">AVG</div>
<div class="slc-card-wpm">130 WPM</div>
</div>
<div class="slc-card-right">
<div class="slc-card-time">${statsData.avg.time}</div>
<div class="slc-card-frames">${statsData.avg.frames} frames</div>
</div>
</div>
<div class="slc-card fast">
<div class="slc-card-left">
<div class="slc-card-speed">FAST</div>
<div class="slc-card-wpm">160 WPM</div>
</div>
<div class="slc-card-right">
<div class="slc-card-time">${statsData.fast.time}</div>
<div class="slc-card-frames">${statsData.fast.frames} frames</div>
</div>
</div>
</div>
<div class="slc-legend">WPM = Words Per Minute</div>
`;
}
app.registerExtension({ app.registerExtension({
name: "Comfy.SpeechLengthCalculator", name: "Comfy.SpeechLengthCalculator",
async beforeRegisterNodeDef(nodeType, nodeData, app) { async beforeRegisterNodeDef(nodeType, nodeData, app) {
@@ -10,74 +168,75 @@ app.registerExtension({
nodeType.prototype.onNodeCreated = function () { nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
// Add a read-only multiline widget to display the results natively in the node // 3. CREATE THE DOM WIDGET
const statsWidget = ComfyWidgets["STRING"](this, "Stats", ["STRING", { multiline: true }], app).widget; const uiContainer = document.createElement("div");
uiContainer.className = "slc-ui-container";
// Style the widget so it looks like a stats display and prevent editing uiContainer.innerHTML = buildUIHTML({ empty: true });
if (statsWidget.inputEl) {
statsWidget.inputEl.readOnly = true; // Attach to node: standard addDOMWidget works natively in both V1 and V3 Frontends
statsWidget.inputEl.style.opacity = "0.9"; let statsWidget;
statsWidget.inputEl.style.backgroundColor = "rgba(0,0,0,0.3)"; if (typeof this.addDOMWidget === "function") {
statsWidget.inputEl.style.fontFamily = "monospace"; statsWidget = this.addDOMWidget("Stats", "HTML", uiContainer, {
statsWidget.inputEl.style.pointerEvents = "none"; serialize: false,
statsWidget.inputEl.style.height = "150px"; // Increased to fit new line hideOnZoom: false
statsWidget.inputEl.style.minHeight = "150px"; });
statsWidget.inputEl.style.overflow = "hidden"; } 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() { 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(() => { requestAnimationFrame(() => {
const targetWidth = 400; const minWidth = 340;
const targetHeight = 400; 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); 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 = () => { 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)); const inputSlot = this.inputs && this.inputs.find(i => i.name === "text_input" || (i.name === "text" && i.link));
if (inputSlot && inputSlot.link) { if (inputSlot && inputSlot.link) {
const link = app.graph.links[inputSlot.link]; const link = app.graph.links[inputSlot.link];
if (link) { if (link) {
const sourceNode = app.graph.getNodeById(link.origin_id); const sourceNode = app.graph.getNodeById(link.origin_id);
if (sourceNode && sourceNode.widgets) { 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"); 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; if (w && typeof w.value === "string") return w.value;
} }
} }
} }
// Fallback to our internal text widget const textWidget = this.widgets && this.widgets.find(w => w.name === "text");
const textWidget = this.widgets.find(w => w.name === "text");
return textWidget ? (textWidget.value || "") : ""; return textWidget ? (textWidget.value || "") : "";
}; };
// Track the last state so we only recalculate when values actually change
this._lastState = { text: null, fps: null, addTime: null }; this._lastState = { text: null, fps: null, addTime: null };
const updateStats = () => { const updateStats = () => {
const fpsWidget = this.widgets.find(w => w.name === "fps"); const fpsWidget = this.widgets && this.widgets.find(w => w.name === "fps");
const additionalTimeWidget = this.widgets.find(w => w.name === "additional_time"); const additionalTimeWidget = this.widgets && this.widgets.find(w => w.name === "additional_time");
if (!fpsWidget || !statsWidget) return; if (!fpsWidget) return;
const text = this._getCurrentText(); const text = this._getCurrentText();
const fps = fpsWidget.value || 24; const fps = fpsWidget.value || 24;
const additionalTime = additionalTimeWidget ? parseFloat(additionalTimeWidget.value) || 0 : 0; 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 && if (this._lastState.text === text &&
this._lastState.fps === fps && this._lastState.fps === fps &&
this._lastState.addTime === additionalTime) { this._lastState.addTime === additionalTime) {
@@ -88,7 +247,6 @@ app.registerExtension({
this._lastState.fps = fps; this._lastState.fps = fps;
this._lastState.addTime = additionalTime; this._lastState.addTime = additionalTime;
// Regex to find words inside standard or smart quotes
const regex = /"([^"]*)"|'([^']*)'|“([^”]*)”|([^]*)/g; const regex = /"([^"]*)"|'([^']*)'|“([^”]*)”|([^]*)/g;
let match; let match;
let quotedText = ""; let quotedText = "";
@@ -96,71 +254,64 @@ app.registerExtension({
quotedText += (match[1] || match[2] || match[3] || match[4] || "") + " "; quotedText += (match[1] || match[2] || match[3] || match[4] || "") + " ";
} }
// Count words
const words = quotedText.trim().split(/\s+/).filter(w => w.length > 0); const words = quotedText.trim().split(/\s+/).filter(w => w.length > 0);
const wordCount = words.length; 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 formatTime = (wpm) => {
const baseMins = wordCount / wpm; const baseMins = wordCount / wpm;
const totalSecs = (baseMins * 60) + additionalTime; const totalSecs = (baseMins * 60) + additionalTime;
const mins = Math.floor(totalSecs / 60); 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 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 { return {
time: `${mins}m ${secsStr}s`, time: timeStr,
frames: frames.toString() frames: frames.toString()
}; };
}; };
const slow = formatTime(100); const statsData = {
const avg = formatTime(130); empty: (wordCount === 0 && additionalTime === 0),
const fast = formatTime(160); wordCount: wordCount,
additionalTime: additionalTime,
statsWidget.value = slow: formatTime(100),
`Spoken Words: ${wordCount} avg: formatTime(130),
Additional Time: ${additionalTime}s fast: formatTime(160)
WPM = Words Per Minute };
--------------------------------------------
Speech Speed Length Frames // 4. INJECT HTML TO THE DOM
Slow (100 WPM) ${slow.time.padEnd(10)} ${slow.frames} // Replaces the heavy canvas redrawing!
Avg (130 WPM) ${avg.time.padEnd(10)} ${avg.frames} uiContainer.innerHTML = buildUIHTML(statsData);
Fast (160 WPM) ${fast.time.padEnd(10)} ${fast.frames}`;
this.setDirtyCanvas(true, false);
}; };
// Hook into LiteGraph's drawing cycle to actively poll the upstream node // We still use onDrawBackground as an efficient silent trigger
// This guarantees the display updates if a connected node's text changes // to auto-update in case upstream nodes silently change their text
const onDrawBackground = this.onDrawBackground; const onDrawBackground = this.onDrawBackground;
this.onDrawBackground = function(ctx) { this.onDrawBackground = function(ctx) {
if (onDrawBackground) onDrawBackground.apply(this, arguments); if (onDrawBackground) onDrawBackground.apply(this, arguments);
updateStats(); updateStats();
}; };
// Bind initial events to update the stats in real-time natively // Bind events to update in real time based on node interactions
setTimeout(() => { setTimeout(() => {
const textWidget = this.widgets.find(w => w.name === "text"); const textWidget = this.widgets && this.widgets.find(w => w.name === "text");
const fpsWidget = this.widgets.find(w => w.name === "fps"); const fpsWidget = this.widgets && this.widgets.find(w => w.name === "fps");
const additionalTimeWidget = this.widgets.find(w => w.name === "additional_time"); const additionalTimeWidget = this.widgets && this.widgets.find(w => w.name === "additional_time");
// Update on text typing natively if (textWidget) {
if (textWidget && textWidget.inputEl) {
textWidget.inputEl.addEventListener("input", updateStats);
} else if (textWidget) {
const origCallback = textWidget.callback; const origCallback = textWidget.callback;
textWidget.callback = function() { textWidget.callback = function() {
if (origCallback) origCallback.apply(this, arguments); if (origCallback) origCallback.apply(this, arguments);
updateStats(); updateStats();
} }
} }
// Update on FPS changing
if (fpsWidget) { if (fpsWidget) {
const origFpsCallback = fpsWidget.callback; const origFpsCallback = fpsWidget.callback;
fpsWidget.callback = function() { fpsWidget.callback = function() {
@@ -168,8 +319,6 @@ Fast (160 WPM) ${fast.time.padEnd(10)} ${fast.frames}`;
updateStats(); updateStats();
} }
} }
// Update on Additional Time changing
if (additionalTimeWidget) { if (additionalTimeWidget) {
const origAddCallback = additionalTimeWidget.callback; const origAddCallback = additionalTimeWidget.callback;
additionalTimeWidget.callback = function() { additionalTimeWidget.callback = function() {
@@ -177,8 +326,6 @@ Fast (160 WPM) ${fast.time.padEnd(10)} ${fast.frames}`;
updateStats(); updateStats();
} }
} }
// Initial calculation
updateStats(); updateStats();
}, 100); }, 100);

View File

@@ -72,7 +72,8 @@ class LoadAudioUI:
} }
CATEGORY = "audio" CATEGORY = "audio"
RETURN_TYPES = ("AUDIO", ) RETURN_TYPES = ("AUDIO", "FLOAT")
RETURN_NAMES = ("audio", "duration")
FUNCTION = "load_audio" FUNCTION = "load_audio"
@classmethod @classmethod
@@ -134,4 +135,7 @@ class LoadAudioUI:
# Format for ComfyUI's standard AUDIO type: [batch, channels, time] # Format for ComfyUI's standard AUDIO type: [batch, channels, time]
audio_output = {"waveform": trimmed_waveform.unsqueeze(0), "sample_rate": sample_rate} audio_output = {"waveform": trimmed_waveform.unsqueeze(0), "sample_rate": sample_rate}
return (audio_output, ) # Calculate the final trimmed duration in seconds as a float
final_duration = float(trimmed_waveform.shape[1] / sample_rate)
return (audio_output, final_duration)

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "WhatDreamsCost-ComfyUI" name = "WhatDreamsCost-ComfyUI"
description = "A variety of custom ComfyUI nodes and workflows for creatives." description = "A variety of custom ComfyUI nodes and workflows for creatives."
version = "1.2.5" version = "1.2.6"
license = {file = "LICENSE"} license = {file = "LICENSE"}
# classifiers = [ # classifiers = [
# # For OS-independent nodes (works on all operating systems) # # For OS-independent nodes (works on all operating systems)