Files
ruminar-ComfyUI-PromptRando…/web/runtime_prompt_random_choice.js
るみなー先生 08b86f3683 feat: update project version and description; add runtime state management and server routes
- Bump version to 0.6.0 and enhance project description in pyproject.toml.
- Introduce runtime state management with RuntimeCandidateState and RuntimeCandidateStateStore classes to handle candidate updates and state retrieval.
- Implement server routes for updating and reading runtime candidate states, handling various request scenarios and errors.
- Add tests for runtime layout and prompt random choice functionality, ensuring robust validation and state management.
- Enhance frontend with runtime candidate editor, including UI updates and synchronization with server state.
2026-08-19 20:39:39 +09:00

442 lines
15 KiB
JavaScript

import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const TARGETS = new Map([
["RuntimePromptRandomChoice", "flat"],
["RuntimePromptRandomChoiceEx", "ex"],
]);
const LIVE_ROUTE = "/prompt_random_choice/runtime_candidate_state";
const DEBOUNCE_MS = 120;
const MAX_TEXT_BYTES = 512 * 1024;
const LIVE_BACKGROUND_TINT = "rgba(255, 210, 70, 0.1)";
function widgetByName(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function hideSavedWidget(widget) {
if (!widget) return;
if (!widget.__promptRandomChoiceHidden) {
widget.__promptRandomChoiceHidden = true;
widget.type = "hidden";
widget.hidden = true;
widget.disabled = true;
widget.serialize = true;
widget.options = { ...(widget.options ?? {}), hidden: true };
widget.computeSize = () => [0, -4];
widget.draw = () => {};
}
const elements = new Set([
widget.element,
widget.inputEl,
widget.domWidget?.element,
widget.options?.element,
]);
for (const element of elements) {
if (!(element instanceof HTMLElement)) continue;
element.hidden = true;
element.setAttribute("aria-hidden", "true");
element.style.setProperty("display", "none", "important");
element.style.setProperty("visibility", "hidden", "important");
element.style.setProperty("pointer-events", "none", "important");
}
}
function randomId(prefix) {
if (globalThis.crypto?.randomUUID) {
return `${prefix}-${globalThis.crypto.randomUUID()}`;
}
return `${prefix}-${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2)}`;
}
function nodeClass(node) {
return node.comfyClass ?? node.type;
}
function nodeKind(node) {
return TARGETS.get(nodeClass(node));
}
function ensureUniqueStateKey(node) {
const keyWidget = widgetByName(node, "state_key");
if (!keyWidget) return "";
let key = typeof keyWidget.value === "string" ? keyWidget.value.trim() : "";
const duplicate = (app.graph?._nodes ?? []).some((other) => {
const otherKey = widgetByName(other, "state_key")?.value;
return (
other !== node &&
TARGETS.has(nodeClass(other)) &&
typeof otherKey === "string" &&
otherKey.trim() === key
);
});
if (!key || duplicate) {
key = randomId(`prc-${nodeKind(node) ?? "runtime"}`);
keyWidget.value = key;
node.graph?.change?.();
}
return key;
}
function characterCount(value) {
return Array.from(value).length;
}
function utf8ByteCount(value) {
return new TextEncoder().encode(value).length;
}
function markWorkflowChanged(node) {
node.graph?.change?.();
node.setDirtyCanvas?.(true, false);
}
function setStatus(node, status, detail = "") {
const ui = node.__promptRandomChoiceRuntimeUI;
if (!ui) return;
ui.status.textContent = detail ? `${status} · ${detail}` : status;
ui.status.dataset.state = status;
ui.status.style.color =
status === "HARD ERROR" || status === "SYNC ERROR"
? "rgba(255,155,135,0.98)"
: status === "LIVE"
? "rgba(165,255,195,0.95)"
: status === "SYNCING"
? "rgba(255,220,135,0.95)"
: "rgba(215,215,225,0.9)";
}
function setStateStatus(node, state) {
const status = state?.input_status;
if (status === "hard_error") {
setStatus(node, "HARD ERROR", state?.error ?? "unsupported syntax");
} else if (status === "incomplete") {
const revision = Number(state?.revision ?? 0);
setStatus(
node,
"EDITING / SYNTAX INCOMPLETE",
revision > 0
? `using revision ${revision}`
: "no accepted revision yet",
);
} else {
const revision = Number(state?.revision ?? 0);
setStatus(node, "LIVE", `revision ${revision}`);
}
}
function renderCount(node, value) {
const ui = node.__promptRandomChoiceRuntimeUI;
if (!ui) return;
const bytes = utf8ByteCount(value);
ui.count.textContent =
bytes > MAX_TEXT_BYTES
? `${characterCount(value)} characters · ${bytes} / ${MAX_TEXT_BYTES} bytes`
: `${characterCount(value)} characters`;
ui.count.style.color =
bytes > MAX_TEXT_BYTES
? "rgba(255,155,135,0.98)"
: "rgba(210,210,220,0.72)";
}
function setLocalText(node, value, { sync = false, dirty = false } = {}) {
const text = typeof value === "string" ? value : String(value ?? "");
const optionsWidget = widgetByName(node, "options_text");
const ui = node.__promptRandomChoiceRuntimeUI;
const changed = Boolean(optionsWidget && optionsWidget.value !== text);
if (changed) optionsWidget.value = text;
if (ui && ui.textarea.value !== text) ui.textarea.value = text;
renderCount(node, text);
if (dirty && changed) markWorkflowChanged(node);
if (sync) scheduleSync(node, text);
return text;
}
function scheduleSync(node, value, immediate = false) {
const sync = node.__promptRandomChoiceRuntimeSync;
if (!sync) return;
sync.desiredText = value;
clearTimeout(sync.timer);
if (!sync.inFlight && sync.acknowledgedText === value) return;
setStatus(node, "EDITING");
sync.timer = setTimeout(
() => void flushSync(node),
immediate ? 0 : DEBOUNCE_MS,
);
}
async function responseError(response) {
try {
const payload = await response.json();
return payload?.error ?? `${response.status} ${response.statusText}`;
} catch {
return `${response.status} ${response.statusText}`;
}
}
async function flushSync(node) {
const sync = node.__promptRandomChoiceRuntimeSync;
if (!sync || sync.composing || sync.inFlight) return;
clearTimeout(sync.timer);
sync.timer = null;
sync.inFlight = true;
try {
while (!sync.composing && sync.acknowledgedText !== sync.desiredText) {
const sentText = sync.desiredText;
if (utf8ByteCount(sentText) > MAX_TEXT_BYTES) {
throw new Error(`Text exceeds the ${MAX_TEXT_BYTES}-byte server limit`);
}
const sequence = ++sync.nextSequence;
setStatus(node, "SYNCING");
const response = await api.fetchApi(LIVE_ROUTE, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
state_key: ensureUniqueStateKey(node),
node_kind: nodeKind(node),
text: sentText,
client_id: sync.clientId,
client_sequence: sequence,
}),
});
if (!response.ok) throw new Error(await responseError(response));
const payload = await response.json();
sync.acknowledgedText = sentText;
sync.revision = payload?.state?.revision ?? sync.revision;
if (sync.desiredText === sentText) setStateStatus(node, payload?.state);
}
} catch (error) {
console.error("[PromptRandomChoice] Runtime sync failed", error);
setStatus(node, "SYNC ERROR", String(error?.message ?? error));
} finally {
sync.inFlight = false;
}
}
function buildUI(node) {
const optionsWidget = widgetByName(node, "options_text");
const keyWidget = widgetByName(node, "state_key");
if (!optionsWidget || !keyWidget) return;
hideSavedWidget(optionsWidget);
hideSavedWidget(keyWidget);
if (node.__promptRandomChoiceRuntimeUI) return;
const root = document.createElement("div");
Object.assign(root.style, {
display: "flex",
flexDirection: "column",
gap: "5px",
width: "100%",
height: "100%",
boxSizing: "border-box",
padding: "2px 0",
});
const textarea = document.createElement("textarea");
textarea.spellcheck = false;
textarea.placeholder = "Candidates used when this Runtime node executes";
Object.assign(textarea.style, {
width: "100%",
height: "100%",
minHeight: "64px",
flex: "1 1 auto",
resize: "none",
boxSizing: "border-box",
padding: "8px",
border: "1px solid rgba(255,210,70,0.35)",
borderRadius: "4px",
background: "rgba(60,45,0,0.18)",
color: "inherit",
fontFamily: "monospace",
fontSize: "12px",
lineHeight: "1.35",
});
const footer = document.createElement("div");
Object.assign(footer.style, {
display: "flex",
flex: "0 0 auto",
justifyContent: "space-between",
gap: "8px",
padding: "0 2px",
fontSize: "10px",
});
const status = document.createElement("span");
const count = document.createElement("span");
count.style.marginLeft = "auto";
footer.append(status, count);
root.append(textarea, footer);
const domWidget = node.addDOMWidget(
"runtime_candidate_editor",
"prompt-random-choice-runtime-editor",
root,
{
serialize: false,
hideOnZoom: false,
getMinHeight: () => 96,
getHeight: () => "100%",
afterResize: () => {
root.style.height = "100%";
textarea.style.height = "100%";
},
getValue: () => textarea.value,
setValue: (value) =>
setLocalText(node, value, { sync: true, dirty: true }),
},
);
node.__promptRandomChoiceRuntimeUI = { root, textarea, status, count, domWidget };
node.__promptRandomChoiceRuntimeSync = {
clientId: randomId("client"),
nextSequence: 0,
desiredText:
typeof optionsWidget.value === "string" ? optionsWidget.value : "",
acknowledgedText: null,
revision: null,
composing: false,
inFlight: false,
timer: null,
editVersion: 0,
};
const absorb = (event) => event.stopPropagation();
for (const eventName of [
"pointerdown",
"mousedown",
"mouseup",
"click",
"dblclick",
"keyup",
]) {
textarea.addEventListener(eventName, absorb);
}
textarea.addEventListener("keydown", (event) => {
const save =
(event.ctrlKey || event.metaKey) &&
!event.altKey &&
event.key.toLowerCase() === "s";
if (!save) {
event.stopPropagation();
return;
}
event.preventDefault();
event.stopPropagation();
scheduleSync(node, textarea.value, true);
const commandId = event.shiftKey
? "Comfy.SaveWorkflowAs"
: "Comfy.SaveWorkflow";
app.extensionManager?.command?.execute?.(commandId, {
errorHandler: (error) =>
console.error(`[PromptRandomChoice] ${commandId} failed`, error),
});
});
textarea.addEventListener("compositionstart", () => {
node.__promptRandomChoiceRuntimeSync.composing = true;
setStatus(node, "EDITING");
});
textarea.addEventListener("compositionend", () => {
const sync = node.__promptRandomChoiceRuntimeSync;
sync.composing = false;
sync.editVersion += 1;
const value = setLocalText(node, textarea.value, { dirty: true });
scheduleSync(node, value, true);
});
textarea.addEventListener("input", () => {
const sync = node.__promptRandomChoiceRuntimeSync;
sync.editVersion += 1;
const value = setLocalText(node, textarea.value, { dirty: true });
if (sync.composing) sync.desiredText = value;
else scheduleSync(node, value);
});
textarea.addEventListener("blur", () => {
if (!node.__promptRandomChoiceRuntimeSync.composing) {
scheduleSync(node, textarea.value, true);
}
});
setLocalText(node, optionsWidget.value);
setStatus(node, "SYNCING", "checking server state");
}
async function initialiseFromServer(node) {
buildUI(node);
if (!node.__promptRandomChoiceRuntimeUI) return;
if (node.__promptRandomChoiceRuntimeInitialising) {
return node.__promptRandomChoiceRuntimeInitialising;
}
const operation = (async () => {
const key = ensureUniqueStateKey(node);
const sync = node.__promptRandomChoiceRuntimeSync;
const editVersion = sync.editVersion;
const response = await api.fetchApi(`${LIVE_ROUTE}/${encodeURIComponent(key)}`);
if (response.status === 404) {
sync.desiredText = setLocalText(
node,
widgetByName(node, "options_text")?.value ?? "",
);
sync.acknowledgedText = null;
await flushSync(node);
return;
}
if (!response.ok) throw new Error(await responseError(response));
const payload = await response.json();
const state = payload?.state;
if (typeof state?.input_text !== "string") {
throw new Error("Server returned an invalid Runtime candidate state");
}
sync.revision = state.revision ?? null;
sync.acknowledgedText = state.input_text;
if (sync.editVersion !== editVersion) {
await flushSync(node);
return;
}
sync.desiredText = state.input_text;
setLocalText(node, state.input_text, { dirty: true });
setStateStatus(node, state);
})()
.catch((error) => {
console.error("[PromptRandomChoice] Runtime initialisation failed", error);
setStatus(node, "SYNC ERROR", String(error?.message ?? error));
})
.finally(() => {
node.__promptRandomChoiceRuntimeInitialising = null;
});
node.__promptRandomChoiceRuntimeInitialising = operation;
return operation;
}
app.registerExtension({
name: "ruminar.PromptRandomChoice.Runtime",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (!TARGETS.has(nodeData.name)) return;
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = originalOnNodeCreated?.apply(this, arguments);
setTimeout(() => void initialiseFromServer(this), 0);
return result;
};
const originalOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const result = originalOnConfigure?.apply(this, arguments);
setTimeout(() => void initialiseFromServer(this), 0);
return result;
};
const originalOnDrawBackground = nodeType.prototype.onDrawBackground;
nodeType.prototype.onDrawBackground = function (ctx) {
const result = originalOnDrawBackground?.apply(this, arguments);
if (!this.flags?.collapsed) {
ctx.save();
ctx.fillStyle = LIVE_BACKGROUND_TINT;
ctx.fillRect(
1,
0,
Math.max(0, Number(this.size?.[0] ?? 0) - 2),
Math.max(0, Number(this.size?.[1] ?? 0)),
);
ctx.restore();
}
return result;
};
},
});