From ea6fae7e562641d160c497e3dd6a512d44283ad6 Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Thu, 10 Sep 2026 10:34:53 +0000 Subject: [PATCH] Align beat prompt with upstream Long Videos --- README.md | 4 +- dumas_h3_beat_prompt.py | 63 +++++++++-- js/h3_beat_prompt/index.js | 174 ++++++++++++----------------- tests/test_dumas_h3_beat_prompt.py | 47 ++++++-- 4 files changed, 169 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 79e1515..86e45e2 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,9 @@ - `Dumas H3 Beat Prompt` - Inputs: authored through the custom front-end beat editor - Output: `prompt` - - Builds one H3 prompt block per beat, with quick controls for per-shot timing, continuity, ref behavior, anchor additions, soundscape, and music while staying compatible with direct text editing. + - Builds an upstream-compatible Long Videos prompt: optional scene paragraph, optional character sheet, then one blank-line-separated textbox per beat. + - Per-beat helpers only emit upstream-supported state directives: `remove:` / `removed:` / `off:` and `add:` / `wear:` / `wearing:`. + - Old Dumas-only beat directives such as `seconds:`, `continuity:`, `ref_mode:`, `ref_noise_aug:`, `anchor_add:`, `soundscape:`, and `music:` are stripped from the generated prompt so they are not sent to the upstream node as visible text. - `Dumas H3 Prompt Curator` - Inputs: `action_prompt`, `anatomy_guard`, `subject_count_guard`, optional `anchor`, optional `soundscape`, optional `bgm`, optional `ref_1` through `ref_9` diff --git a/dumas_h3_beat_prompt.py b/dumas_h3_beat_prompt.py index 9f0cb96..a6eb65f 100644 --- a/dumas_h3_beat_prompt.py +++ b/dumas_h3_beat_prompt.py @@ -2,11 +2,46 @@ import json _DEFAULT_BEAT = "Describe this beat." -_DEFAULT_STATE = {"beats": [{"text": _DEFAULT_BEAT}]} +_DEFAULT_STATE = {"scene": "", "character_sheet": "", "beats": [{"text": _DEFAULT_BEAT}]} +_LEGACY_DIRECTIVE_PREFIXES = ( + "seconds", + "duration", + "continuity", + "ref_mode", + "ref_noise_aug", + "anchor_add", + "overall_soundscape", + "soundscape", + "non_diegetic_music", + "music", + "wardrobe", + "enter", + "exit", +) def _clone_default_state(): - return {"beats": [{"text": _DEFAULT_BEAT}]} + return { + "scene": "", + "character_sheet": "", + "beats": [{"text": _DEFAULT_BEAT}], + } + + +def _strip_legacy_directives(text): + """Remove directives from the abandoned Dumas Long Videos fork. + + The upstream Long Videos node sends unknown field labels to the model as text, + so this builder strips the old managed controls rather than emitting prompts + that ask H3 to draw labels such as "seconds:" or "music:" in the frame. + """ + kept = [] + for line in str(text or "").splitlines(): + lowered = line.strip().lower() + if any(lowered.startswith(f"{name}:") for name in _LEGACY_DIRECTIVE_PREFIXES): + continue + kept.append(line) + return "\n".join(kept).strip() def _parse_beat_prompt_state(value): @@ -21,6 +56,8 @@ def _parse_beat_prompt_state(value): except Exception: return _clone_default_state() + scene = str(raw.get("scene") or "") + character_sheet = str(raw.get("character_sheet") or "") beats = [] for item in list(raw.get("beats") or []): if isinstance(item, dict): @@ -30,15 +67,25 @@ def _parse_beat_prompt_state(value): beats.append({"text": text}) if not beats: - return _clone_default_state() - return {"beats": beats} + beats = [{"text": _DEFAULT_BEAT}] + return { + "scene": scene, + "character_sheet": character_sheet, + "beats": beats, + } def _assemble_beat_prompt(state): parsed = _parse_beat_prompt_state(state) chunks = [] + scene = str(parsed.get("scene") or "").strip() + if scene: + chunks.append(scene) + character_sheet = str(parsed.get("character_sheet") or "").strip() + if character_sheet: + chunks.append(character_sheet) for beat in parsed["beats"]: - text = str(beat.get("text") or "").strip() + text = _strip_legacy_directives(beat.get("text") or "") if text: chunks.append(text) return "\n\n".join(chunks) @@ -46,9 +93,9 @@ def _assemble_beat_prompt(state): class DumasH3BeatPromptNode: DESCRIPTION = ( - "Build a MiniMax H3 prompt from one textbox per beat, with a front-end beat " - "editor that can append directive examples and expose per-shot controls for " - "timing, continuity, ref behavior, anchor additions, soundscape, and music." + "Build an upstream MiniMax H3 Long Videos prompt: optional scene paragraph, " + "optional character sheet, then one blank-line-separated textbox per beat. " + "Per-beat helpers only emit directives the upstream node understands." ) RETURN_TYPES = ("STRING",) RETURN_NAMES = ("prompt",) diff --git a/js/h3_beat_prompt/index.js b/js/h3_beat_prompt/index.js index e955d00..9018286 100644 --- a/js/h3_beat_prompt/index.js +++ b/js/h3_beat_prompt/index.js @@ -8,32 +8,15 @@ 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"], + remove: ["remove", "removed", "off"], + add: ["add", "wear", "wearing"], }; 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"], + ["remove", "remove: red jacket"], + ["off", "off: steel collar"], + ["add", "add: white shirt underneath"], + ["wearing", "wearing: black coat"], ]; function injectCSS() { @@ -183,7 +166,7 @@ function injectCSS() { } function defaultState() { - return { beats: [{ text: DEFAULT_BEAT }] }; + return { scene: "", character_sheet: "", beats: [{ text: DEFAULT_BEAT }] }; } function normalizeState(value) { @@ -200,7 +183,11 @@ function normalizeState(value) { const normalized = beats.map((beat) => ({ text: typeof beat?.text === "string" ? beat.text : String(beat?.text || ""), })); - return normalized.length ? { beats: normalized } : defaultState(); + return { + scene: typeof parsed.scene === "string" ? parsed.scene : String(parsed.scene || ""), + character_sheet: typeof parsed.character_sheet === "string" ? parsed.character_sheet : String(parsed.character_sheet || ""), + beats: normalized.length ? normalized : [{ text: DEFAULT_BEAT }], + }; } function readState(node) { @@ -321,6 +308,51 @@ function renderUI(node) { node._dh3bpRenderedState = JSON.stringify(state); ui.list.innerHTML = ""; + const buildTopTextarea = ({ labelText, placeholder, value, onInput }) => { + const card = document.createElement("div"); + card.className = "dh3bp-beat"; + + const label = document.createElement("div"); + label.className = "dh3bp-label"; + label.textContent = labelText; + + const textarea = document.createElement("textarea"); + textarea.className = "dh3bp-text"; + textarea.placeholder = placeholder; + textarea.value = value || ""; + textarea.addEventListener("input", () => { + onInput(textarea.value); + updateTextareaHeight(textarea); + }); + textarea.addEventListener("keydown", stopCanvasKeyboard); + + card.append(label, textarea); + updateTextareaHeight(textarea); + return card; + }; + + ui.list.appendChild(buildTopTextarea({ + labelText: "Scene paragraph", + placeholder: "Optional. Persistent location, lighting, camera, tone. Leave empty if you wire the Long Videos anchor input.", + value: state.scene, + onInput: (value) => { + const next = readState(node); + next.scene = value; + writeState(node, next); + }, + })); + + ui.list.appendChild(buildTopTextarea({ + labelText: "Character sheet", + placeholder: "Optional. One character per line, e.g. Maya: 27, she, silver hair, red jacket, the woman in .", + value: state.character_sheet, + onInput: (value) => { + const next = readState(node); + next.character_sheet = value; + writeState(node, next); + }, + })); + state.beats.forEach((beat, index) => { const card = document.createElement("div"); card.className = "dh3bp-beat"; @@ -379,85 +411,27 @@ function renderUI(node) { 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 removeInput = document.createElement("input"); + removeInput.className = "dh3bp-input"; + removeInput.type = "text"; + removeInput.placeholder = "red jacket"; + removeInput.value = readDirectiveValue(beat.text, MANAGED_DIRECTIVES.remove); + removeInput.addEventListener("input", () => { + applyTextUpdate(setDirectiveValue(textarea.value, "remove", MANAGED_DIRECTIVES.remove, removeInput.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)); + const addInput = document.createElement("input"); + addInput.className = "dh3bp-input"; + addInput.type = "text"; + addInput.placeholder = "white shirt underneath"; + addInput.value = readDirectiveValue(beat.text, MANAGED_DIRECTIVES.add); + addInput.addEventListener("input", () => { + applyTextUpdate(setDirectiveValue(textarea.value, "add", MANAGED_DIRECTIVES.add, addInput.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 }), + buildField({ labelText: "Remove from memory", input: removeInput }), + buildField({ labelText: "Add to memory", input: addInput }), ); const directives = document.createElement("div"); @@ -501,7 +475,7 @@ function setupNode(node) { 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."; + subtitle.textContent = "Upstream Long Videos format: optional scene, optional character sheet, then one blank-line-separated beat per shot."; titleWrap.append(title, subtitle); const addButton = document.createElement("button"); diff --git a/tests/test_dumas_h3_beat_prompt.py b/tests/test_dumas_h3_beat_prompt.py index 1fc7a5c..1534d16 100644 --- a/tests/test_dumas_h3_beat_prompt.py +++ b/tests/test_dumas_h3_beat_prompt.py @@ -11,35 +11,62 @@ class DumasH3BeatPromptTests(unittest.TestCase): state = self.module._parse_beat_prompt_state("not json") self.assertEqual( state, - {"beats": [{"text": "Describe this beat."}]}, + { + "scene": "", + "character_sheet": "", + "beats": [{"text": "Describe this beat."}], + }, ) - def test_assemble_prompt_joins_beats_with_blank_lines(self): + def test_assemble_prompt_outputs_upstream_sections(self): prompt = self.module._assemble_beat_prompt( { + "scene": "A rainy kitchen at night.", + "character_sheet": "Maya: 27, she, red jacket, silver hair.", "beats": [ - {"text": "A woman enters the room."}, - {"text": "wardrobe: Maya = red jacket\nShe sits at the table."}, + {"text": "Maya enters the room."}, + {"text": "remove: red jacket\nadd: white shirt underneath\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" + "A rainy kitchen at night.\n\n" + "Maya: 27, she, red jacket, silver hair.\n\n" + "Maya enters the room.\n\n" + "remove: red jacket\nadd: white shirt underneath\nShe sits at the table." ), ) + def test_assemble_prompt_strips_old_dumas_directives(self): + prompt = self.module._assemble_beat_prompt( + { + "beats": [ + { + "text": ( + "seconds: 8\n" + "continuity: hard cut\n" + "ref_mode: every shot\n" + "soundscape: soft rain\n" + "music: low synth\n" + "Maya opens the cupboard.\n" + "remove: red jacket" + ) + }, + ] + } + ) + + self.assertEqual(prompt, "Maya opens the cupboard.\nremove: red jacket") + def test_node_build_prompt_uses_hidden_state(self): node = self.module.DumasH3BeatPromptNode() result = node.build_prompt( - '{"beats":[{"text":"Beat one"},{"text":"Beat two"}]}' + '{"scene":"Scene","character_sheet":"Maya: 27, she","beats":[{"text":"Beat one"},{"text":"Beat two"}]}' ) - self.assertEqual(result, ("Beat one\n\nBeat two",)) + self.assertEqual(result, ("Scene\n\nMaya: 27, she\n\nBeat one\n\nBeat two",)) if __name__ == "__main__":