Compare commits

...
2 Commits
Author SHA1 Message Date
chris.dumas 9923a3e417 Add handoff context frames 2026-09-10 11:44:18 +00:00
chris.dumas ea6fae7e56 Align beat prompt with upstream Long Videos 2026-09-10 10:34:53 +00:00
6 changed files with 240 additions and 121 deletions
+4 -1
View File
@@ -38,6 +38,7 @@
- The local Dumas prompt-engineering fork has been removed from this node. Long Videos now wraps the upstream sampler/engine directly so it can track the source project again. - The local Dumas prompt-engineering fork has been removed from this node. Long Videos now wraps the upstream sampler/engine directly so it can track the source project again.
- Upstream compatibility keys `H3LongVideos`, `H3LongVideosFL2VA`, `H3LongVideosV1`, and `H3LongVideosREF2VA` are also registered to the same class. - Upstream compatibility keys `H3LongVideos`, `H3LongVideosFL2VA`, `H3LongVideosV1`, and `H3LongVideosREF2VA` are also registered to the same class.
- The old Dumas browser widget grouping script is disabled for this node because it targeted controls that no longer exist on the upstream sampler. - The old Dumas browser widget grouping script is disabled for this node because it targeted controls that no longer exist on the upstream sampler.
- `handoff_frames` extends the upstream last-frame handoff: `1` keeps the current single keyframe behavior; higher values keep that final-frame keyframe and add earlier tail frames from the previous shot as claimed reference context for the next beat.
- Upstream license text is included in [`H3_LONGVIDEOS_UPSTREAM_LICENSE.txt`](./H3_LONGVIDEOS_UPSTREAM_LICENSE.txt). - Upstream license text is included in [`H3_LONGVIDEOS_UPSTREAM_LICENSE.txt`](./H3_LONGVIDEOS_UPSTREAM_LICENSE.txt).
- `Dumas H3 Latent Upscale Params` - `Dumas H3 Latent Upscale Params`
@@ -48,7 +49,9 @@
- `Dumas H3 Beat Prompt` - `Dumas H3 Beat Prompt`
- Inputs: authored through the custom front-end beat editor - Inputs: authored through the custom front-end beat editor
- Output: `prompt` - 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` - `Dumas H3 Prompt Curator`
- Inputs: `action_prompt`, `anatomy_guard`, `subject_count_guard`, optional `anchor`, optional `soundscape`, optional `bgm`, optional `ref_1` through `ref_9` - 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_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(): 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): def _parse_beat_prompt_state(value):
@@ -21,6 +56,8 @@ def _parse_beat_prompt_state(value):
except Exception: except Exception:
return _clone_default_state() return _clone_default_state()
scene = str(raw.get("scene") or "")
character_sheet = str(raw.get("character_sheet") or "")
beats = [] beats = []
for item in list(raw.get("beats") or []): for item in list(raw.get("beats") or []):
if isinstance(item, dict): if isinstance(item, dict):
@@ -30,15 +67,25 @@ def _parse_beat_prompt_state(value):
beats.append({"text": text}) beats.append({"text": text})
if not beats: if not beats:
return _clone_default_state() beats = [{"text": _DEFAULT_BEAT}]
return {"beats": beats} return {
"scene": scene,
"character_sheet": character_sheet,
"beats": beats,
}
def _assemble_beat_prompt(state): def _assemble_beat_prompt(state):
parsed = _parse_beat_prompt_state(state) parsed = _parse_beat_prompt_state(state)
chunks = [] 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"]: for beat in parsed["beats"]:
text = str(beat.get("text") or "").strip() text = _strip_legacy_directives(beat.get("text") or "")
if text: if text:
chunks.append(text) chunks.append(text)
return "\n\n".join(chunks) return "\n\n".join(chunks)
@@ -46,9 +93,9 @@ def _assemble_beat_prompt(state):
class DumasH3BeatPromptNode: class DumasH3BeatPromptNode:
DESCRIPTION = ( DESCRIPTION = (
"Build a MiniMax H3 prompt from one textbox per beat, with a front-end beat " "Build an upstream MiniMax H3 Long Videos prompt: optional scene paragraph, "
"editor that can append directive examples and expose per-shot controls for " "optional character sheet, then one blank-line-separated textbox per beat. "
"timing, continuity, ref behavior, anchor additions, soundscape, and music." "Per-beat helpers only emit directives the upstream node understands."
) )
RETURN_TYPES = ("STRING",) RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("prompt",) RETURN_NAMES = ("prompt",)
+61 -2
View File
@@ -5831,6 +5831,24 @@ def handoff_claim(n):
f"anybody new.") f"anybody new.")
def handoff_context_claim(first, last):
"""Claim previous-shot tail frames carried as reference context.
These are not keyframes. They are context stills that show the room, camera
path, and motion immediately before the hard handoff keyframe. They must be
named in the prompt because every reference image the VLM sees needs a job:
unnamed pictures are free to become extra subjects.
"""
first, last = int(first), int(last)
if last <= first:
return (f" <Picture {first}> is a continuity context frame from immediately "
f"before this shot: same place, same camera path, same people, no "
f"new subject.")
return (f" <Picture {first}> through <Picture {last}> are continuity context "
f"frames from immediately before this shot: same place, same camera "
f"path, same people, no new subjects.")
def room_claim(n, present, joining): def room_claim(n, present, joining):
"""Claim a handoff carried as a reference because somebody NEW is in the shot. """Claim a handoff carried as a reference because somebody NEW is in the shot.
@@ -7062,6 +7080,7 @@ _WIDGET_RANGE = {
"pace": (1.0, 0.25, 2.0, float), "pace": (1.0, 0.25, 2.0, float),
"ambient_level": (0.25, 0.0, 1.0, float), "ambient_level": (0.25, 0.0, 1.0, float),
"foley_level": (0.35, 0.0, 1.0, float), "foley_level": (0.35, 0.0, 1.0, float),
"handoff_frames": (1, 1, MAX_FRAMES, int),
} }
@@ -7582,6 +7601,18 @@ class H3LongVideos:
"It is synthesis, not a recording: a click, a rattle, " "It is synthesis, not a recording: a click, a rattle, "
"a rustle, in the right place. Nothing vocal is ever " "a rustle, in the right place. Nothing vocal is ever "
"built. 0 turns it off; needs auto_sound on."}), "built. 0 turns it off; needs auto_sound on."}),
# APPENDED. Saved workflows restore widget values by position.
"handoff_frames": ("INT", {"default": 1, "min": 1, "max": MAX_FRAMES,
"tooltip": "How many frames from the previous shot are used to "
"condition the next one. 1 is the upstream default: "
"the previous final frame becomes the next shot's "
"keyframe. Values above 1 keep that final-frame "
"keyframe and append the earlier tail frames as "
"claimed reference context, so the next beat can see "
"more of the incoming motion and room continuity. "
"The extra frames are references, not extra "
"keyframes, so seam trimming still removes only the "
"duplicated opening frame."}),
}, },
} }
@@ -7608,6 +7639,7 @@ class H3LongVideos:
character_guard=True, pace=1.0, auto_sound=True, hold_scene_state=True, character_guard=True, pace=1.0, auto_sound=True, hold_scene_state=True,
mouths_shut_when_no_line=True, hold_gaze=True, mouths_shut_when_no_line=True, hold_gaze=True,
ambient_audio=None, ambient_level=0.25, foley_level=0.35, ambient_audio=None, ambient_level=0.25, foley_level=0.35,
handoff_frames=1,
**_removed): **_removed):
# **_removed: a workflow saved with the old `save_defaults` widget still sends # **_removed: a workflow saved with the old `save_defaults` widget still sends
# it. Swallowed rather than raising, so an existing workflow keeps loading. # it. Swallowed rather than raising, so an existing workflow keeps loading.
@@ -7632,7 +7664,8 @@ class H3LongVideos:
shift_video=shift_video, shift_audio=shift_audio, shift_video=shift_video, shift_audio=shift_audio,
ref_noise_aug=ref_noise_aug, latent_upscale_scale=latent_upscale_scale, ref_noise_aug=ref_noise_aug, latent_upscale_scale=latent_upscale_scale,
upscale_target_short_edge=upscale_target_short_edge, upscale_target_short_edge=upscale_target_short_edge,
upscale_batch=upscale_batch, pace=pace)) upscale_batch=upscale_batch, pace=pace,
handoff_frames=handoff_frames))
megapixels, shot_seconds = _fixed["megapixels"], _fixed["shot_seconds"] megapixels, shot_seconds = _fixed["megapixels"], _fixed["shot_seconds"]
steps, cfg = _fixed["steps"], _fixed["cfg"] steps, cfg = _fixed["steps"], _fixed["cfg"]
shift_video, shift_audio = _fixed["shift_video"], _fixed["shift_audio"] shift_video, shift_audio = _fixed["shift_video"], _fixed["shift_audio"]
@@ -7640,6 +7673,7 @@ class H3LongVideos:
latent_upscale_scale = _fixed["latent_upscale_scale"] latent_upscale_scale = _fixed["latent_upscale_scale"]
upscale_target_short_edge = _fixed["upscale_target_short_edge"] upscale_target_short_edge = _fixed["upscale_target_short_edge"]
upscale_batch, pace = _fixed["upscale_batch"], _fixed["pace"] upscale_batch, pace = _fixed["upscale_batch"], _fixed["pace"]
handoff_frames = _fixed["handoff_frames"]
notes.extend(_fixnotes) notes.extend(_fixnotes)
# <Picture N> means ref_image_N, the socket. Everything downstream works on # <Picture N> means ref_image_N, the socket. Everything downstream works on
# the packed roster instead, so translate once, here, before anything has # the packed roster instead, so translate once, here, before anything has
@@ -10234,6 +10268,7 @@ class H3LongVideos:
negative = clip.encode_from_tokens_scheduled(clip.tokenize("")) negative = clip.encode_from_tokens_scheduled(clip.tokenize(""))
handoff = first_frame handoff = first_frame
handoff_context = None
# Where the time actually goes. Sampling and decode trade off against each # Where the time actually goes. Sampling and decode trade off against each
# other -- latent_upscale buys cheaper sampling and pays for it at decode, # other -- latent_upscale buys cheaper sampling and pays for it at decode,
# and which side wins depends on `steps`. Reported so the trade is a # and which side wins depends on `steps`. Reported so the trade is a
@@ -10365,6 +10400,20 @@ class H3LongVideos:
# here rather than inside build_conditioning because the claim is text, # here rather than inside build_conditioning because the claim is text,
# and the text is assembled up here. # and the text is assembled up here.
_shot_refs = list(shot_refs_all[i]) + _extra _shot_refs = list(shot_refs_all[i]) + _extra
_ctx_refs = []
_keyframe_ok = ref_noise_aug is None or float(ref_noise_aug) >= KEYFRAME_SAFE_AUG
if (handoff_frames > 1 and shot_handoff is not None and handoff_context is not None
and not _handoff_ref and _keyframe_ok):
try:
_ctx_refs = [handoff_context[j:j + 1]
for j in range(int(handoff_context.shape[0]))]
except Exception:
_ctx_refs = []
if _ctx_refs:
_first = len(_shot_refs) + 1
_last = _first + len(_ctx_refs) - 1
shot_prompt = shot_prompt + handoff_context_claim(_first, _last)
_shot_refs.extend(_ctx_refs)
if _handoff_ref: if _handoff_ref:
# Carried for the ROOM, with somebody new in the shot -- so the # Carried for the ROOM, with somebody new in the shot -- so the
# standing claim is exactly wrong here ("joined by anybody new") and # standing claim is exactly wrong here ("joined by anybody new") and
@@ -10380,7 +10429,7 @@ class H3LongVideos:
sent_text[i] = shot_prompt sent_text[i] = shot_prompt
cond, latent, fc, demoted = build_conditioning( cond, latent, fc, demoted = build_conditioning(
clip, vae, audio_vae, shot_prompt, w, h, lens[i], clip, vae, audio_vae, shot_prompt, w, h, lens[i],
handoff=shot_handoff, refs=list(shot_refs_all[i]) + _extra, handoff=shot_handoff, refs=_shot_refs,
ref_noise_aug=ref_noise_aug, silent=silent, ref_noise_aug=ref_noise_aug, silent=silent,
handoff_as_ref=_handoff_ref) handoff_as_ref=_handoff_ref)
if demoted and not _aug_warned: if demoted and not _aug_warned:
@@ -10457,6 +10506,16 @@ class H3LongVideos:
# 0..1, and feeding that back in to be re-encoded every boundary is a # 0..1, and feeding that back in to be re-encoded every boundary is a
# drift that accumulates rather than cancels. # drift that accumulates rather than cancels.
handoff = hand_src[-1:].detach().clamp(0.0, 1.0).to("cpu", copy=True) handoff = hand_src[-1:].detach().clamp(0.0, 1.0).to("cpu", copy=True)
handoff_context = None
if handoff_frames > 1:
try:
available = max(0, int(hand_src.shape[0]) - 1)
want = min(max(0, int(handoff_frames) - 1), available)
if want > 0:
handoff_context = hand_src[-(want + 1):-1].detach().clamp(
0.0, 1.0).to("cpu", copy=True)
except Exception:
handoff_context = None
# Keep a frame for the shot they come back on -- but ONLY from a shot that # Keep a frame for the shot they come back on -- but ONLY from a shot that
# was theirs alone. # was theirs alone.
# #
+74 -100
View File
@@ -8,32 +8,15 @@ const DEFAULT_W = 520;
const DEFAULT_H = 340; const DEFAULT_H = 340;
const DEFAULT_BEAT = "Describe this beat."; const DEFAULT_BEAT = "Describe this beat.";
const STATE_PROPERTY = "dumas_h3_beat_prompt_state"; 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 = { const MANAGED_DIRECTIVES = {
seconds: ["seconds", "duration"], remove: ["remove", "removed", "off"],
continuity: ["continuity"], add: ["add", "wear", "wearing"],
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 = [ const DIRECTIVE_EXAMPLES = [
["wardrobe set", "wardrobe: Maya = grey shorts, red jacket"], ["remove", "remove: red jacket"],
["wardrobe add", "wardrobe: Maya += red jacket"], ["off", "off: steel collar"],
["wardrobe remove", "wardrobe: Maya -= red jacket"], ["add", "add: white shirt underneath"],
["seconds", "seconds: 8"], ["wearing", "wearing: black coat"],
["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() { function injectCSS() {
@@ -183,7 +166,7 @@ function injectCSS() {
} }
function defaultState() { function defaultState() {
return { beats: [{ text: DEFAULT_BEAT }] }; return { scene: "", character_sheet: "", beats: [{ text: DEFAULT_BEAT }] };
} }
function normalizeState(value) { function normalizeState(value) {
@@ -200,7 +183,11 @@ function normalizeState(value) {
const normalized = beats.map((beat) => ({ const normalized = beats.map((beat) => ({
text: typeof beat?.text === "string" ? beat.text : String(beat?.text || ""), 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) { function readState(node) {
@@ -321,6 +308,51 @@ function renderUI(node) {
node._dh3bpRenderedState = JSON.stringify(state); node._dh3bpRenderedState = JSON.stringify(state);
ui.list.innerHTML = ""; 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) => { state.beats.forEach((beat, index) => {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "dh3bp-beat"; card.className = "dh3bp-beat";
@@ -379,85 +411,27 @@ function renderUI(node) {
return wrap; return wrap;
}; };
const secondsInput = document.createElement("input"); const removeInput = document.createElement("input");
secondsInput.className = "dh3bp-input"; removeInput.className = "dh3bp-input";
secondsInput.type = "text"; removeInput.type = "text";
secondsInput.placeholder = "8"; removeInput.placeholder = "red jacket";
secondsInput.value = readDirectiveValue(beat.text, MANAGED_DIRECTIVES.seconds); removeInput.value = readDirectiveValue(beat.text, MANAGED_DIRECTIVES.remove);
secondsInput.addEventListener("input", () => { removeInput.addEventListener("input", () => {
applyTextUpdate(setDirectiveValue(textarea.value, "seconds", MANAGED_DIRECTIVES.seconds, secondsInput.value)); applyTextUpdate(setDirectiveValue(textarea.value, "remove", MANAGED_DIRECTIVES.remove, removeInput.value));
}); });
const continuitySelect = document.createElement("select"); const addInput = document.createElement("input");
continuitySelect.className = "dh3bp-select"; addInput.className = "dh3bp-input";
CONTINUITY_OPTIONS.forEach((value) => { addInput.type = "text";
const option = document.createElement("option"); addInput.placeholder = "white shirt underneath";
option.value = value; addInput.value = readDirectiveValue(beat.text, MANAGED_DIRECTIVES.add);
option.textContent = value || "Default"; addInput.addEventListener("input", () => {
continuitySelect.appendChild(option); applyTextUpdate(setDirectiveValue(textarea.value, "add", MANAGED_DIRECTIVES.add, addInput.value));
});
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( controls.append(
buildField({ labelText: "Seconds", input: secondsInput }), buildField({ labelText: "Remove from memory", input: removeInput }),
buildField({ labelText: "Continuity", input: continuitySelect }), buildField({ labelText: "Add to memory", input: addInput }),
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"); const directives = document.createElement("div");
@@ -501,7 +475,7 @@ function setupNode(node) {
title.textContent = "Beat Prompt Builder"; title.textContent = "Beat Prompt Builder";
const subtitle = document.createElement("div"); const subtitle = document.createElement("div");
subtitle.className = "dh3bp-subtitle"; 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); titleWrap.append(title, subtitle);
const addButton = document.createElement("button"); 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") state = self.module._parse_beat_prompt_state("not json")
self.assertEqual( self.assertEqual(
state, 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( prompt = self.module._assemble_beat_prompt(
{ {
"scene": "A rainy kitchen at night.",
"character_sheet": "Maya: 27, she, red jacket, silver hair.",
"beats": [ "beats": [
{"text": "A woman enters the room."}, {"text": "Maya enters the room."},
{"text": "wardrobe: Maya = red jacket\nShe sits at the table."}, {"text": "remove: red jacket\nadd: white shirt underneath\nShe sits at the table."},
{"text": " "}, {"text": " "},
{"text": "music: low synth pulse"},
] ]
} }
) )
self.assertEqual( self.assertEqual(
prompt, prompt,
( (
"A woman enters the room.\n\n" "A rainy kitchen at night.\n\n"
"wardrobe: Maya = red jacket\nShe sits at the table.\n\n" "Maya: 27, she, red jacket, silver hair.\n\n"
"music: low synth pulse" "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): def test_node_build_prompt_uses_hidden_state(self):
node = self.module.DumasH3BeatPromptNode() node = self.module.DumasH3BeatPromptNode()
result = node.build_prompt( 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__": if __name__ == "__main__":
+9
View File
@@ -112,8 +112,17 @@ class DumasH3LongVideosUpstreamWrapperTests(unittest.TestCase):
self.assertIn("first_frame", schema["optional"]) self.assertIn("first_frame", schema["optional"])
self.assertIn("ref_image_1", schema["optional"]) self.assertIn("ref_image_1", schema["optional"])
self.assertIn("latent_upscale", schema["optional"]) self.assertIn("latent_upscale", schema["optional"])
self.assertIn("handoff_frames", schema["optional"])
self.assertEqual(schema["optional"]["handoff_frames"][1]["default"], 1)
self.assertEqual(node_cls.RETURN_NAMES[0:4], ("images", "audio", "info", "script")) self.assertEqual(node_cls.RETURN_NAMES[0:4], ("images", "audio", "info", "script"))
def test_handoff_context_claim_names_reference_range(self):
upstream = importlib.import_module("dumas_h3_longvideos_upstream")
self.assertIn("<Picture 2> through <Picture 22>", upstream.handoff_context_claim(2, 22))
self.assertIn("no new subjects", upstream.handoff_context_claim(2, 22))
self.assertIn("<Picture 5>", upstream.handoff_context_claim(5, 5))
class _NullContext: class _NullContext:
def __enter__(self): def __enter__(self):