Added new node "Speech Length Calculator"
This commit is contained in:
@@ -1,19 +1,22 @@
|
|||||||
from .ltx_keyframer import LTXKeyframer
|
from .ltx_keyframer import LTXKeyframer
|
||||||
from .multi_image_loader import MultiImageLoader
|
from .multi_image_loader import MultiImageLoader
|
||||||
from .ltx_sequencer import LTXSequencer
|
from .ltx_sequencer import LTXSequencer
|
||||||
|
from .speech_length_calculator import SpeechLengthCalculator
|
||||||
|
|
||||||
# Register the node classes
|
# Register the node classes
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
"LTXKeyframer": LTXKeyframer,
|
"LTXKeyframer": LTXKeyframer,
|
||||||
"MultiImageLoader": MultiImageLoader,
|
"MultiImageLoader": MultiImageLoader,
|
||||||
"LTXSequencer": LTXSequencer
|
"LTXSequencer": LTXSequencer,
|
||||||
|
"SpeechLengthCalculator": SpeechLengthCalculator
|
||||||
}
|
}
|
||||||
|
|
||||||
# Provide clean display names for the ComfyUI interface
|
# Provide clean display names for the ComfyUI interface
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
"LTXKeyframer": "LTX Keyframer",
|
"LTXKeyframer": "LTX Keyframer",
|
||||||
"MultiImageLoader": "Multi Image Loader",
|
"MultiImageLoader": "Multi Image Loader",
|
||||||
"LTXSequencer": "LTX Sequencer"
|
"LTXSequencer": "LTX Sequencer",
|
||||||
|
"SpeechLengthCalculator": "Speech Length Calculator"
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./js"
|
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]
|
[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.1.0"
|
version = "1.2.0"
|
||||||
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)
|
||||||
|
|||||||
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