Compare commits

...
1 Commits
Author SHA1 Message Date
chris.dumas ea6fae7e56 Align beat prompt with upstream Long Videos 2026-09-10 10:34:53 +00:00
4 changed files with 169 additions and 119 deletions
+3 -1
View File
@@ -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`
+55 -8
View File
@@ -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",)
+74 -100
View File
@@ -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 <Picture 1>.",
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");
+37 -10
View File
@@ -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__":