686 lines
20 KiB
JavaScript
686 lines
20 KiB
JavaScript
/*
|
|
* Title: Extras widgets
|
|
* Author: AlekPet
|
|
* Github: https://github.com/AlekPet/ComfyUI_Custom_Nodes_AlekPet/tree/master/ExtrasNode
|
|
*/
|
|
|
|
import { api } from "../../../../scripts/api.js";
|
|
import { app } from "../../../../scripts/app.js";
|
|
import { $el } from "../../../../scripts/ui.js";
|
|
import { rgbToHex, isValidStyle } from "../../utils.js";
|
|
import { RecognationSpeechDialog } from "./extras_node_dialogs.js";
|
|
|
|
const idExt = "alekpet.ExtrasNode";
|
|
const CONVERTED_TYPE = "converted-widget";
|
|
|
|
/* ~~~ Speech & Recognition speech Widget ~~~ */
|
|
const spRecognition =
|
|
window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
const SpeechSynthesis = window.speechSynthesis;
|
|
|
|
const regExpFileName = /[^\p{L}\d]+/gu;
|
|
const maxLenAudioFileName = 50;
|
|
|
|
let speechRect;
|
|
let mediaRecorder = null;
|
|
let audioChunks = [];
|
|
|
|
if (spRecognition) {
|
|
speechRect = new spRecognition();
|
|
speechRect.elements = null;
|
|
speechRect.isRecognition = false;
|
|
speechRect.isRecognitionAbort = false;
|
|
speechRect.lastText = "";
|
|
|
|
speechRect.addEventListener("result", (event) => {
|
|
const results = event?.results[0][0]?.transcript;
|
|
if (results && speechRect?.elements?.length) {
|
|
const clear_text = speechRect.elements[0].querySelector(
|
|
".alekpet_extras_node_recognition_clear"
|
|
);
|
|
|
|
if (clear_text.checked) {
|
|
speechRect.elements[1].value = results;
|
|
} else {
|
|
speechRect.elements[1].value += " " + results;
|
|
}
|
|
}
|
|
});
|
|
|
|
const resetSpeechRecognition = () => {
|
|
if (speechRect?.elements?.length) {
|
|
const info = speechRect.elements[0].querySelector(
|
|
".alekpet_extras_node_info span"
|
|
);
|
|
const icon_rec = speechRect.elements[0].querySelector(
|
|
".alekpet_extras_node_recognition_icon"
|
|
);
|
|
|
|
speechRect.elements[0].querySelector(
|
|
".alekpet_extras_node_recognition_aborted"
|
|
).style.display = "none";
|
|
|
|
icon_rec.classList.remove("alekpet_extras_node_recognition_icon_active");
|
|
setTimeout(() => (info.textContent = ""), 1000);
|
|
}
|
|
|
|
setStylesAllElements(".alekpet_extras_node_recognition_icon", null, {
|
|
display: "inline-block",
|
|
});
|
|
|
|
if (mediaRecorder) {
|
|
speechRect.lastText = speechRect?.elements[1].value;
|
|
mediaRecorder.stop();
|
|
} else {
|
|
speechRect.lastText = "";
|
|
speechRect.isRecognitionAbort = false;
|
|
}
|
|
|
|
mediaRecorder = null;
|
|
audioChunks = [];
|
|
speechRect.isRecognition = false;
|
|
|
|
speechRect.elements = null;
|
|
};
|
|
|
|
speechRect.addEventListener("audiostart", (e) => {
|
|
speechRect.isRecognition = true;
|
|
});
|
|
|
|
speechRect.addEventListener("end", () => {
|
|
resetSpeechRecognition();
|
|
speechRect.stop();
|
|
});
|
|
|
|
speechRect.addEventListener("speechend", () => {
|
|
resetSpeechRecognition();
|
|
speechRect.stop();
|
|
});
|
|
|
|
speechRect.addEventListener("error", (event) => {
|
|
if (speechRect?.elements?.length) {
|
|
const info = speechRect.elements[0].querySelector(
|
|
".alekpet_extras_node_info span"
|
|
);
|
|
info.textContent = event.error;
|
|
}
|
|
resetSpeechRecognition();
|
|
console.log(">> Error recognition: " + event.error);
|
|
});
|
|
} else {
|
|
console.warn(
|
|
"Your browser does not support: SpeechRecognition.\nIf Firefox: go to about:config > media.webspeech.recognition.enable and media.webspeech.recognition.force_enable > set true"
|
|
);
|
|
}
|
|
|
|
if (!SpeechSynthesis) {
|
|
console.warn("Your browser does not support: speechSynthesis");
|
|
}
|
|
|
|
// Get position speech SpeechRecognition widget
|
|
function getPostition(ctx, w_width, y, n_height, wInput) {
|
|
const MARGIN = 10;
|
|
|
|
const rect = ctx.canvas.getBoundingClientRect();
|
|
const transform = new DOMMatrix()
|
|
.scaleSelf(rect.width / ctx.canvas.width, rect.height / ctx.canvas.height)
|
|
.multiplySelf(ctx.getTransform())
|
|
.translateSelf(MARGIN, MARGIN);
|
|
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d);
|
|
|
|
return {
|
|
transformOrigin: "0 0",
|
|
transform: scale,
|
|
transform: transform,
|
|
left: `${transform.a * w_width - 70 * scale.a + rect.left}px`,
|
|
top: `${(wInput.last_y - 15) * scale.d + scale.f + rect.top}px`,
|
|
maxWidth: `${w_width - MARGIN * 2}px`,
|
|
maxHeight: `${n_height - MARGIN * 2}px`,
|
|
zIndex: wInput?.inputEl?.style?.zIndex
|
|
? +wInput?.inputEl.style.zIndex + 1
|
|
: 20,
|
|
};
|
|
}
|
|
|
|
function getVoiceAndSettings() {
|
|
const voices = speechSynthesis.getVoices();
|
|
|
|
const { voice, volume, pitch, rate } =
|
|
RecognationSpeechDialog.getSettingsRecSpeechLS();
|
|
const voiceSelected = voices.filter((v) => v.name === voice);
|
|
|
|
return {
|
|
voice: voiceSelected.length ? voiceSelected[0] : null,
|
|
volume,
|
|
pitch,
|
|
rate,
|
|
};
|
|
}
|
|
|
|
// Function return utterance
|
|
function speakSynthesisUtterance(text, options = {}) {
|
|
const utterance = new SpeechSynthesisUtterance(text);
|
|
|
|
Object.assign(utterance, { ...options, ...getVoiceAndSettings() });
|
|
return utterance;
|
|
}
|
|
|
|
// Check premissions
|
|
async function checkPremissions(
|
|
device = { name: "microphone" },
|
|
update = null
|
|
) {
|
|
return navigator.permissions
|
|
.query(device)
|
|
.then((result) => {
|
|
const state = result.state;
|
|
if (state == "granted") {
|
|
return { device, state, status: true };
|
|
} else if (state == "prompt") {
|
|
return { device, state, status: false };
|
|
} else if (state == "denied") {
|
|
return { device, state, status: false };
|
|
}
|
|
result.onchange = update;
|
|
})
|
|
.catch((e) => ({ device, state: "error", status: false }));
|
|
}
|
|
|
|
// Set styles
|
|
const setStylesAllElements = (selector, exclude = null, styles = {}) => {
|
|
let elements = Array.from(document.querySelectorAll(selector));
|
|
if (exclude) elements = elements.filter((r) => r !== exclude);
|
|
elements = elements.map((r) => Object.assign(r.style, styles));
|
|
};
|
|
|
|
function SpeechWidget(node, inputName, inputData, widgetsText) {
|
|
const widget = {
|
|
type: "speak_and_recognation_type",
|
|
name: inputName,
|
|
value: inputData,
|
|
size: [22, 1],
|
|
options: { hideOnZoom: true },
|
|
text_element: widgetsText?.inputEl,
|
|
draw(ctx, node, widget_width, y, widget_height) {
|
|
const hidden = widgetsText?.element?.hidden;
|
|
|
|
widget.element.dataset.shouldHide = hidden ? "true" : "false";
|
|
const isInVisibleNodes =
|
|
widget.element.dataset.isInVisibleNodes === "true";
|
|
const isCollapsed = widget.element.dataset.collapsed === "true";
|
|
const actualHidden = hidden || !isInVisibleNodes || isCollapsed;
|
|
const wasHidden = widget.element.hidden;
|
|
widget.element.hidden = actualHidden;
|
|
widget.element.style.display = actualHidden ? "none" : "flex";
|
|
if (actualHidden && !wasHidden) {
|
|
widget.options.onHide?.(widget);
|
|
}
|
|
|
|
if (hidden) {
|
|
return;
|
|
}
|
|
|
|
Object.assign(
|
|
widget.element.style,
|
|
getPostition(ctx, widget_width, y, node.size[1], widgetsText)
|
|
);
|
|
},
|
|
computeSize(...args) {
|
|
return [22, 1];
|
|
},
|
|
async callback(v) {
|
|
if (widgetsText?.element?.hasAttribute("readonly")) return;
|
|
|
|
widget.value = v ?? inputData ?? [false, true];
|
|
|
|
const checkboxSave = widget.element.querySelector(
|
|
".alekpet_extras_node_recognition_save"
|
|
);
|
|
const checkboxClear = widget.element.querySelector(
|
|
".alekpet_extras_node_recognition_clear"
|
|
);
|
|
|
|
if (checkboxClear) checkboxClear.checked = widget.value[1] ?? false;
|
|
|
|
if (checkboxSave) {
|
|
const isCheckedSave = widget.value[0] ?? false;
|
|
|
|
if (isCheckedSave) {
|
|
const premission = await checkPremissions();
|
|
checkboxSave.checked =
|
|
premission?.status && isCheckedSave ? true : false;
|
|
} else {
|
|
checkboxSave.checked = isCheckedSave;
|
|
}
|
|
}
|
|
},
|
|
onRemove() {
|
|
widget.element?.remove();
|
|
},
|
|
};
|
|
|
|
const buttons = [];
|
|
|
|
if (speechRect && !widgetsText?.element?.hasAttribute("readonly")) {
|
|
buttons.push(
|
|
$el("div.alekpet_extras_node_recognition_icon_box", [
|
|
$el("span.alekpet_extras_node_recognition_aborted", {
|
|
title: "Speech recognition abort",
|
|
textContent: "✖",
|
|
style: { display: "none" },
|
|
onclick: async function (e) {
|
|
const info = widget.element.querySelector(
|
|
".alekpet_extras_node_info span"
|
|
);
|
|
|
|
if (speechRect.isRecognition) {
|
|
e.currentTarget.style.display = "none";
|
|
speechRect.isRecognitionAbort = true;
|
|
speechRect.abort();
|
|
info.textContent = "aborted";
|
|
this.classList.remove(
|
|
"alekpet_extras_node_recognition_icon_active"
|
|
);
|
|
setTimeout(() => (info.textContent = ""), 2000);
|
|
}
|
|
},
|
|
}),
|
|
$el("span.alekpet_extras_node_recognition_icon", {
|
|
title: "Speech recognition",
|
|
onclick: async function (e) {
|
|
const info = widget.element.querySelector(
|
|
".alekpet_extras_node_info span"
|
|
);
|
|
const abort = widget.element.querySelector(
|
|
".alekpet_extras_node_recognition_aborted"
|
|
);
|
|
const checkboxSave = widget.element.querySelector(
|
|
".alekpet_extras_node_recognition_save"
|
|
);
|
|
|
|
// Hide other recognitions buttons
|
|
setStylesAllElements(
|
|
".alekpet_extras_node_recognition_icon",
|
|
e.currentTarget,
|
|
{
|
|
display: "none",
|
|
}
|
|
);
|
|
|
|
// Recognition
|
|
if (speechRect.elements === null) {
|
|
// Record audio
|
|
if (checkboxSave.checked) {
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: true,
|
|
});
|
|
mediaRecorder = new MediaRecorder(stream);
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
audioChunks.push(event.data);
|
|
};
|
|
|
|
mediaRecorder.onstop = async () => {
|
|
if (speechRect.isRecognitionAbort) {
|
|
speechRect.lastText = "";
|
|
speechRect.isRecognitionAbort = false;
|
|
return;
|
|
}
|
|
|
|
const saveAsWindow = JSON.parse(
|
|
localStorage.getItem(
|
|
`${idExt}.SpeechAndRecognationSpeechSaveAs`,
|
|
false
|
|
)
|
|
);
|
|
|
|
// Filename
|
|
let nameFile = "recording.webm";
|
|
if (speechRect?.lastText?.length) {
|
|
nameFile = `${speechRect.lastText
|
|
.slice(0, maxLenAudioFileName)
|
|
.replaceAll(regExpFileName, "_")}.webm`;
|
|
}
|
|
|
|
// Get audio
|
|
const audioBlob = new Blob(audioChunks, {
|
|
type: "audio/webm",
|
|
});
|
|
|
|
if (!saveAsWindow) {
|
|
const body = new FormData();
|
|
body.append("image", audioBlob, nameFile);
|
|
body.append("overwrite", "true");
|
|
const resp = await api.fetchApi("/upload/image", {
|
|
method: "POST",
|
|
body,
|
|
});
|
|
|
|
if (resp.status !== 200) {
|
|
console.error("[ExtrasNode] Recording audio not saved!");
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
`[ExtrasNode] Recording audio "${nameFile}" saved successfully!`
|
|
);
|
|
} else {
|
|
const audioUrl = URL.createObjectURL(audioBlob);
|
|
const linkDown = document.createElement("a");
|
|
linkDown.href = audioUrl;
|
|
linkDown.download = nameFile;
|
|
linkDown.click();
|
|
}
|
|
|
|
speechRect.lastText = "";
|
|
};
|
|
}
|
|
// end - Record audio
|
|
|
|
speechRect.elements = [widget.element, widgetsText.inputEl];
|
|
info.textContent = "recognition";
|
|
this.classList.add("alekpet_extras_node_recognition_icon_active");
|
|
speechRect.start();
|
|
mediaRecorder && mediaRecorder.start();
|
|
abort.style.display = "inline-block";
|
|
} else {
|
|
speechRect.stop();
|
|
abort.style.display = "none";
|
|
info.textContent = "stoped";
|
|
this.classList.remove(
|
|
"alekpet_extras_node_recognition_icon_active"
|
|
);
|
|
setTimeout(() => (info.textContent = ""), 2000);
|
|
}
|
|
},
|
|
}),
|
|
$el(
|
|
"input.alekpet_extras_node_speech_recognition_checkbox.alekpet_extras_node_recognition_save",
|
|
{
|
|
type: "checkbox",
|
|
checked: widget.value[0] ?? false,
|
|
title: "Save in audio file after recognition",
|
|
onchange: async (e) => {
|
|
const premission = await checkPremissions();
|
|
let checkValue = !!e.target.checked;
|
|
|
|
if (!premission?.status && premission.state != "prompt") {
|
|
alert(
|
|
`Access to the device "${premission.device.name}" is denied!\nAllow access to the device!`
|
|
);
|
|
checkValue = false;
|
|
}
|
|
|
|
navigator.mediaDevices
|
|
.getUserMedia({ audio: true })
|
|
.then(() => widget?.callback([checkValue, widget.value[1]]))
|
|
.catch((e) => {
|
|
widget?.callback([false, widget.value[1]]);
|
|
alert(
|
|
`Access to the device "${premission.device.name}" is denied!\nAllow access to the device!`
|
|
);
|
|
});
|
|
},
|
|
}
|
|
),
|
|
$el(
|
|
"input.alekpet_extras_node_speech_recognition_checkbox.alekpet_extras_node_recognition_clear",
|
|
{
|
|
type: "checkbox",
|
|
checked: widget.value[1] ?? true,
|
|
title: "Clear text after recognition",
|
|
onchange: (e) => {
|
|
widget?.callback([widget.value[0], !!e.target.checked]);
|
|
},
|
|
}
|
|
),
|
|
])
|
|
);
|
|
}
|
|
|
|
if (SpeechSynthesis && speechSynthesis) {
|
|
function buttonsStyles(
|
|
speechesButtons,
|
|
action = "add",
|
|
className = "alekpet_extras_node_speech_icon_playing"
|
|
) {
|
|
speechesButtons?.forEach((speechButton) =>
|
|
speechButton?.classList[action](className)
|
|
);
|
|
|
|
const settingTestSpeech = document.querySelector(
|
|
".panel_settings_recognation_speech_voice_test"
|
|
);
|
|
if (settingTestSpeech) {
|
|
if (action === "add") {
|
|
settingTestSpeech.style.opacity = 0.7;
|
|
settingTestSpeech.style.color = "var(--error-text)";
|
|
settingTestSpeech.title = "Cancel test speech";
|
|
settingTestSpeech.textContent = "Cancel test";
|
|
} else {
|
|
settingTestSpeech.style.opacity = 1;
|
|
settingTestSpeech.title = "Run test speech";
|
|
settingTestSpeech.textContent = "Test speech";
|
|
settingTestSpeech.style.color = "limegreen";
|
|
}
|
|
}
|
|
}
|
|
|
|
buttons.push(
|
|
$el("span.alekpet_extras_node_speech_icon", {
|
|
onclick: function () {
|
|
try {
|
|
const info = widget.element.querySelector(
|
|
".alekpet_extras_node_info span"
|
|
);
|
|
|
|
const speechesButtons = Array.from(
|
|
document.querySelectorAll(".alekpet_extras_node_speech_icon")
|
|
);
|
|
|
|
// Already playing
|
|
if (speechSynthesis.speaking) {
|
|
SpeechSynthesis.cancel();
|
|
|
|
info.textContent = "canceled";
|
|
this.title = "Speak text";
|
|
|
|
buttonsStyles(speechesButtons, "remove");
|
|
setTimeout(() => (info.textContent = ""), 2000);
|
|
return;
|
|
}
|
|
|
|
// Start playing text
|
|
const text = widgetsText?.element?.value;
|
|
if (text.trim() !== "") {
|
|
const utterance = speakSynthesisUtterance(text, {
|
|
onend: (e) => {
|
|
this.title = "Speak text";
|
|
info.textContent = "";
|
|
|
|
buttonsStyles(speechesButtons, "remove");
|
|
},
|
|
});
|
|
|
|
if (utterance) {
|
|
this.title = "Cancel speech";
|
|
info.textContent = "saying now";
|
|
|
|
buttonsStyles(speechesButtons);
|
|
SpeechSynthesis.speak(utterance);
|
|
}
|
|
} else {
|
|
const utterance = speakSynthesisUtterance(
|
|
`${widgetsText?.name || "This"}}, field is empty!`,
|
|
{
|
|
onend: (e) => {
|
|
this.title = "Speak text";
|
|
info.textContent = "";
|
|
|
|
buttonsStyles(speechesButtons, "remove");
|
|
},
|
|
}
|
|
);
|
|
|
|
if (utterance) {
|
|
this.title = "Cancel speech";
|
|
info.textContent = "empty text!";
|
|
buttonsStyles(speechesButtons);
|
|
SpeechSynthesis.speak(utterance);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.log(err);
|
|
}
|
|
},
|
|
textContent: "🔊",
|
|
title: "Speak text",
|
|
})
|
|
);
|
|
}
|
|
|
|
widget.element = $el(
|
|
"div.alekpet_extras_node_speechrecognition_box",
|
|
{ hidden: true, style: { display: "none" } },
|
|
[
|
|
$el("div.alekpet_extras_node_speechrecognition_row", [...buttons]),
|
|
$el("div.alekpet_extras_node_info", [
|
|
$el("span", {
|
|
textContent: "",
|
|
style: { fontSize: "0.4em" },
|
|
}),
|
|
]),
|
|
]
|
|
);
|
|
|
|
const collapse = node.collapse;
|
|
node.collapse = function () {
|
|
collapse.apply(this, arguments);
|
|
if (this.flags?.collapsed) {
|
|
widget.element.hidden = true;
|
|
widget.element.style.display = "none";
|
|
}
|
|
widget.element.dataset.collapsed = this.flags?.collapsed ? "true" : "false";
|
|
};
|
|
|
|
const onRemovedOrig = node.onRemoved;
|
|
node.onRemoved = function () {
|
|
node?.widgets?.forEach((w) => {
|
|
if (w.type === "speak_and_recognation_type") {
|
|
w?.onRemove();
|
|
}
|
|
});
|
|
onRemovedOrig?.apply(node, arguments);
|
|
};
|
|
|
|
document.body.appendChild(widget.element);
|
|
|
|
return widget;
|
|
}
|
|
/* ~~~ end - Speech & Recognition speech Widget ~~~ */
|
|
|
|
/* ~~~ Color Widget ~~~ */
|
|
function makeColorWidget(node, inputName, inputData, widget) {
|
|
const color_hex = $el("input", {
|
|
type: "color",
|
|
value: inputData[1]?.default || "#00ff33",
|
|
oninput: () => widget.callback?.(color_hex.value),
|
|
});
|
|
|
|
const color_text = $el("div", {
|
|
title: "Click to copy color to clipboard",
|
|
style: {
|
|
textAlign: "center",
|
|
fontSize: "20px",
|
|
height: "20px",
|
|
fontWeight: "600",
|
|
lineHeight: 1.5,
|
|
background: "var(--comfy-menu-bg)",
|
|
border: "dotted 2px white",
|
|
fontFamily: "sans-serif",
|
|
letterSpacing: "0.5rem",
|
|
borderRadius: "8px",
|
|
textShadow: "0 0 4px #fff",
|
|
cursor: "pointer",
|
|
},
|
|
onclick: () => navigator.clipboard.writeText(color_hex.value),
|
|
});
|
|
|
|
const w_color_hex = node.addDOMWidget(inputName, "color_hex", color_hex, {
|
|
getValue() {
|
|
color_text.style.color = color_hex.value;
|
|
color_text.textContent = color_hex.value;
|
|
return color_hex.value;
|
|
},
|
|
setValue(v) {
|
|
widget.value = v;
|
|
color_hex.value = v;
|
|
},
|
|
});
|
|
|
|
widget.callback = (v) => {
|
|
let color = isValidStyle("color", v).result ? v : "#00ff33";
|
|
if (color.includes("#") && color.length === 4) {
|
|
const opt_color = new Option().style;
|
|
opt_color["color"] = color;
|
|
color = rgbToHex(opt_color["color"]);
|
|
}
|
|
|
|
color_hex.value = color;
|
|
widget.value = color;
|
|
};
|
|
|
|
const w_color_text = node.addDOMWidget(
|
|
inputName + "_box",
|
|
"color_hex_box",
|
|
color_text
|
|
);
|
|
|
|
w_color_hex.color_hex = color_hex;
|
|
|
|
widget.w_color_hex = w_color_hex;
|
|
widget.w_color_text = w_color_text;
|
|
|
|
return { widget };
|
|
}
|
|
/* ~~~ end - Color Widget ~~~ */
|
|
|
|
/* ~~~ Preview Image Size Widget ~~~ */
|
|
function createPreiviewSize(node, name, options) {
|
|
const { color } = options;
|
|
|
|
const res = $el("div", {
|
|
style: {
|
|
fontSize: "0.8rem",
|
|
color: color,
|
|
fontFamily: "monospace",
|
|
padding: 0,
|
|
margin: 0,
|
|
outline: 0,
|
|
},
|
|
});
|
|
|
|
const widget = node.addDOMWidget(name, "show_resolution", res, {
|
|
getValue() {
|
|
return res.innerHTML;
|
|
},
|
|
setValue(v) {
|
|
res.innerHTML = v;
|
|
},
|
|
});
|
|
|
|
widget.computeSize = () => [node.size[0], 40];
|
|
|
|
return widget;
|
|
}
|
|
/* ~~~ end - Preview Image Size Widget ~~~ */
|
|
|
|
export {
|
|
SpeechWidget,
|
|
makeColorWidget,
|
|
createPreiviewSize,
|
|
speechRect,
|
|
SpeechSynthesis,
|
|
speakSynthesisUtterance,
|
|
};
|