From 18129327d9f086251380a20054ba66049f82603e Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Tue, 25 Aug 2026 23:10:50 +0000 Subject: [PATCH] Add H3 beat prompt builder node --- __init__.py | 6 + dumas_h3_beat_prompt.py | 85 +++++++ js/h3_beat_prompt/index.js | 386 +++++++++++++++++++++++++++++ tests/test_dumas_h3_beat_prompt.py | 46 ++++ 4 files changed, 523 insertions(+) create mode 100644 dumas_h3_beat_prompt.py create mode 100644 js/h3_beat_prompt/index.js create mode 100644 tests/test_dumas_h3_beat_prompt.py diff --git a/__init__.py b/__init__.py index b9693db..5e20c38 100644 --- a/__init__.py +++ b/__init__.py @@ -22,6 +22,10 @@ from .dumas_h3_inspector import ( NODE_CLASS_MAPPINGS as H3_INSPECTOR_NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as H3_INSPECTOR_NODE_DISPLAY_NAME_MAPPINGS, ) +from .dumas_h3_beat_prompt import ( + NODE_CLASS_MAPPINGS as H3_BEAT_PROMPT_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as H3_BEAT_PROMPT_NODE_DISPLAY_NAME_MAPPINGS, +) NODE_CLASS_MAPPINGS = {} NODE_CLASS_MAPPINGS.update(JSON_NODE_CLASS_MAPPINGS) @@ -29,6 +33,7 @@ NODE_CLASS_MAPPINGS.update(IMAGE_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(H3_LONGVIDEO_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(H3_SHOT_LENGTH_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(H3_INSPECTOR_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(H3_BEAT_PROMPT_NODE_CLASS_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS.update(JSON_NODE_DISPLAY_NAME_MAPPINGS) @@ -36,6 +41,7 @@ NODE_DISPLAY_NAME_MAPPINGS.update(IMAGE_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(H3_LONGVIDEO_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(H3_SHOT_LENGTH_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(H3_INSPECTOR_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(H3_BEAT_PROMPT_NODE_DISPLAY_NAME_MAPPINGS) WEB_DIRECTORY = "./js" diff --git a/dumas_h3_beat_prompt.py b/dumas_h3_beat_prompt.py new file mode 100644 index 0000000..7d6bd38 --- /dev/null +++ b/dumas_h3_beat_prompt.py @@ -0,0 +1,85 @@ +import json + + +_DEFAULT_BEAT = "Describe this beat." +_DEFAULT_STATE = {"beats": [{"text": _DEFAULT_BEAT}]} + + +def _clone_default_state(): + return {"beats": [{"text": _DEFAULT_BEAT}]} + + +def _parse_beat_prompt_state(value): + if isinstance(value, dict): + raw = value + else: + text = str(value or "").strip() + if not text: + return _clone_default_state() + try: + raw = json.loads(text) + except Exception: + return _clone_default_state() + + beats = [] + for item in list(raw.get("beats") or []): + if isinstance(item, dict): + text = str(item.get("text") or "") + else: + text = str(item or "") + beats.append({"text": text}) + + if not beats: + return _clone_default_state() + return {"beats": beats} + + +def _assemble_beat_prompt(state): + parsed = _parse_beat_prompt_state(state) + chunks = [] + for beat in parsed["beats"]: + text = str(beat.get("text") or "").strip() + if text: + chunks.append(text) + return "\n\n".join(chunks) + + +class DumasH3BeatPromptNode: + DESCRIPTION = ( + "Build a MiniMax H3 prompt from one textbox per beat, with a front-end beat " + "editor that can append directive examples such as wardrobe, seconds, exit, and music." + ) + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("prompt",) + FUNCTION = "build_prompt" + CATEGORY = "Dumas/MiniMax" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "hidden": { + "H3BeatPromptState": ("STRING", {"default": json.dumps(_DEFAULT_STATE)}), + }, + } + + def build_prompt(self, H3BeatPromptState=""): + return (_assemble_beat_prompt(H3BeatPromptState),) + + +NODE_CLASS_MAPPINGS = { + "DumasH3BeatPrompt": DumasH3BeatPromptNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "DumasH3BeatPrompt": "Dumas H3 Beat Prompt", +} + + +__all__ = [ + "DumasH3BeatPromptNode", + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", + "_assemble_beat_prompt", + "_parse_beat_prompt_state", +] diff --git a/js/h3_beat_prompt/index.js b/js/h3_beat_prompt/index.js new file mode 100644 index 0000000..f452dca --- /dev/null +++ b/js/h3_beat_prompt/index.js @@ -0,0 +1,386 @@ +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 DIRECTIVE_EXAMPLES = [ + ["wardrobe", "wardrobe: Maya = grey shorts, red jacket"], + ["seconds", "seconds: 8"], + ["duration", "duration: 8 seconds"], + ["exit", "exit: Maya"], + ["enter", "enter: Jon"], + ["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-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 || ""); +} + +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; + const widget = findWidget(node, HIDDEN_INPUT_NAME); + if (widget) widget.value = serialized; +} + +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 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", (event) => event.stopImmediatePropagation()); + + 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", () => { + const next = readState(node); + next.beats[index].text = appendDirectiveText(next.beats[index].text, example); + writeState(node, next); + renderUI(node); + }); + directives.appendChild(button); + }); + + card.append(head, textarea, 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. Buttons append directive examples into that beat."; + 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); + + 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); + + writeState(node, readState(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); + this._dh3bpState = findWidget(this, HIDDEN_INPUT_NAME)?.value || this._dh3bpState; + 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; + }; + }, +}); diff --git a/tests/test_dumas_h3_beat_prompt.py b/tests/test_dumas_h3_beat_prompt.py new file mode 100644 index 0000000..1fc7a5c --- /dev/null +++ b/tests/test_dumas_h3_beat_prompt.py @@ -0,0 +1,46 @@ +import importlib +import unittest + + +class DumasH3BeatPromptTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = importlib.import_module("dumas_h3_beat_prompt") + + def test_parse_state_falls_back_to_default(self): + state = self.module._parse_beat_prompt_state("not json") + self.assertEqual( + state, + {"beats": [{"text": "Describe this beat."}]}, + ) + + def test_assemble_prompt_joins_beats_with_blank_lines(self): + prompt = self.module._assemble_beat_prompt( + { + "beats": [ + {"text": "A woman enters the room."}, + {"text": "wardrobe: Maya = red jacket\nShe sits at the table."}, + {"text": " "}, + {"text": "music: low synth pulse"}, + ] + } + ) + self.assertEqual( + prompt, + ( + "A woman enters the room.\n\n" + "wardrobe: Maya = red jacket\nShe sits at the table.\n\n" + "music: low synth pulse" + ), + ) + + def test_node_build_prompt_uses_hidden_state(self): + node = self.module.DumasH3BeatPromptNode() + result = node.build_prompt( + '{"beats":[{"text":"Beat one"},{"text":"Beat two"}]}' + ) + self.assertEqual(result, ("Beat one\n\nBeat two",)) + + +if __name__ == "__main__": + unittest.main()