diff --git a/js/speech_length_calculator.js b/js/speech_length_calculator.js index 4ae44cd..93a51de 100644 --- a/js/speech_length_calculator.js +++ b/js/speech_length_calculator.js @@ -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 ` +
+
Awaiting Script
+
Wrap spoken text inside "quotes"
+
+ `; + } + + return ` +
+
+
SPOKEN WORDS
+
${statsData.wordCount}
+
+
+
ADDED TIME
+
${statsData.additionalTime}s
+
+
+
+
+
+
SLOW
+
100 WPM
+
+
+
${statsData.slow.time}
+
${statsData.slow.frames} frames
+
+
+
+
+
AVG
+
130 WPM
+
+
+
${statsData.avg.time}
+
${statsData.avg.frames} frames
+
+
+
+
+
FAST
+
160 WPM
+
+
+
${statsData.fast.time}
+
${statsData.fast.frames} frames
+
+
+
+
WPM = Words Per Minute
+ `; +} + 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); diff --git a/load_audio_ui.py b/load_audio_ui.py index ebebf5d..cd7fe8e 100644 --- a/load_audio_ui.py +++ b/load_audio_ui.py @@ -72,7 +72,8 @@ class LoadAudioUI: } CATEGORY = "audio" - RETURN_TYPES = ("AUDIO", ) + RETURN_TYPES = ("AUDIO", "FLOAT") + RETURN_NAMES = ("audio", "duration") FUNCTION = "load_audio" @classmethod @@ -134,4 +135,7 @@ class LoadAudioUI: # Format for ComfyUI's standard AUDIO type: [batch, channels, time] audio_output = {"waveform": trimmed_waveform.unsqueeze(0), "sample_rate": sample_rate} - return (audio_output, ) \ No newline at end of file + # Calculate the final trimmed duration in seconds as a float + final_duration = float(trimmed_waveform.shape[1] / sample_rate) + + return (audio_output, final_duration) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index cceabab..49bc272 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.2.5" +version = "1.2.6" license = {file = "LICENSE"} # classifiers = [ # # For OS-independent nodes (works on all operating systems)