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 { 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({
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);