diff --git a/__init__.py b/__init__.py index 092ed56..d2978aa 100644 --- a/__init__.py +++ b/__init__.py @@ -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" diff --git a/js/speech_length_calculator.js b/js/speech_length_calculator.js new file mode 100644 index 0000000..4102933 --- /dev/null +++ b/js/speech_length_calculator.js @@ -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; + }; + } + } +}); \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4213f5e..a29ef28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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) diff --git a/speech_length_calculator.py b/speech_length_calculator.py new file mode 100644 index 0000000..6ec7e4c --- /dev/null +++ b/speech_length_calculator.py @@ -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) \ No newline at end of file