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) { if (nodeData.name === "SpeechLengthCalculator") { const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; // 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); } } } // Tell LiteGraph exactly how much vertical space to hold for our UI statsWidget.computeSize = function() { return [0, 280]; // Increased from 240 to prevent bottom cropping in V1 }; // Ensure the node is wide enough on initial creation requestAnimationFrame(() => { 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; this.setDirtyCanvas(true, true); }); // Fetch text from input link OR widget this._getCurrentText = () => { 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) { 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; } } } const textWidget = this.widgets && this.widgets.find(w => w.name === "text"); return textWidget ? (textWidget.value || "") : ""; }; this._lastState = { text: null, fps: null, addTime: null }; const updateStats = () => { 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) return; const text = this._getCurrentText(); const fps = fpsWidget.value || 24; const additionalTime = additionalTimeWidget ? parseFloat(additionalTimeWidget.value) || 0 : 0; // Skip expensive calculations if nothing 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; const regex = /"([^"]*)"|'([^']*)'|“([^”]*)”|‘([^’]*)’/g; let match; let quotedText = ""; while ((match = regex.exec(text)) !== null) { quotedText += (match[1] || match[2] || match[3] || match[4] || "") + " "; } const words = quotedText.trim().split(/\s+/).filter(w => w.length > 0); const wordCount = words.length; const formatTime = (wpm) => { const baseMins = wordCount / wpm; const totalSecs = (baseMins * 60) + additionalTime; const mins = Math.floor(totalSecs / 60); let secs = totalSecs % 60; secs = Math.ceil(secs * 10) / 10; const frames = Math.ceil(totalSecs * fps); const secsStr = secs.toFixed(1); const timeStr = mins > 0 ? `${mins}m ${secsStr}s` : `${secsStr}s`; return { time: timeStr, frames: frames.toString() }; }; 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); }; // 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 events to update in real time based on node interactions setTimeout(() => { 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"); if (textWidget) { const origCallback = textWidget.callback; textWidget.callback = function() { if (origCallback) origCallback.apply(this, arguments); updateStats(); } } if (fpsWidget) { const origFpsCallback = fpsWidget.callback; fpsWidget.callback = function() { if (origFpsCallback) origFpsCallback.apply(this, arguments); updateStats(); } } if (additionalTimeWidget) { const origAddCallback = additionalTimeWidget.callback; additionalTimeWidget.callback = function() { if (origAddCallback) origAddCallback.apply(this, arguments); updateStats(); } } updateStats(); }, 100); return r; }; } } });