Added new node "Speech Length Calculator"
This commit is contained in:
@@ -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"
|
||||
|
||||
189
js/speech_length_calculator.js
Normal file
189
js/speech_length_calculator.js
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
48
speech_length_calculator.py
Normal file
48
speech_length_calculator.py
Normal 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)
|
||||
Reference in New Issue
Block a user