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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user