Files
DumasNodes/js/h3_beat_prompt/index.js
T

616 lines
20 KiB
JavaScript

import { app } from "/scripts/app.js";
import { applyAdaptiveCanvasOnly } from "../shared/nodes2.mjs";
const COMFY_CLASS = "DumasH3BeatPrompt";
const HIDDEN_INPUT_NAME = "H3BeatPromptState";
const MIN_W = 420;
const DEFAULT_W = 520;
const DEFAULT_H = 340;
const DEFAULT_BEAT = "Describe this beat.";
const STATE_PROPERTY = "dumas_h3_beat_prompt_state";
const CONTINUITY_OPTIONS = ["", "soft carry", "hard cut", "keyframe carry", "handoff ref"];
const REF_MODE_OPTIONS = ["", "auto ref2v", "where tagged", "first shot", "every shot", "every shot + handoff ref"];
const MANAGED_DIRECTIVES = {
seconds: ["seconds", "duration"],
continuity: ["continuity"],
ref_mode: ["ref_mode"],
ref_noise_aug: ["ref_noise_aug"],
anchor_add: ["anchor_add"],
overall_soundscape: ["overall_soundscape", "soundscape"],
non_diegetic_music: ["non_diegetic_music", "music"],
};
const DIRECTIVE_EXAMPLES = [
["wardrobe set", "wardrobe: Maya = grey shorts, red jacket"],
["wardrobe add", "wardrobe: Maya += red jacket"],
["wardrobe remove", "wardrobe: Maya -= red jacket"],
["seconds", "seconds: 8"],
["exit", "exit: Maya"],
["enter", "enter: Jon"],
["continuity", "continuity: hard cut"],
["ref_mode", "ref_mode: every shot"],
["ref_noise_aug", "ref_noise_aug: 0.92"],
["anchor_add", "anchor_add: harsh sodium-vapor spill, wet pavement, long-lens compression"],
["overall_soundscape", "overall_soundscape: soft rain, distant traffic"],
["non_diegetic_music", "non_diegetic_music: tense analog synth pulse"],
["soundscape", "soundscape: fluorescent room tone, faint HVAC hum"],
["music", "music: low ominous cello and sparse percussion"],
];
function injectCSS() {
if (document.getElementById("dumas-h3-beat-prompt-css")) return;
const style = document.createElement("style");
style.id = "dumas-h3-beat-prompt-css";
style.textContent = `
.dh3bp-root {
box-sizing: border-box;
width: 100%;
color: #e8e8e8;
font: 12px/1.35 "Segoe UI", sans-serif;
padding: 8px 10px 10px;
}
.dh3bp-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.dh3bp-title {
font-size: 12px;
font-weight: 600;
color: #f0f0f0;
}
.dh3bp-subtitle {
color: #9da3ae;
font-size: 11px;
}
.dh3bp-add {
background: #e66a2c;
color: #fff;
border: 1px solid #f28b57;
border-radius: 7px;
padding: 6px 10px;
cursor: pointer;
font-weight: 600;
}
.dh3bp-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.dh3bp-beat {
background: #1e2025;
border: 1px solid #363a42;
border-radius: 10px;
padding: 10px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.02);
}
.dh3bp-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.dh3bp-label {
color: #f3f4f6;
font-size: 12px;
font-weight: 600;
}
.dh3bp-remove {
background: transparent;
color: #f59a9a;
border: 1px solid #5a2c2c;
border-radius: 6px;
padding: 4px 8px;
cursor: pointer;
font-size: 11px;
}
.dh3bp-remove:disabled {
opacity: 0.45;
cursor: default;
}
.dh3bp-text {
width: 100%;
min-height: 96px;
resize: vertical;
box-sizing: border-box;
background: #121418;
color: #e8e8e8;
border: 1px solid #3c414a;
border-radius: 8px;
padding: 8px 10px;
font: 12px/1.4 "Consolas", "SFMono-Regular", monospace;
margin-bottom: 8px;
}
.dh3bp-directives {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.dh3bp-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.dh3bp-control {
display: flex;
flex-direction: column;
gap: 4px;
}
.dh3bp-control-wide {
grid-column: 1 / -1;
}
.dh3bp-control-label {
color: #b8bec8;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dh3bp-input,
.dh3bp-select {
width: 100%;
box-sizing: border-box;
background: #121418;
color: #e8e8e8;
border: 1px solid #3c414a;
border-radius: 8px;
padding: 7px 9px;
font: 12px/1.35 "Segoe UI", sans-serif;
}
.dh3bp-input::placeholder {
color: #7f8793;
}
.dh3bp-directive {
background: #252a33;
color: #d4d9e1;
border: 1px solid #414754;
border-radius: 999px;
padding: 4px 8px;
cursor: pointer;
font-size: 11px;
line-height: 1.2;
}
.dh3bp-directive:hover,
.dh3bp-remove:hover,
.dh3bp-add:hover {
filter: brightness(1.08);
}
`;
document.head.appendChild(style);
}
function defaultState() {
return { beats: [{ text: DEFAULT_BEAT }] };
}
function normalizeState(value) {
let parsed = value;
if (typeof parsed === "string") {
try {
parsed = JSON.parse(parsed);
} catch (_error) {
parsed = null;
}
}
if (!parsed || typeof parsed !== "object") return defaultState();
const beats = Array.isArray(parsed.beats) ? parsed.beats : [];
const normalized = beats.map((beat) => ({
text: typeof beat?.text === "string" ? beat.text : String(beat?.text || ""),
}));
return normalized.length ? { beats: normalized } : defaultState();
}
function readState(node) {
return normalizeState(
node._dh3bpState
|| findWidget(node, HIDDEN_INPUT_NAME)?.value
|| node.properties?.[STATE_PROPERTY]
|| ""
);
}
function findWidget(node, name) {
return (node.widgets || []).find((widget) => widget?.name === name);
}
function hideWidget(node, name) {
const widget = findWidget(node, name);
if (!widget) return;
widget.type = "hidden";
widget.computeSize = () => [0, 0];
}
function writeState(node, state) {
const normalized = normalizeState(state);
const serialized = JSON.stringify(normalized);
node._dh3bpState = serialized;
node.properties = node.properties || {};
node.properties[STATE_PROPERTY] = serialized;
const widget = findWidget(node, HIDDEN_INPUT_NAME);
if (widget) widget.value = serialized;
}
function syncStateFromNode(node) {
const widgetValue = findWidget(node, HIDDEN_INPUT_NAME)?.value;
const propertyValue = node.properties?.[STATE_PROPERTY];
const source = node._dh3bpState || widgetValue || propertyValue || "";
writeState(node, source);
}
function updateTextareaHeight(textarea) {
textarea.style.height = "auto";
textarea.style.height = `${Math.max(96, textarea.scrollHeight)}px`;
}
function appendDirectiveText(currentText, example) {
const trimmed = String(currentText || "").replace(/\s+$/, "");
if (!trimmed) return example;
if (trimmed.includes(example)) return trimmed;
return `${trimmed}\n${example}`;
}
function splitBeatLines(text) {
return String(text || "").split(/\n/);
}
function readDirectiveValue(text, directiveNames) {
let value = "";
for (const line of splitBeatLines(text)) {
const trimmed = line.trim();
for (const name of directiveNames) {
const lower = name.toLowerCase();
if (trimmed.toLowerCase().startsWith(`${lower}:`)) {
value = trimmed.slice(trimmed.indexOf(":") + 1).trim();
}
}
}
return value;
}
function stripDirectiveValues(text, directiveNames) {
const lowered = directiveNames.map((name) => name.toLowerCase());
const kept = splitBeatLines(text).filter((line) => {
const trimmed = line.trim().toLowerCase();
return !lowered.some((name) => trimmed.startsWith(`${name}:`));
});
return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
function setDirectiveValue(text, canonicalName, directiveNames, value) {
const cleaned = stripDirectiveValues(text, directiveNames);
const trimmedValue = String(value || "").trim();
if (!trimmedValue) return cleaned;
const directiveLine = `${canonicalName}: ${trimmedValue}`;
return cleaned ? `${directiveLine}\n${cleaned}` : directiveLine;
}
function isInteractiveTarget(target) {
return !!target?.closest?.("textarea, input, select, button, label");
}
function stopCanvasEvent(event) {
if (isInteractiveTarget(event.target)) event.stopPropagation();
}
function stopCanvasKeyboard(event) {
if (isInteractiveTarget(event.target)) event.stopImmediatePropagation();
}
function renderUI(node) {
const ui = node._dh3bpUI;
if (!ui) return;
const state = readState(node);
ui.list.innerHTML = "";
state.beats.forEach((beat, index) => {
const card = document.createElement("div");
card.className = "dh3bp-beat";
const head = document.createElement("div");
head.className = "dh3bp-head";
const label = document.createElement("div");
label.className = "dh3bp-label";
label.textContent = `Beat ${index + 1}`;
const remove = document.createElement("button");
remove.className = "dh3bp-remove";
remove.type = "button";
remove.textContent = "Remove";
remove.disabled = state.beats.length <= 1;
remove.addEventListener("click", () => {
const next = readState(node);
next.beats.splice(index, 1);
writeState(node, next);
renderUI(node);
});
head.append(label, remove);
const textarea = document.createElement("textarea");
textarea.className = "dh3bp-text";
textarea.placeholder = "Describe this beat...";
textarea.value = beat.text || "";
textarea.addEventListener("input", () => {
const next = readState(node);
next.beats[index].text = textarea.value;
writeState(node, next);
updateTextareaHeight(textarea);
});
textarea.addEventListener("keydown", stopCanvasKeyboard);
const applyTextUpdate = (nextText) => {
const next = readState(node);
next.beats[index].text = nextText;
writeState(node, next);
textarea.value = nextText;
updateTextareaHeight(textarea);
};
const controls = document.createElement("div");
controls.className = "dh3bp-controls";
const buildField = ({ labelText, className = "", input }) => {
const wrap = document.createElement("label");
wrap.className = `dh3bp-control ${className}`.trim();
const labelEl = document.createElement("div");
labelEl.className = "dh3bp-control-label";
labelEl.textContent = labelText;
wrap.append(labelEl, input);
return wrap;
};
const secondsInput = document.createElement("input");
secondsInput.className = "dh3bp-input";
secondsInput.type = "text";
secondsInput.placeholder = "8";
secondsInput.value = readDirectiveValue(beat.text, MANAGED_DIRECTIVES.seconds);
secondsInput.addEventListener("input", () => {
applyTextUpdate(setDirectiveValue(textarea.value, "seconds", MANAGED_DIRECTIVES.seconds, secondsInput.value));
});
const continuitySelect = document.createElement("select");
continuitySelect.className = "dh3bp-select";
CONTINUITY_OPTIONS.forEach((value) => {
const option = document.createElement("option");
option.value = value;
option.textContent = value || "Default";
continuitySelect.appendChild(option);
});
continuitySelect.value = readDirectiveValue(beat.text, MANAGED_DIRECTIVES.continuity);
continuitySelect.addEventListener("change", () => {
applyTextUpdate(setDirectiveValue(textarea.value, "continuity", MANAGED_DIRECTIVES.continuity, continuitySelect.value));
});
const refModeSelect = document.createElement("select");
refModeSelect.className = "dh3bp-select";
REF_MODE_OPTIONS.forEach((value) => {
const option = document.createElement("option");
option.value = value;
option.textContent = value || "Global";
refModeSelect.appendChild(option);
});
refModeSelect.value = readDirectiveValue(beat.text, MANAGED_DIRECTIVES.ref_mode);
refModeSelect.addEventListener("change", () => {
applyTextUpdate(setDirectiveValue(textarea.value, "ref_mode", MANAGED_DIRECTIVES.ref_mode, refModeSelect.value));
});
const refNoiseInput = document.createElement("input");
refNoiseInput.className = "dh3bp-input";
refNoiseInput.type = "text";
refNoiseInput.placeholder = "0.95";
refNoiseInput.value = readDirectiveValue(beat.text, MANAGED_DIRECTIVES.ref_noise_aug);
refNoiseInput.addEventListener("input", () => {
applyTextUpdate(setDirectiveValue(textarea.value, "ref_noise_aug", MANAGED_DIRECTIVES.ref_noise_aug, refNoiseInput.value));
});
const anchorInput = document.createElement("input");
anchorInput.className = "dh3bp-input";
anchorInput.type = "text";
anchorInput.placeholder = "extra per-shot style treatment";
anchorInput.value = readDirectiveValue(beat.text, MANAGED_DIRECTIVES.anchor_add);
anchorInput.addEventListener("input", () => {
applyTextUpdate(setDirectiveValue(textarea.value, "anchor_add", MANAGED_DIRECTIVES.anchor_add, anchorInput.value));
});
const soundscapeInput = document.createElement("input");
soundscapeInput.className = "dh3bp-input";
soundscapeInput.type = "text";
soundscapeInput.placeholder = "faint traffic, loose sign rattle";
soundscapeInput.value = readDirectiveValue(beat.text, MANAGED_DIRECTIVES.overall_soundscape);
soundscapeInput.addEventListener("input", () => {
applyTextUpdate(setDirectiveValue(textarea.value, "overall_soundscape", MANAGED_DIRECTIVES.overall_soundscape, soundscapeInput.value));
});
const musicInput = document.createElement("input");
musicInput.className = "dh3bp-input";
musicInput.type = "text";
musicInput.placeholder = "low pulsing synth tension";
musicInput.value = readDirectiveValue(beat.text, MANAGED_DIRECTIVES.non_diegetic_music);
musicInput.addEventListener("input", () => {
applyTextUpdate(setDirectiveValue(textarea.value, "non_diegetic_music", MANAGED_DIRECTIVES.non_diegetic_music, musicInput.value));
});
controls.append(
buildField({ labelText: "Seconds", input: secondsInput }),
buildField({ labelText: "Continuity", input: continuitySelect }),
buildField({ labelText: "Ref Mode", input: refModeSelect }),
buildField({ labelText: "Ref Noise Aug", input: refNoiseInput }),
buildField({ labelText: "Anchor Add", className: "dh3bp-control-wide", input: anchorInput }),
buildField({ labelText: "Shot Soundscape", className: "dh3bp-control-wide", input: soundscapeInput }),
buildField({ labelText: "Shot Music", className: "dh3bp-control-wide", input: musicInput }),
);
const directives = document.createElement("div");
directives.className = "dh3bp-directives";
DIRECTIVE_EXAMPLES.forEach(([labelText, example]) => {
const button = document.createElement("button");
button.className = "dh3bp-directive";
button.type = "button";
button.textContent = labelText;
button.title = example;
button.addEventListener("click", () => {
applyTextUpdate(appendDirectiveText(textarea.value, example));
renderUI(node);
});
directives.appendChild(button);
});
card.append(head, textarea, controls, directives);
ui.list.appendChild(card);
updateTextareaHeight(textarea);
});
node.setDirtyCanvas?.(true, true);
}
function setupNode(node) {
injectCSS();
hideWidget(node, HIDDEN_INPUT_NAME);
const root = document.createElement("div");
root.className = "dh3bp-root";
const toolbar = document.createElement("div");
toolbar.className = "dh3bp-toolbar";
const titleWrap = document.createElement("div");
const title = document.createElement("div");
title.className = "dh3bp-title";
title.textContent = "Beat Prompt Builder";
const subtitle = document.createElement("div");
subtitle.className = "dh3bp-subtitle";
subtitle.textContent = "One textbox per H3 beat, plus per-shot controls for timing, ref behavior, continuity, anchor adds, and audio directives.";
titleWrap.append(title, subtitle);
const addButton = document.createElement("button");
addButton.className = "dh3bp-add";
addButton.type = "button";
addButton.textContent = "+ Add Beat";
addButton.addEventListener("click", () => {
const next = readState(node);
next.beats.push({ text: "" });
writeState(node, next);
renderUI(node);
});
toolbar.append(titleWrap, addButton);
const list = document.createElement("div");
list.className = "dh3bp-list";
root.append(toolbar, list);
root.addEventListener("pointerdown", stopCanvasEvent);
root.addEventListener("mousedown", stopCanvasEvent);
root.addEventListener("click", stopCanvasEvent);
root.addEventListener("dblclick", stopCanvasEvent);
root.addEventListener("keydown", stopCanvasKeyboard, true);
node._dh3bpUI = { root, list };
const widget = node.addDOMWidget("dumas_h3_beat_prompt", "custom", root, {
getValue: () => null,
setValue: () => {},
serialize: false,
getMinHeight: () => 180,
});
widget.computeLayoutSize = () => ({ minHeight: 180, minWidth: 1 });
applyAdaptiveCanvasOnly(widget);
node.size[0] = Math.max(node.size[0] || 0, DEFAULT_W);
node.size[1] = Math.max(node.size[1] || 0, DEFAULT_H);
syncStateFromNode(node);
renderUI(node);
}
function collectNodes(graph, out) {
if (!graph) return;
const nodes = graph._nodes || graph.nodes || [];
for (const node of nodes) {
if (node?.comfyClass === COMFY_CLASS) out.push(node);
const inner = node?.subgraph || node?.graph || node?._graph;
if (inner && inner !== graph) collectNodes(inner, out);
}
}
function matchNode(nodes, promptId) {
let node = nodes.find((entry) => String(entry.id) === String(promptId));
if (node) return node;
const tail = String(promptId).split(":").pop();
return nodes.find((entry) => String(entry.id) === tail) || null;
}
function injectState(result) {
const output = result?.output;
if (!output) return;
const nodes = [];
collectNodes(app.graph, nodes);
for (const id in output) {
const entry = output[id];
if (!entry || entry.class_type !== COMFY_CLASS) continue;
const node = matchNode(nodes, id);
if (!node) continue;
if (!entry.inputs) entry.inputs = {};
entry.inputs[HIDDEN_INPUT_NAME] = node._dh3bpState || JSON.stringify(defaultState());
}
}
function installGraphToPromptHook() {
if (app._dh3bpGraphPatched) return;
app._dh3bpGraphPatched = true;
const original = app.graphToPrompt.bind(app);
app.graphToPrompt = async function graphToPromptPatched(...args) {
const result = await original(...args);
injectState(result);
return result;
};
}
app.registerExtension({
name: "Dumas.H3BeatPrompt",
setup() {
installGraphToPromptHook();
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== COMFY_CLASS) return;
const originalNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function onNodeCreated() {
const result = originalNodeCreated?.apply(this, arguments);
setupNode(this);
return result;
};
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function onConfigure() {
const result = originalConfigure?.apply(this, arguments);
syncStateFromNode(this);
queueMicrotask(() => renderUI(this));
return result;
};
const originalSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function onSerialize(o) {
syncStateFromNode(this);
const result = originalSerialize?.apply(this, arguments);
if (o && this.properties?.[STATE_PROPERTY]) {
o.properties = o.properties || {};
o.properties[STATE_PROPERTY] = this.properties[STATE_PROPERTY];
}
queueMicrotask(() => renderUI(this));
return result;
};
const originalDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function onDrawForeground(ctx) {
const result = originalDrawForeground?.call(this, ctx);
this.size[0] = Math.max(this.size[0], MIN_W);
return result;
};
},
});