Compare commits

..
4 Commits
13 changed files with 13068 additions and 10047 deletions
+53
View File
@@ -0,0 +1,53 @@
H3-LongVideos — Licence
Copyright (c) 2026 Smite79. All rights reserved.
This licence applies to every version published on or after 2026-09-07.
WHAT YOU MAY DO
1. Download and use this software, in unmodified or modified form, for your
own purposes, personal or commercial. Rendering with it, and whatever you
render with it, is yours and is not covered by this licence.
2. Modify your own copy.
3. Submit changes back to the original project.
WHAT YOU MAY NOT DO WITHOUT WRITTEN PERMISSION
4. Redistribute this software, in whole or in part, modified or unmodified.
That includes publishing it to any repository, registry, model hub, node
manager, marketplace, or mirror; bundling it inside another package,
product, image, or installer; and hosting it as a service.
5. Remove, alter, or obscure the copyright notice above, this licence, or the
attribution in the source files — including where a permitted redistribution
has been agreed.
6. Represent this software, or a derivative of it, as your own work.
ASKING
Permission for anything under 4 is granted case by case and is usually given
for things like inclusion in a node manager. Ask via the project's GitHub
issues at https://github.com/Smite79/MiniMax-H3-LongVideos.
EARLIER VERSIONS
Versions published before 2026-09-07 were released under Apache License 2.0.
That grant is irrevocable for those versions: copies obtained under it stay
under it, and this licence does not and cannot withdraw it retroactively. It
governs this version and every version after it.
Apache 2.0 also required attribution, so a copy of an earlier version
republished with the copyright notice stripped was already in breach of the
licence it was taken under.
NO WARRANTY
This software is provided "as is", without warranty of any kind, express or
implied, including but not limited to the warranties of merchantability,
fitness for a particular purpose, and non-infringement. In no event shall the
copyright holder be liable for any claim, damages, or other liability, whether
in an action of contract, tort, or otherwise, arising from, out of, or in
connection with the software or the use or other dealings in the software.
+12 -15
View File
@@ -33,18 +33,13 @@
- Outputs: `plan`, `image1`..`image9`, `connected_images` - Outputs: `plan`, `image1`..`image9`, `connected_images`
- Reads back the nine optional images for a selected MiniMax H3 plan scene, for example by connecting the current `clip_index`. - Reads back the nine optional images for a selected MiniMax H3 plan scene, for example by connecting the current `clip_index`.
- `Dumas H3 Long Videos (FL2VA + REF2VA)` - `Dumas H3 Long Videos`
- Inputs: H3 model stack, prompt socket, optional `first_frame`, optional `ref_1`..`ref_9`, plus the upstream long-video control surface for pacing, continuity, audio, overlays, and guards - Inputs/outputs: the current upstream `MiniMax-H3-Longvideos` sampler surface, exposed under the existing `DumasH3LongVideos` key for saved Dumas workflows.
- Outputs: `images`, `audio`, `info`, `script`, `frames_per_shot`, `total_frames`, `shots`, `video_seconds`, `fps`, `fps_int`, `latent`, `soundscape` - 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.
- First-pass Dumas port of the `MiniMax-H3-Longvideos` sampler, brought in as a local starting point for long-form H3 chaining work. - Upstream compatibility keys `H3LongVideos`, `H3LongVideosFL2VA`, `H3LongVideosV1`, and `H3LongVideosREF2VA` are also registered to the same class.
- Keeps the upstream split-beats / handoff / ref-routing behavior close to source so future Dumas-specific improvements can be compared against a known baseline. - The old Dumas browser widget grouping script is disabled for this node because it targeted controls that no longer exist on the upstream sampler.
- Full user guide: [`H3_LONG_VIDEOS_GUIDE.md`](./H3_LONG_VIDEOS_GUIDE.md) - `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.
- Only the canonical `DumasH3LongVideos` node key is exposed now; the older FL2VA/REF2VA alias entries are no longer duplicated in the Add Node menu. - Upstream license text is included in [`H3_LONGVIDEOS_UPSTREAM_LICENSE.txt`](./H3_LONGVIDEOS_UPSTREAM_LICENSE.txt).
- Prompt `<Picture N>` tags now map to the actual ref socket numbers you wire, even with gaps such as only `ref_2` and `ref_7` connected.
- Character refs now contribute appearance and wardrobe context from the same structured object, while location refs contribute environment context from theirs.
- The default ref2v bias is now stronger: `ref_mode` defaults to `auto ref2v` so untagged prompts condition every shot instead of only shot 1, and `ref_noise_aug` defaults to `0.95` rather than the upstream-literal `0.999`.
- `Dumas H3 Latent Upscale Params` provides the optional pre-decode latent refinement stage for the long-video node.
- Per-shot directives now support `continuity:`, `ref_mode:`, `ref_noise_aug:`, `anchor_add:`, `soundscape:`, and `music:` in addition to the existing timing and wardrobe directives.
- `Dumas H3 Latent Upscale Params` - `Dumas H3 Latent Upscale Params`
- Inputs: `mode`, `model_name`, `method`, `width`, `height`, `device`, `precision`, `sampler_name`, `scheduler`, `steps`, `denoise`, `megapixels`, `tile_width`, `tile_height`, `overlap`, `fade_width`, `fade_height`, `overlap_mode`, `overlap_blend`, `tile_size_mode`, `grid_rows`, `grid_cols`, `spatial_w_overlap`, `spatial_h_overlap`, `min_tile_size`, `masked_area_noise`, `brightness_match`, `dynamic_fade`, `dynamic_fade_min`, `chunk_length`, `temporal_overlap`, `resize_conditioning`, `anchor_strength` - Inputs: `mode`, `model_name`, `method`, `width`, `height`, `device`, `precision`, `sampler_name`, `scheduler`, `steps`, `denoise`, `megapixels`, `tile_width`, `tile_height`, `overlap`, `fade_width`, `fade_height`, `overlap_mode`, `overlap_blend`, `tile_size_mode`, `grid_rows`, `grid_cols`, `spatial_w_overlap`, `spatial_h_overlap`, `min_tile_size`, `masked_area_noise`, `brightness_match`, `dynamic_fade`, `dynamic_fade_min`, `chunk_length`, `temporal_overlap`, `resize_conditioning`, `anchor_strength`
@@ -54,14 +49,16 @@
- `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`
- Outputs: `prompt`, `ref_image_1` through `ref_image_9`, `reference_count`, `debug`, `anchor`, `sounds`, `bgm`, `original_ref_1` through `original_ref_9` - Outputs: `prompt`, `ref_image_1` through `ref_image_9`, `reference_count`, `debug`, `anchor`, `sounds`, `bgm`, `original_ref_1` through `original_ref_9`, `compiled_ref_description_1` through `compiled_ref_description_9`
- Builds one standalone MiniMax H3 prompt from your final action text plus structured character/location references. - Builds one standalone MiniMax H3 prompt from your final action text plus structured character/location references.
- The action text can mention references by character/location name, alias, `<Picture N>`, or `<refN>`. Only mentioned references are emitted, and the output images are compacted/renumbered so skipped inputs do not leave gaps. - The action text can mention references by character/location name, alias, `<Picture N>`, or `<refN>`. Only mentioned references are emitted, and the output images are compacted/renumbered so skipped inputs do not leave gaps.
- Extra component outputs expose the cleaned anchor, sounds, BGM, and each selected original reference image in compacted order. - Extra component outputs expose the cleaned anchor, sounds, BGM, and each selected original reference image plus its compiled reference description in compacted order.
- Adds curated reference context, anatomy guard text, optional subject-count guard text, anchor/style text, `overall_soundscape:` text, and `background_music:` text while respecting MiniMax H3's reference-generation shape: one prompt plus up to nine reference images. - Adds curated reference context, anatomy guard text, optional subject-count guard text, anchor/style text, `overall_soundscape:` text, and `background_music:` text while respecting MiniMax H3's reference-generation shape: one prompt plus up to nine reference images.
- `Dumas H3 Shot Length` - `Dumas H3 Shot Length`
+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",)
+23 -7916
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18 -2
View File
@@ -1253,11 +1253,13 @@ def curate_h3_prompt(
soundscape_text = _reference_text(soundscape) soundscape_text = _reference_text(soundscape)
bgm_text = _reference_text(bgm) bgm_text = _reference_text(bgm)
reference_description = "" reference_description = ""
individual_reference_descriptions = []
if selected: if selected:
reference_description = " ".join( individual_reference_descriptions = [
_reference_context(ref, picture_number) _reference_context(ref, picture_number)
for picture_number, (_slot, ref) in enumerate(selected, 1) for picture_number, (_slot, ref) in enumerate(selected, 1)
) ]
reference_description = " ".join(individual_reference_descriptions)
prompt_parts = [] prompt_parts = []
_append_prompt_section(prompt_parts, "Scene anchor", anchor_text) _append_prompt_section(prompt_parts, "Scene anchor", anchor_text)
@@ -1277,6 +1279,9 @@ def curate_h3_prompt(
prompt = prompt[: _H3_PROMPT_MAX_CHARS - 3].rstrip() + "..." prompt = prompt[: _H3_PROMPT_MAX_CHARS - 3].rstrip() + "..."
images = [_reference_image(ref) for _slot, ref in selected] images = [_reference_image(ref) for _slot, ref in selected]
images.extend([None] * (_H3_PROMPT_REF_SLOTS - len(images))) images.extend([None] * (_H3_PROMPT_REF_SLOTS - len(images)))
individual_reference_descriptions.extend(
[""] * (_H3_PROMPT_REF_SLOTS - len(individual_reference_descriptions))
)
debug = ( debug = (
f"Selected {len(selected)} reference(s): " f"Selected {len(selected)} reference(s): "
+ ", ".join( + ", ".join(
@@ -1295,6 +1300,7 @@ def curate_h3_prompt(
soundscape_text, soundscape_text,
bgm_text, bgm_text,
*images[:_H3_PROMPT_REF_SLOTS], *images[:_H3_PROMPT_REF_SLOTS],
*individual_reference_descriptions[:_H3_PROMPT_REF_SLOTS],
) )
@@ -2687,6 +2693,7 @@ class DumasH3PromptCuratorNode:
+ ("IMAGE",) * _H3_PROMPT_REF_SLOTS + ("IMAGE",) * _H3_PROMPT_REF_SLOTS
+ ("INT", "STRING", "STRING", "STRING", "STRING") + ("INT", "STRING", "STRING", "STRING", "STRING")
+ ("IMAGE",) * _H3_PROMPT_REF_SLOTS + ("IMAGE",) * _H3_PROMPT_REF_SLOTS
+ ("STRING",) * _H3_PROMPT_REF_SLOTS
) )
RETURN_NAMES = ( RETURN_NAMES = (
"prompt", "prompt",
@@ -2713,6 +2720,15 @@ class DumasH3PromptCuratorNode:
"original_ref_7", "original_ref_7",
"original_ref_8", "original_ref_8",
"original_ref_9", "original_ref_9",
"compiled_ref_description_1",
"compiled_ref_description_2",
"compiled_ref_description_3",
"compiled_ref_description_4",
"compiled_ref_description_5",
"compiled_ref_description_6",
"compiled_ref_description_7",
"compiled_ref_description_8",
"compiled_ref_description_9",
) )
FUNCTION = "curate_prompt" FUNCTION = "curate_prompt"
CATEGORY = "Dumas/MiniMax" CATEGORY = "Dumas/MiniMax"
+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");
+5 -371
View File
@@ -1,375 +1,9 @@
import { app } from "/scripts/app.js"; import { app } from "/scripts/app.js";
import { applyAdaptiveCanvasOnly } from "../shared/nodes2.mjs";
const COMFY_CLASS = "DumasH3LongVideos";
const STATE_PROPERTY = "dumas_h3_longvideos_section_state";
const DOM_WIDGET_NAME = "dumas_h3_longvideos_sections";
const MIN_WIDTH = 520;
const MIN_HEIGHT = 280;
const GROUPS = [
{
id: "prompt",
label: "Prompt",
defaultCollapsed: false,
widgets: ["prompt", "resolution", "megapixels", "beat_split", "anchor_override", "shot_seconds", "plan_only", "fps"],
},
{
id: "refs",
label: "Refs",
defaultCollapsed: true,
widgets: ["ref_mode", "ref_image_size", "ref_noise_aug", "character_memory", "trim_seam", "vary_seed_per_shot", "handoff_offset"],
},
{
id: "sampling",
label: "Sampling",
defaultCollapsed: true,
widgets: [
"steps", "cfg", "sampler_name", "scheduler", "seed",
"apply_model_sampling", "shift_video", "shift_audio",
"vram_headroom_gb", "allow_res_backoff",
"decode_tile_frames", "decode_tile_size",
],
},
{
id: "audio",
label: "Audio",
defaultCollapsed: true,
widgets: [
"global_soundscape", "non_diegetic_music", "auto_soundscape",
"auto_silence_nonspeech", "allow_nonspeech_vocals",
"mute_nonspeech_audio", "mute_fade_ms",
],
},
{
id: "scene",
label: "Scene Logic",
defaultCollapsed: true,
widgets: [
"auto_wardrobe", "auto_props", "prevent_nudity", "exposed_terms",
"anatomy_guard", "subject_count_guard", "lock_restraints",
"contact_guard", "motion_guard", "solidity_guard",
],
},
{
id: "finish",
label: "Upscale",
defaultCollapsed: true,
widgets: [
"upscale", "upscale_model", "upscale_target_short_edge", "upscale_batch",
],
},
{
id: "overlay",
label: "Overlays",
defaultCollapsed: true,
widgets: [
"watermark_text", "watermark_position", "watermark_size", "watermark_opacity", "watermark_margin",
"intro_text", "intro_position", "intro_seconds", "intro_fade", "intro_size",
"overlay_font", "overlay_stroke",
],
},
];
function injectCSS() {
if (document.getElementById("dumas-h3lv-sections-css")) return;
const style = document.createElement("style");
style.id = "dumas-h3lv-sections-css";
style.textContent = `
.dh3lv-sections {
box-sizing: border-box;
width: 100%;
padding: 8px 10px 6px;
color: #e6e7eb;
font: 12px/1.35 "Segoe UI", sans-serif;
pointer-events: auto;
background: linear-gradient(180deg, rgba(33, 36, 42, 0.96), rgba(22, 24, 29, 0.96));
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.dh3lv-sections-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.dh3lv-sections-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #9da5b1;
}
.dh3lv-sections-actions {
display: flex;
gap: 6px;
}
.dh3lv-sections-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.dh3lv-chip,
.dh3lv-action {
appearance: none;
border: 1px solid #464d59;
background: #262c35;
color: #d7dce3;
border-radius: 999px;
padding: 5px 9px;
cursor: pointer;
font: inherit;
line-height: 1.1;
}
.dh3lv-chip[data-open="true"] {
background: #d96f2b;
border-color: #f09358;
color: #fff7f0;
}
.dh3lv-chip:hover,
.dh3lv-action:hover {
filter: brightness(1.06);
}
.dh3lv-count {
opacity: 0.78;
margin-left: 4px;
font-size: 11px;
}
`;
document.head.appendChild(style);
}
function defaultState() {
const state = {};
for (const group of GROUPS) state[group.id] = !group.defaultCollapsed;
return state;
}
function parseState(value) {
let parsed = value;
if (typeof parsed === "string") {
try {
parsed = JSON.parse(parsed);
} catch (_error) {
parsed = null;
}
}
const base = defaultState();
if (!parsed || typeof parsed !== "object") return base;
for (const group of GROUPS) {
if (typeof parsed[group.id] === "boolean") base[group.id] = parsed[group.id];
}
return base;
}
function readState(node) {
return parseState(node.properties?.[STATE_PROPERTY] || node._dh3lvSectionState || "");
}
function writeState(node, state) {
const normalized = parseState(state);
const serialized = JSON.stringify(normalized);
node._dh3lvSectionState = serialized;
node.properties = node.properties || {};
node.properties[STATE_PROPERTY] = serialized;
}
function findWidget(node, name) {
return (node.widgets || []).find((widget) => widget?.name === name) || null;
}
function isInteractiveTarget(target) {
return !!target?.closest?.("button, input, textarea, select, label");
}
function stopCanvasEvent(event) {
if (isInteractiveTarget(event.target)) event.stopPropagation();
}
function stopCanvasKeyboard(event) {
if (isInteractiveTarget(event.target)) event.stopImmediatePropagation();
}
function setWidgetHidden(widget, hidden) {
if (!widget) return;
if (!widget._dh3lvOriginal) {
widget._dh3lvOriginal = {
type: widget.type,
computeSize: widget.computeSize,
hidden: widget.hidden,
};
}
if (hidden) {
widget.type = "hidden";
widget.hidden = true;
widget.computeSize = () => [0, -4];
return;
}
widget.type = widget._dh3lvOriginal.type;
widget.hidden = !!widget._dh3lvOriginal.hidden;
widget.computeSize = widget._dh3lvOriginal.computeSize;
}
function applyVisibility(node) {
const state = readState(node);
for (const group of GROUPS) {
for (const name of group.widgets) {
const widget = findWidget(node, name);
if (!widget || widget.name === DOM_WIDGET_NAME) continue;
setWidgetHidden(widget, !state[group.id]);
}
}
}
function resizeNode(node) {
requestAnimationFrame(() => {
const size = node.computeSize?.();
if (Array.isArray(size)) {
node.size[0] = Math.max(MIN_WIDTH, size[0] || 0, node.size?.[0] || 0);
node.size[1] = Math.max(MIN_HEIGHT, size[1] || 0);
}
node.setDirtyCanvas?.(true, true);
});
}
function renderToolbar(node) {
const ui = node._dh3lvUI;
if (!ui) return;
const state = readState(node);
ui.list.innerHTML = "";
for (const group of GROUPS) {
const button = document.createElement("button");
button.type = "button";
button.className = "dh3lv-chip";
button.dataset.open = state[group.id] ? "true" : "false";
button.textContent = state[group.id] ? `Hide ${group.label}` : `Show ${group.label}`;
const count = document.createElement("span");
count.className = "dh3lv-count";
count.textContent = String(group.widgets.filter((name) => findWidget(node, name)).length);
button.appendChild(count);
button.addEventListener("click", () => {
const next = readState(node);
next[group.id] = !next[group.id];
writeState(node, next);
applyVisibility(node);
renderToolbar(node);
resizeNode(node);
});
ui.list.appendChild(button);
}
}
function setAll(node, open) {
const next = {};
for (const group of GROUPS) next[group.id] = !!open;
writeState(node, next);
applyVisibility(node);
renderToolbar(node);
resizeNode(node);
}
function setupNode(node) {
if (node._dh3lvUI) return;
injectCSS();
writeState(node, readState(node));
const root = document.createElement("div");
root.className = "dh3lv-sections";
const head = document.createElement("div");
head.className = "dh3lv-sections-head";
const title = document.createElement("div");
title.className = "dh3lv-sections-title";
title.textContent = "Sections";
const actions = document.createElement("div");
actions.className = "dh3lv-sections-actions";
const expandAll = document.createElement("button");
expandAll.type = "button";
expandAll.className = "dh3lv-action";
expandAll.textContent = "Expand All";
expandAll.addEventListener("click", () => setAll(node, true));
const collapseAll = document.createElement("button");
collapseAll.type = "button";
collapseAll.className = "dh3lv-action";
collapseAll.textContent = "Collapse Extras";
collapseAll.addEventListener("click", () => {
const next = defaultState();
writeState(node, next);
applyVisibility(node);
renderToolbar(node);
resizeNode(node);
});
actions.append(expandAll, collapseAll);
head.append(title, actions);
const list = document.createElement("div");
list.className = "dh3lv-sections-list";
root.append(head, list);
root.addEventListener("pointerdown", stopCanvasEvent);
root.addEventListener("mousedown", stopCanvasEvent);
root.addEventListener("click", stopCanvasEvent);
root.addEventListener("dblclick", stopCanvasEvent);
root.addEventListener("keydown", stopCanvasKeyboard, true);
node._dh3lvUI = { root, list };
const widget = node.addDOMWidget(DOM_WIDGET_NAME, "custom", root, {
getValue: () => null,
setValue: () => {},
serialize: false,
getMinHeight: () => 52,
hideOnZoom: false,
});
applyAdaptiveCanvasOnly(widget);
const widgets = node.widgets || [];
const index = widgets.indexOf(widget);
if (index > 0) {
widgets.splice(index, 1);
widgets.unshift(widget);
}
applyVisibility(node);
renderToolbar(node);
resizeNode(node);
}
// The Dumas Long Videos node now wraps the upstream MiniMax-H3-Longvideos
// sampler directly. The old local frontend grouped Dumas-specific widgets that
// no longer exist on the upstream node, so this extension intentionally does
// nothing.
app.registerExtension({ app.registerExtension({
name: "Dumas.H3LongVideosSections", name: "Dumas.H3LongVideos.UpstreamWrapper",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== COMFY_CLASS) return;
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function onNodeCreated() {
const result = originalOnNodeCreated?.apply(this, arguments);
setupNode(this);
return result;
};
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function onConfigure() {
const result = originalConfigure?.apply(this, arguments);
setupNode(this);
writeState(this, readState(this));
applyVisibility(this);
renderToolbar(this);
resizeNode(this);
return result;
};
const originalSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function onSerialize(o) {
writeState(this, readState(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];
}
return result;
};
},
}); });
+9
View File
@@ -26,6 +26,15 @@ const EXPECTED_OUTPUTS = [
"original_ref_7", "original_ref_7",
"original_ref_8", "original_ref_8",
"original_ref_9", "original_ref_9",
"compiled_ref_description_1",
"compiled_ref_description_2",
"compiled_ref_description_3",
"compiled_ref_description_4",
"compiled_ref_description_5",
"compiled_ref_description_6",
"compiled_ref_description_7",
"compiled_ref_description_8",
"compiled_ref_description_9",
]; ];
const EXPECTED_NAMES = new Set(EXPECTED_OUTPUTS); const EXPECTED_NAMES = new Set(EXPECTED_OUTPUTS);
+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__":
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -505,6 +505,11 @@ class DumasImageNodeTests(unittest.TestCase):
self.assertIs(result[15], dave_image) self.assertIs(result[15], dave_image)
self.assertIs(result[16], cafe_image) self.assertIs(result[16], cafe_image)
self.assertIsNone(result[17]) self.assertIsNone(result[17])
self.assertIn("<Picture 1> Dave", result[24])
self.assertNotIn("<Picture 2> Coffee Shop", result[24])
self.assertIn("<Picture 2> Coffee Shop", result[25])
self.assertIn("Location context for <Picture 2> Coffee Shop", result[25])
self.assertEqual(result[26], "")
def test_h3_prompt_curator_renumbers_explicit_reference_tags(self): def test_h3_prompt_curator_renumbers_explicit_reference_tags(self):
node = self.image_nodes.DumasH3PromptCuratorNode() node = self.image_nodes.DumasH3PromptCuratorNode()
@@ -642,10 +647,14 @@ class DumasImageNodeTests(unittest.TestCase):
def test_h3_prompt_curator_uses_documented_reference_limits(self): def test_h3_prompt_curator_uses_documented_reference_limits(self):
node = self.image_nodes.DumasH3PromptCuratorNode() node = self.image_nodes.DumasH3PromptCuratorNode()
self.assertEqual(len(node.RETURN_TYPES), 24) self.assertEqual(len(node.RETURN_TYPES), 33)
self.assertEqual(node.RETURN_NAMES[1:10], tuple(f"ref_image_{i}" for i in range(1, 10))) self.assertEqual(node.RETURN_NAMES[1:10], tuple(f"ref_image_{i}" for i in range(1, 10)))
self.assertEqual(node.RETURN_NAMES[12:15], ("anchor", "sounds", "bgm")) self.assertEqual(node.RETURN_NAMES[12:15], ("anchor", "sounds", "bgm"))
self.assertEqual(node.RETURN_NAMES[15:24], tuple(f"original_ref_{i}" for i in range(1, 10))) self.assertEqual(node.RETURN_NAMES[15:24], tuple(f"original_ref_{i}" for i in range(1, 10)))
self.assertEqual(
node.RETURN_NAMES[24:33],
tuple(f"compiled_ref_description_{i}" for i in range(1, 10)),
)
def test_normalize_reference_upgrades_generic_summary_with_socket_picture_id(self): def test_normalize_reference_upgrades_generic_summary_with_socket_picture_id(self):
image = FakeTensorBatch() image = FakeTensorBatch()