Added new node "Speech Length Calculator"

This commit is contained in:
WhatDreamsCost
2026-03-25 15:24:45 -05:00
parent 7e39450ccd
commit 3725151cd2
4 changed files with 243 additions and 3 deletions

View File

@@ -1,19 +1,22 @@
from .ltx_keyframer import LTXKeyframer
from .multi_image_loader import MultiImageLoader
from .ltx_sequencer import LTXSequencer
from .speech_length_calculator import SpeechLengthCalculator
# Register the node classes
NODE_CLASS_MAPPINGS = {
"LTXKeyframer": LTXKeyframer,
"MultiImageLoader": MultiImageLoader,
"LTXSequencer": LTXSequencer
"LTXSequencer": LTXSequencer,
"SpeechLengthCalculator": SpeechLengthCalculator
}
# Provide clean display names for the ComfyUI interface
NODE_DISPLAY_NAME_MAPPINGS = {
"LTXKeyframer": "LTX Keyframer",
"MultiImageLoader": "Multi Image Loader",
"LTXSequencer": "LTX Sequencer"
"LTXSequencer": "LTX Sequencer",
"SpeechLengthCalculator": "Speech Length Calculator"
}
WEB_DIRECTORY = "./js"

View File

@@ -0,0 +1,189 @@
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
app.registerExtension({
name: "Comfy.SpeechLengthCalculator",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "SpeechLengthCalculator") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
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";
}
// Explicitly tell LiteGraph how much space this custom widget requires
statsWidget.computeSize = function() {
return [0, 155]; // 150px for the height + 5px padding
};
// Force the node to recalculate its dimensions to perfectly fit all widgets.
requestAnimationFrame(() => {
const targetWidth = 400;
const targetHeight = 400;
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)
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");
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");
if (!fpsWidget || !statsWidget) 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
if (this._lastState.text === text &&
this._lastState.fps === fps &&
this._lastState.addTime === additionalTime) {
return;
}
this._lastState.text = text;
this._lastState.fps = fps;
this._lastState.addTime = additionalTime;
// Regex to find words inside standard or smart quotes
const regex = /"([^"]*)"|'([^']*)'|“([^”]*)”|([^]*)/g;
let match;
let quotedText = "";
while ((match = regex.exec(text)) !== null) {
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);
const frames = Math.ceil(totalSecs * fps);
const secsStr = secs.toString().padStart(2, '0');
return {
time: `${mins}m ${secsStr}s`,
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 Time 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}`;
};
// Hook into LiteGraph's drawing cycle to actively poll the upstream node
// This guarantees the display updates if a connected node's text changes
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
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");
// Update on text typing natively
if (textWidget && textWidget.inputEl) {
textWidget.inputEl.addEventListener("input", updateStats);
} else 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() {
if (origFpsCallback) origFpsCallback.apply(this, arguments);
updateStats();
}
}
// Update on Additional Time changing
if (additionalTimeWidget) {
const origAddCallback = additionalTimeWidget.callback;
additionalTimeWidget.callback = function() {
if (origAddCallback) origAddCallback.apply(this, arguments);
updateStats();
}
}
// Initial calculation
updateStats();
}, 100);
return r;
};
}
}
});

View File

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

View File

@@ -0,0 +1,48 @@
import re
import math
class SpeechLengthCalculator:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {"multiline": True, "default": 'Enter your script here. "Make sure to put spoken words inside quotes!"'}),
"fps": ("INT", {"default": 24, "min": 1, "max": 120, "step": 1}),
"additional_time": ("FLOAT", {"default": 0.0, "min": 0.0, "step": 0.1}),
},
"optional": {
"text_input": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("INT", "INT", "INT")
RETURN_NAMES = ("slow_frame_count", "average_frame_count", "fast_frame_count")
FUNCTION = "calculate_speech"
CATEGORY = "text/speech"
def calculate_speech(self, text, fps, additional_time=0.0, text_input=None):
# Prioritize the connected text_input if provided, otherwise fallback to the text widget
active_text = text_input if (text_input is not None and isinstance(text_input, str) and text_input.strip() != "") else text
# Regex to find words inside double quotes, single quotes, or smart quotes
matches = re.findall(r'"([^"]*)"|\'([^\']*)\'|“([^”]*)”|([^]*)', active_text)
# Extract matches, handling all possible captured groups from the regex
quoted_text = " ".join([next((g for g in m if g), "") for m in matches])
# Split by whitespace to get words and count them
words = quoted_text.split()
word_count = len(words)
def calc_frames(wpm):
if word_count == 0 and additional_time == 0:
return 0
minutes = word_count / wpm
seconds = (minutes * 60) + additional_time
return math.ceil(seconds * fps)
slow_frames = calc_frames(100)
avg_frames = calc_frames(130)
fast_frames = calc_frames(160)
return (slow_frames, avg_frames, fast_frames)