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; }; }, });