Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9923a3e417 | ||
|
|
ea6fae7e56 | ||
|
|
72f9b07ed8 | ||
|
|
8d4232a142 | ||
|
|
d68d04254f | ||
|
|
22398c5213 | ||
|
|
1a4cfbfd7e | ||
|
|
eac4e73b47 | ||
|
|
e973a0d785 | ||
|
|
388de1d837 | ||
|
|
338150648b | ||
|
|
7de62226f2 | ||
|
|
a51141cb26 | ||
|
|
dd8ef84379 | ||
|
|
d20b257134 | ||
|
|
173205ca51 | ||
|
|
98833ba99d | ||
|
|
a7f7225b3a | ||
|
|
5dc5c2ce8b | ||
|
|
83645811f4 | ||
|
|
216bc05762 | ||
|
|
49e099ee7f | ||
|
|
32a16645b5 | ||
|
|
7bb0b0abea | ||
|
|
0b189dcf7a | ||
|
|
e8599055a1 | ||
|
|
618a48e4d9 | ||
|
|
cbabcf8208 | ||
|
|
fbfbe4ef5a | ||
|
|
5b52793fad | ||
|
|
7e45c81589 | ||
|
|
027865b9ca | ||
|
|
9f0c546681 | ||
|
|
30ae81b86b | ||
|
|
179fa0778e | ||
|
|
a34c9eb4ff | ||
|
|
782a7d658b | ||
|
|
04a61af874 | ||
|
|
a0d80bcc47 | ||
|
|
fbb5f799cd | ||
|
|
9dc3c405a6 | ||
|
|
21ae4d62e0 | ||
|
|
836a6eba33 | ||
|
|
3b63d33ec7 | ||
|
|
c62921c3e8 | ||
|
|
3ab6342ce5 | ||
|
|
51e03a39b7 | ||
|
|
f7b94ccaef | ||
|
|
f6120a8500 | ||
|
|
0e119646ab | ||
|
|
c89570eae9 | ||
|
|
36d9f4369c | ||
|
|
2cb3c694f0 | ||
|
|
5c12cd18a3 | ||
|
|
c1d937e0e2 | ||
|
|
306816534b | ||
|
|
e098e44bf1 | ||
|
|
769257513b | ||
|
|
ab6ba86d76 | ||
|
|
ecd8ca6336 | ||
|
|
76b88b0c59 | ||
|
|
62fbbdd98e | ||
|
|
85e15c910a | ||
|
|
30400038c2 | ||
|
|
c8ddedbd57 | ||
|
|
6684c5f2e0 | ||
|
|
08630964d5 | ||
|
|
271e7de300 | ||
|
|
130b268d1e | ||
|
|
a2dabaab7e | ||
|
|
b7c3bba199 | ||
|
|
35ec12159e | ||
|
|
58b1c77c19 | ||
|
|
4a0f5cfcd4 | ||
|
|
6a7bec5d03 | ||
|
|
dfead66b9e | ||
|
|
00985b73b3 | ||
|
|
ffae1d87a5 | ||
|
|
74aca203ab | ||
|
|
e08281c12b | ||
|
|
faaeb374e0 | ||
|
|
0bac83a689 | ||
|
|
08365c7df5 | ||
|
|
9cbab72e86 | ||
|
|
e29d01ad24 | ||
|
|
ddd7dbb5af | ||
|
|
1ad68ee9e4 | ||
|
|
e64ad0fae4 | ||
|
|
deb6f1d033 | ||
|
|
72d4d74dd3 | ||
|
|
c42cf436c5 | ||
|
|
eeca789c27 |
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,21 +33,33 @@
|
||||
- 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`.
|
||||
|
||||
- `Dumas H3 Long Videos (FL2VA + REF2VA)`
|
||||
- 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
|
||||
- Outputs: `images`, `audio`, `info`, `script`, `frames_per_shot`, `total_frames`, `shots`, `video_seconds`, `fps`, `fps_int`, `latent`, `soundscape`
|
||||
- First-pass Dumas port of the `MiniMax-H3-Longvideos` sampler, brought in as a local starting point for long-form H3 chaining work.
|
||||
- Keeps the upstream split-beats / handoff / ref-routing behavior close to source so future Dumas-specific improvements can be compared against a known baseline.
|
||||
- Only the canonical `DumasH3LongVideos` node key is exposed now; the older FL2VA/REF2VA alias entries are no longer duplicated in the Add Node menu.
|
||||
- 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`.
|
||||
- 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 Long Videos`
|
||||
- Inputs/outputs: the current upstream `MiniMax-H3-Longvideos` sampler surface, exposed under the existing `DumasH3LongVideos` key for saved Dumas workflows.
|
||||
- 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.
|
||||
- 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).
|
||||
|
||||
- `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`
|
||||
- Output: `latent_upscale_param`
|
||||
- Bundles the optional latent-space upscaler settings used by `Dumas H3 Long Videos` before decode, so the main node can rebuild conditioning at the target size and run a short refinement pass with your chosen sampler, scheduler, step count, denoise, and the full upstream spatial split controls.
|
||||
|
||||
- `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`
|
||||
- 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.
|
||||
- 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 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.
|
||||
|
||||
- `Dumas H3 Shot Length`
|
||||
- Inputs: `shot_seconds`, `fps`, optional `cap_to_h3_max`
|
||||
@@ -60,21 +72,43 @@
|
||||
- Reports the detected H3 base precision / quant format and the relevant compute-capability hints for the current card.
|
||||
|
||||
- `Dumas Character Reference`
|
||||
- Inputs: `image`, `picture_id`, `character_id`, `name`, `alias`, `gender`, `age`, `nationality`, `occupation`, `height_feet`, `height_inches`, `accent`, `description`, `general`, `wardrobe`
|
||||
- Inputs: `image`, `character_id`, `name`, `alias`, `gender`, `age`, `nationality`, `occupation`, `height_feet`, `height_inches`, `accent`, `description`, `general`, `wardrobe`
|
||||
- Output: `reference`
|
||||
- Builds one structured `REFERENCE` object carrying the conditioning image, identity description, wardrobe, general notes, and simple facts together.
|
||||
|
||||
- `Dumas Location Reference`
|
||||
- Inputs: `image`, `picture_id`, `location_id`, `name`, `alias`, `description`, `general`
|
||||
- Inputs: `image`, `location_id`, `name`, `alias`, `description`, `general`
|
||||
- Output: `reference`
|
||||
- Builds one structured `REFERENCE` object for a location/environment so H3 can use the same socket type for both character and scenic refs.
|
||||
|
||||
- `Dumas Character Helper`
|
||||
- Inputs: `image1`, `image2`, picture IDs, character identity fields, `general`, `wardrobe`
|
||||
- Outputs: `image1`, `image2`, `reference_prompt`, `wardrobe`, `reference1`, `reference2`
|
||||
- Restores the original general-purpose helper shape while also emitting two structured `REFERENCE` objects for the prompt curator.
|
||||
- The structured references carry the same character name, alias, age, height, gender, nationality, occupation, accent, wardrobe, and notes, so mentioning the character name in `Dumas H3 Prompt Curator` can include both helper images and the character facts automatically.
|
||||
|
||||
- `Dumas Location Helper`
|
||||
- Inputs: `image1`, `image2`, picture IDs, `location_id`, `name`, `alias`, `description`, `general`
|
||||
- Outputs: `image1`, `image2`, `reference_prompt`, `reference1`, `reference2`
|
||||
- Matching general-purpose helper for environments/locations: pass two images through unchanged, emit location reference prompt text, and provide two structured `REFERENCE` objects for the prompt curator.
|
||||
- The structured references carry the same location name, alias, description, and notes, so mentioning the location name in `Dumas H3 Prompt Curator` can include both helper images and the location context automatically.
|
||||
|
||||
- `Dumas Anchor Style`
|
||||
- Inputs: `anchor_style`, `style_description`
|
||||
- Output: `anchor`
|
||||
- Offers a large preset dropdown of anchor-style titles such as cinematic action movie, comedy, found footage, 90s sitcom, mobile/cell phone captured, news broadcast, mockumentary, heist thriller, cyberpunk neon, nature documentary, courtroom drama, and more.
|
||||
- The preset wording is tuned for H3-safe persistent anchors: camera language, lighting, texture, production treatment, and tone, without naming characters or describing one-off actions.
|
||||
- Selecting a preset fills the editable description field, and the edited multiline description is the `STRING` value passed downstream into H3 anchor sockets such as `anchor_override`.
|
||||
- Selecting a preset fills the editable description field, and the edited multiline description is the `STRING` value passed downstream.
|
||||
|
||||
- `Dumas Soundscape Helper`
|
||||
- Inputs: `soundscape`, `soundscape_description`
|
||||
- Output: `soundscape`
|
||||
- Matching soundscape helper for standalone H3 prompts. Pick a preset such as quiet interior, rainy street, cafe, city night, forest, industrial, or silent; the preset fills the editable textbox, and the edited text flows into `Dumas H3 Prompt Curator`.
|
||||
|
||||
- `Dumas Background Music Helper`
|
||||
- Inputs: `bgm`, `bgm_description`
|
||||
- Output: `bgm`
|
||||
- Matching BGM helper for standalone H3 prompts. Pick a preset such as subtle tension, cinematic suspense, emotional piano, dark ambient, hopeful orchestral, retro synth, action pulse, lo-fi, or no vocals; the preset fills the editable textbox, and the edited text flows into `Dumas H3 Prompt Curator`.
|
||||
|
||||
- `Dumas JSON String to Object`
|
||||
- Input: `json_string`
|
||||
@@ -232,7 +266,7 @@ decr -> use index - 1
|
||||
|
||||
`Dumas H3 Plan Attach Scene Images` and `Dumas H3 Plan Extract Scene Images` are a companion pair for `ComfyUI-MiniMaxH3-Contex-Loop` and the local `ref2v` lane. The upstream H3 plan node cannot dynamically grow nine new image sockets for every JSON-defined scene, so Dumas stores scene image bindings beside the plan using a lightweight token and an in-memory registry. That keeps `plan.json` archiving intact while still letting you wire up nine IMAGE sockets per scene through chained helper nodes.
|
||||
|
||||
`Dumas Character Reference` and `Dumas Location Reference` live in `Dumas/MiniMax`. Both output a structured `REFERENCE` object that carries the image plus its semantic payload. `Dumas H3 Long Videos` accepts those `REFERENCE` sockets directly on `ref_1`..`ref_9`, resolves `<Picture N>` against the wired slot positions, and can also pull character wardrobe context from the structured ref data when `character_memory` is left blank.
|
||||
`Dumas Character Helper` is the restored two-image/text helper for general H3 workflows, and `Dumas Location Helper` mirrors it for scene/environment references. Both helpers also emit structured `REFERENCE` sockets for the curator. The structured `Dumas Character Reference` and `Dumas Location Reference` nodes remain available separately for workflows that want a single `REFERENCE` socket. `Dumas H3 Prompt Curator` consumes those structured references plus optional anchor, soundscape, and BGM strings, assigns the final `<Picture N>` numbering, and outputs only the compacted images the prompt actually mentions.
|
||||
|
||||
`Dumas Strip Iteration Suffix` keeps the part before the first underscore and drops the rest. Names like `char123_pose_final.png` become `char123.png`, while names with no underscore such as `char123.png` are left untouched.
|
||||
|
||||
|
||||
@@ -119,8 +119,10 @@ For locations:
|
||||
- Optional list of alternate match names.
|
||||
|
||||
- `picture_id`
|
||||
- Optional integer representing the intended `<Picture N>` identity.
|
||||
- This is authoring metadata, not the final socket position.
|
||||
- Optional integer representing the effective `<Picture N>` identity.
|
||||
- It is inferred by consumer nodes such as `Dumas H3 Long Videos` from the
|
||||
connected socket position when that position is known.
|
||||
- Producer nodes do not need a manual `picture_id` input.
|
||||
|
||||
- `picture_label`
|
||||
- Derived convenience text like `<Picture 1>`.
|
||||
@@ -167,7 +169,6 @@ Recommended name:
|
||||
Inputs:
|
||||
|
||||
- `image`
|
||||
- `picture_id`
|
||||
- `character_id`
|
||||
- `name`
|
||||
- `alias`
|
||||
@@ -202,7 +203,6 @@ Recommended name:
|
||||
Inputs:
|
||||
|
||||
- `image`
|
||||
- `picture_id`
|
||||
- `location_id`
|
||||
- `name`
|
||||
- `alias`
|
||||
@@ -383,4 +383,3 @@ That is the change that removes the current ambiguity between:
|
||||
- character identity
|
||||
- wardrobe data
|
||||
- location / environment description
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ from .dumas_h3_longvideos import (
|
||||
NODE_CLASS_MAPPINGS as H3_LONGVIDEO_NODE_CLASS_MAPPINGS,
|
||||
NODE_DISPLAY_NAME_MAPPINGS as H3_LONGVIDEO_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
)
|
||||
from .dumas_h3_latent_upscale import (
|
||||
NODE_CLASS_MAPPINGS as H3_LATENT_UPSCALE_NODE_CLASS_MAPPINGS,
|
||||
NODE_DISPLAY_NAME_MAPPINGS as H3_LATENT_UPSCALE_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
)
|
||||
from .dumas_h3_shot_length import (
|
||||
NODE_CLASS_MAPPINGS as H3_SHOT_LENGTH_NODE_CLASS_MAPPINGS,
|
||||
NODE_DISPLAY_NAME_MAPPINGS as H3_SHOT_LENGTH_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
@@ -31,6 +35,7 @@ NODE_CLASS_MAPPINGS = {}
|
||||
NODE_CLASS_MAPPINGS.update(JSON_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(IMAGE_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(H3_LONGVIDEO_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(H3_LATENT_UPSCALE_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(H3_SHOT_LENGTH_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(H3_INSPECTOR_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(H3_BEAT_PROMPT_NODE_CLASS_MAPPINGS)
|
||||
@@ -39,6 +44,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(JSON_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(IMAGE_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(H3_LONGVIDEO_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(H3_LATENT_UPSCALE_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(H3_SHOT_LENGTH_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(H3_INSPECTOR_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(H3_BEAT_PROMPT_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
|
||||
+55
-8
@@ -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",)
|
||||
|
||||
+10
-1
@@ -150,7 +150,16 @@ class H3ModelInspector:
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"model": ("MODEL",)}}
|
||||
return {
|
||||
"required": {
|
||||
"model": (
|
||||
"MODEL",
|
||||
{
|
||||
"tooltip": "MiniMax / H3 model to inspect for quantization and tensor format."
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
def inspect(self, model):
|
||||
label, _counts, report = _detect(model)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+24
-7134
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
@@ -39,10 +39,11 @@ class H3ShotLength:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"shot_seconds": ("FLOAT", {"default": 5.0, "min": 0.2, "max": 15.1, "step": 0.5,
|
||||
"shot_seconds": ("FLOAT", {"default": 3.0, "min": 0.2, "max": 15.1, "step": 0.5,
|
||||
"tooltip": "Length of each shot. Feeds the sampler's shot_seconds AND (as frames) "
|
||||
"the preview override. Max ~15s (362 frames)."}),
|
||||
"fps": ("INT", {"default": 24, "min": 1, "max": 60}),
|
||||
"the preview override. Default 3s matches the common one-beat H3 test shot. Max ~15s (362 frames)."}),
|
||||
"fps": ("INT", {"default": 24, "min": 1, "max": 60,
|
||||
"tooltip": "Frame rate used for the seconds->frames conversion. H3 itself renders at 24fps, so 24 is the realistic default."}),
|
||||
},
|
||||
"optional": {
|
||||
"cap_to_h3_max": ("BOOLEAN", {"default": True,
|
||||
|
||||
+1024
-30
File diff suppressed because it is too large
Load Diff
+50
-29
@@ -277,7 +277,14 @@ class DumasJSONStringToObjectNode:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"json_string": ("STRING", {"multiline": True}),
|
||||
"json_string": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": '{\n "shots": [\n {\n "prompt": "Francine stands by the window."\n }\n ]\n}',
|
||||
"tooltip": "Raw JSON text to parse into a structured JSON object."
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +306,14 @@ class DumasStripIterationSuffixNode:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"filename": ("STRING", {"default": "", "multiline": False}),
|
||||
"filename": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "francine_pose_final.png",
|
||||
"multiline": False,
|
||||
"tooltip": "Filename to normalize by removing everything after the first underscore in the stem."
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +330,14 @@ class DumasSlugifyStringNode:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"default": "", "multiline": False}),
|
||||
"text": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "Francine Coffee Shop",
|
||||
"multiline": False,
|
||||
"tooltip": "Text to slugify into lowercase ASCII words joined with hyphens."
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,8 +355,8 @@ class DumasJSONObjectToStringNode:
|
||||
return {
|
||||
"required": {
|
||||
"json_object": ("JSON",),
|
||||
"pretty": ("BOOLEAN", {"default": True}),
|
||||
"sort_keys": ("BOOLEAN", {"default": False}),
|
||||
"pretty": ("BOOLEAN", {"default": True, "tooltip": "Pretty-print the JSON with indentation."}),
|
||||
"sort_keys": ("BOOLEAN", {"default": False, "tooltip": "Sort object keys alphabetically before serializing."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +376,7 @@ class DumasJSONGetValueNode:
|
||||
return {
|
||||
"required": {
|
||||
"json_object": ("JSON",),
|
||||
"path": ("STRING", {"default": "", "multiline": False}),
|
||||
"path": ("STRING", {"default": "shots.0.prompt", "multiline": False, "tooltip": "Dot-path to read, such as 'shots.0.prompt'."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,8 +394,8 @@ class DumasJSONSetValueNode:
|
||||
return {
|
||||
"required": {
|
||||
"json_object": ("JSON",),
|
||||
"path": ("STRING", {"default": "", "multiline": False}),
|
||||
"value_json": ("STRING", {"multiline": True, "default": "null"}),
|
||||
"path": ("STRING", {"default": "shots.0.prompt", "multiline": False, "tooltip": "Dot-path to write, such as 'shots.0.prompt' or 'shots.1.duration'."}),
|
||||
"value_json": ("STRING", {"multiline": True, "default": '"Francine stands by the window."', "tooltip": "JSON value to store at the path. Must be valid JSON, so strings need quotes."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,7 +419,7 @@ class DumasJSONHasKeyNode:
|
||||
return {
|
||||
"required": {
|
||||
"json_object": ("JSON",),
|
||||
"path": ("STRING", {"default": "", "multiline": False}),
|
||||
"path": ("STRING", {"default": "shots.0.prompt", "multiline": False, "tooltip": "Dot-path to test for existence."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +437,7 @@ class DumasJSONRemoveKeyNode:
|
||||
return {
|
||||
"required": {
|
||||
"json_object": ("JSON",),
|
||||
"path": ("STRING", {"default": "", "multiline": False}),
|
||||
"path": ("STRING", {"default": "shots.0.prompt", "multiline": False, "tooltip": "Dot-path to remove from the object."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,7 +455,7 @@ class DumasJSONPickFieldsNode:
|
||||
return {
|
||||
"required": {
|
||||
"json_object": ("JSON",),
|
||||
"paths": ("STRING", {"multiline": True, "default": ""}),
|
||||
"paths": ("STRING", {"multiline": True, "default": "shots.0.prompt\nshots.0.duration", "tooltip": "One dot-path per line. Only those fields are copied into the output object."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,8 +484,8 @@ class DumasJSONMergeObjectsNode:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"base_object": ("JSON",),
|
||||
"overlay_object": ("JSON",),
|
||||
"base_object": ("JSON", {"tooltip": "Base JSON object to start from."}),
|
||||
"overlay_object": ("JSON", {"tooltip": "Overlay JSON object whose keys replace or merge into the base object."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,7 +503,7 @@ class DumasJSONKeysNode:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"json_object": ("JSON",),
|
||||
"json_object": ("JSON", {"tooltip": "JSON object whose top-level keys should be listed."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,7 +523,7 @@ class DumasJSONArrayLengthNode:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"json_array": ("JSON",),
|
||||
"json_array": ("JSON", {"tooltip": "JSON array whose length should be measured."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,8 +542,8 @@ class DumasJSONArrayAppendNode:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"json_array": ("JSON",),
|
||||
"value_json": ("STRING", {"multiline": True, "default": "null"}),
|
||||
"json_array": ("JSON", {"tooltip": "JSON array to append to."}),
|
||||
"value_json": ("STRING", {"multiline": True, "default": '{"prompt":"Francine looks toward the door."}', "tooltip": "JSON value to append. Must be valid JSON."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,10 +569,10 @@ class DumasJSONArraySliceNode:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"json_array": ("JSON",),
|
||||
"start": ("INT", {"default": 0, "step": 1}),
|
||||
"end": ("INT", {"default": 0, "step": 1}),
|
||||
"step": ("INT", {"default": 1, "step": 1, "min": 1}),
|
||||
"json_array": ("JSON", {"tooltip": "JSON array to slice."}),
|
||||
"start": ("INT", {"default": 0, "step": 1, "tooltip": "Zero-based start index."}),
|
||||
"end": ("INT", {"default": 0, "step": 1, "tooltip": "Zero-based end index. Use 0 to mean 'to the end'."}),
|
||||
"step": ("INT", {"default": 1, "step": 1, "min": 1, "tooltip": "Slice step size."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,9 +593,9 @@ class DumasJSONArrayIteratorNode:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"json_input": ("JSON",),
|
||||
"index": ("INT", {"default": 0, "min": 0, "step": 1}),
|
||||
"mode": (["fixed", "incr", "decr"], {"default": "fixed"}),
|
||||
"json_input": ("JSON", {"tooltip": "JSON array to iterate over."}),
|
||||
"index": ("INT", {"default": 0, "min": 0, "step": 1, "tooltip": "Current zero-based index."}),
|
||||
"mode": (["fixed", "incr", "decr"], {"default": "fixed", "tooltip": "Keep the index fixed, increment it, or decrement it before reading."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,9 +621,9 @@ class DumasJSONObjectIteratorNode:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"json_input": ("JSON",),
|
||||
"index": ("INT", {"default": 0, "min": 0, "step": 1}),
|
||||
"mode": (["fixed", "incr", "decr"], {"default": "fixed"}),
|
||||
"json_input": ("JSON", {"tooltip": "JSON object whose key/value pairs should be iterated in insertion order."}),
|
||||
"index": ("INT", {"default": 0, "min": 0, "step": 1, "tooltip": "Current zero-based index into the object's items."}),
|
||||
"mode": (["fixed", "incr", "decr"], {"default": "fixed", "tooltip": "Keep the index fixed, increment it, or decrement it before reading."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,7 +649,7 @@ class DumasJSONFlattenNode:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"json_input": ("JSON",),
|
||||
"json_input": ("JSON", {"tooltip": "Nested JSON value to flatten into dot-path keys."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,7 +666,7 @@ class DumasJSONUnflattenNode:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"flat_json_object": ("JSON",),
|
||||
"flat_json_object": ("JSON", {"tooltip": "Flat JSON object whose keys are dot-paths to rebuild into nested JSON."}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { app } from "/scripts/app.js";
|
||||
const NODE_NAME = "DumasAnchorStyle";
|
||||
const STYLE_INPUT = "anchor_style";
|
||||
const DESCRIPTION_INPUT = "style_description";
|
||||
const H3_NOTE = " Keep this anchor focused on persistent camera language, lighting, texture, environment treatment, and tone; do not name characters or describe one-off actions.";
|
||||
const H3_NOTE = "";
|
||||
const PRESETS = {
|
||||
"cinematic action movie": "Big-screen action cinema with assertive visual storytelling: dynamic camera placement, strong forward momentum, crisp geography, muscular lighting contrast, practical atmosphere, and a sense of physical consequence. Favor heroic framing, controlled handheld energy or motivated tracking moves, dramatic silhouettes, tasteful lens flares, impact-driven pacing, and polished studio spectacle without drifting into comic-book unreality unless the shot explicitly asks for it." + H3_NOTE,
|
||||
"comedy": "Play the scene for comedic readability and timing: clear staging, expressive performances, slightly heightened reactions, clean eyelines, and visual beats that leave room for the joke to land. Use bright approachable lighting, grounded but playful production design, readable framing, and a tone that feels observant, awkward, or absurd without becoming broad parody unless the action supports it." + H3_NOTE,
|
||||
@@ -51,28 +51,72 @@ const PRESETS = {
|
||||
"fantasy adventure": "Rousing fantasy-adventure language: scenic scale, adventurous clarity, tactile costume-and-prop detail, and camera movement that feels exploratory rather than oppressive. Favor storybook geography, weathered materials, golden or stormy atmosphere, and a tone of peril, wonder, and forward motion." + H3_NOTE,
|
||||
};
|
||||
|
||||
const SOUNDSCAPE_PRESETS = {
|
||||
"quiet interior": "quiet indoor room tone, faint ventilation and distant household ambience",
|
||||
"rainy street": "steady rain, wet pavement, distant traffic hum",
|
||||
"cafe": "low room tone, faint glassware, cutlery, and muted conversation",
|
||||
"city night": "distant traffic hum, occasional horn, night air",
|
||||
"forest": "wind in leaves, distant birds, soft natural ambience",
|
||||
"industrial": "large interior reverb, distant metal ticks, low machine hum",
|
||||
"silent": "no dialogue, no vocals, only the natural ambient bed of the scene",
|
||||
"custom": "",
|
||||
};
|
||||
|
||||
const BGM_PRESETS = {
|
||||
"none": "",
|
||||
"subtle tension": "low, restrained tension bed with sparse pulses and no vocals",
|
||||
"cinematic suspense": "cinematic suspense score with muted strings, low drones, and controlled rising pressure",
|
||||
"emotional piano": "soft emotional piano underscoring with gentle space and no vocals",
|
||||
"dark ambient": "dark ambient music bed with deep drones, distant texture, and slow unease",
|
||||
"hopeful orchestral": "hopeful orchestral underscore with warm strings, gentle brass, and restrained lift",
|
||||
"retro synth": "retro synth score with analog pulses, warm pads, and steady momentum",
|
||||
"action pulse": "driving action pulse with percussion, rhythmic bass, and urgent forward motion",
|
||||
"lo-fi": "soft lo-fi instrumental bed with mellow rhythm and warm tape texture",
|
||||
"no vocals": "instrumental background music only, no singing, no lyrics, no vocal hooks",
|
||||
"custom": "",
|
||||
};
|
||||
|
||||
const NODE_CONFIGS = {
|
||||
[NODE_NAME]: {
|
||||
presetInput: STYLE_INPUT,
|
||||
descriptionInput: DESCRIPTION_INPUT,
|
||||
presets: PRESETS,
|
||||
},
|
||||
DumasSoundscapeHelper: {
|
||||
presetInput: "soundscape",
|
||||
descriptionInput: "soundscape_description",
|
||||
presets: SOUNDSCAPE_PRESETS,
|
||||
},
|
||||
DumasBackgroundMusicHelper: {
|
||||
presetInput: "bgm",
|
||||
descriptionInput: "bgm_description",
|
||||
presets: BGM_PRESETS,
|
||||
},
|
||||
};
|
||||
|
||||
function findWidget(node, name) {
|
||||
return (node.widgets || []).find((widget) => widget?.name === name) || null;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Dumas.AnchorStyle",
|
||||
name: "Dumas.PresetTextHelpers",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== NODE_NAME) return;
|
||||
const config = NODE_CONFIGS[nodeData?.name];
|
||||
if (!config) return;
|
||||
|
||||
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function onNodeCreated() {
|
||||
const created = originalOnNodeCreated?.apply(this, arguments);
|
||||
const styleWidget = findWidget(this, STYLE_INPUT);
|
||||
const descriptionWidget = findWidget(this, DESCRIPTION_INPUT);
|
||||
const styleWidget = findWidget(this, config.presetInput);
|
||||
const descriptionWidget = findWidget(this, config.descriptionInput);
|
||||
if (!styleWidget || !descriptionWidget) return created;
|
||||
|
||||
const originalCallback = styleWidget.callback;
|
||||
styleWidget.callback = (...args) => {
|
||||
const selected = String(styleWidget.value || "");
|
||||
if (Object.hasOwn(PRESETS, selected)) {
|
||||
descriptionWidget.value = PRESETS[selected];
|
||||
if (Object.hasOwn(config.presets, selected)) {
|
||||
descriptionWidget.value = config.presets[selected];
|
||||
descriptionWidget.inputEl?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
this.setDirtyCanvas?.(true, true);
|
||||
|
||||
+74
-100
@@ -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");
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
// 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({
|
||||
name: "Dumas.H3LongVideos.UpstreamWrapper",
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
const NODE_NAME = "DumasH3PromptCurator";
|
||||
const EXPECTED_OUTPUTS = [
|
||||
"prompt",
|
||||
"ref_image_1",
|
||||
"ref_image_2",
|
||||
"ref_image_3",
|
||||
"ref_image_4",
|
||||
"ref_image_5",
|
||||
"ref_image_6",
|
||||
"ref_image_7",
|
||||
"ref_image_8",
|
||||
"ref_image_9",
|
||||
"reference_count",
|
||||
"debug",
|
||||
"anchor",
|
||||
"sounds",
|
||||
"bgm",
|
||||
"original_ref_1",
|
||||
"original_ref_2",
|
||||
"original_ref_3",
|
||||
"original_ref_4",
|
||||
"original_ref_5",
|
||||
"original_ref_6",
|
||||
"original_ref_7",
|
||||
"original_ref_8",
|
||||
"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);
|
||||
|
||||
function pruneStaleOutputs(node) {
|
||||
if (!Array.isArray(node.outputs)) return;
|
||||
|
||||
const byName = new Map();
|
||||
for (const output of node.outputs) {
|
||||
if (!output?.name || !EXPECTED_NAMES.has(output.name) || byName.has(output.name)) continue;
|
||||
byName.set(output.name, output);
|
||||
}
|
||||
|
||||
const nextOutputs = [];
|
||||
for (const name of EXPECTED_OUTPUTS) {
|
||||
const existing = byName.get(name);
|
||||
if (existing) {
|
||||
nextOutputs.push(existing);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextOutputs.length && nextOutputs.length !== node.outputs.length) {
|
||||
node.outputs = nextOutputs;
|
||||
node.size = node.computeSize?.() || node.size;
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Dumas.H3PromptCuratorOutputs",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== NODE_NAME) return;
|
||||
|
||||
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
const originalOnConfigure = nodeType.prototype.onConfigure;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function onNodeCreated() {
|
||||
const created = originalOnNodeCreated?.apply(this, arguments);
|
||||
pruneStaleOutputs(this);
|
||||
return created;
|
||||
};
|
||||
|
||||
nodeType.prototype.onConfigure = function onConfigure() {
|
||||
const configured = originalOnConfigure?.apply(this, arguments);
|
||||
pruneStaleOutputs(this);
|
||||
return configured;
|
||||
};
|
||||
},
|
||||
});
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "dumasnodes"
|
||||
description = "Dumas-branded generic utility nodes for ComfyUI, starting with JSON helpers."
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import importlib
|
||||
import inspect
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
|
||||
class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
class DumasH3LongVideosUpstreamWrapperTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls._saved_modules = {
|
||||
@@ -15,37 +14,40 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
"nodes",
|
||||
"comfy",
|
||||
"comfy.utils",
|
||||
"comfy.sample",
|
||||
"comfy.samplers",
|
||||
"comfy.nested_tensor",
|
||||
"comfy.model_management",
|
||||
"latent_preview",
|
||||
"node_helpers",
|
||||
"numpy",
|
||||
"PIL",
|
||||
"PIL.Image",
|
||||
"folder_paths",
|
||||
"dumas_image_nodes",
|
||||
"dumas_h3_longvideos",
|
||||
"dumas_h3_longvideos_upstream",
|
||||
)
|
||||
}
|
||||
|
||||
fake_torch = types.SimpleNamespace(
|
||||
cuda=types.SimpleNamespace(OutOfMemoryError=RuntimeError),
|
||||
float32="float32",
|
||||
float16="float16",
|
||||
bfloat16="bfloat16",
|
||||
zeros=lambda *args, **kwargs: None,
|
||||
empty=lambda *args, **kwargs: None,
|
||||
cat=lambda *args, **kwargs: None,
|
||||
stack=lambda *args, **kwargs: None,
|
||||
tensor=lambda *args, **kwargs: None,
|
||||
no_grad=lambda: _NullContext(),
|
||||
inference_mode=lambda: _NullContext(),
|
||||
)
|
||||
fake_numpy = types.SimpleNamespace(
|
||||
clip=lambda array, _low, _high: array,
|
||||
uint8="uint8",
|
||||
fake_nodes = types.SimpleNamespace(
|
||||
NODE_CLASS_MAPPINGS={},
|
||||
common_ksampler=lambda *args, **kwargs: ({},),
|
||||
)
|
||||
fake_pil_image_module = types.SimpleNamespace(fromarray=lambda _array: None)
|
||||
fake_pil_module = types.SimpleNamespace(Image=fake_pil_image_module)
|
||||
fake_folder_paths = types.SimpleNamespace(
|
||||
get_temp_directory=lambda: "/tmp",
|
||||
get_output_directory=lambda: "/tmp",
|
||||
get_save_image_path=lambda prefix, _out, _width, _height: ("/tmp", prefix, 1, "", prefix),
|
||||
)
|
||||
fake_nodes = types.SimpleNamespace(common_ksampler=lambda *args, **kwargs: ({},))
|
||||
fake_comfy_samplers = types.SimpleNamespace(
|
||||
KSampler=types.SimpleNamespace(SAMPLERS=("res_multistep",), SCHEDULERS=("simple",))
|
||||
KSampler=types.SimpleNamespace(
|
||||
SAMPLERS=("res_multistep", "euler"),
|
||||
SCHEDULERS=("simple", "normal"),
|
||||
)
|
||||
)
|
||||
fake_comfy_utils = types.SimpleNamespace(ProgressBar=lambda total: None)
|
||||
fake_mm = types.SimpleNamespace(
|
||||
@@ -59,25 +61,31 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
)
|
||||
fake_comfy = types.SimpleNamespace(
|
||||
utils=fake_comfy_utils,
|
||||
sample=types.SimpleNamespace(),
|
||||
samplers=fake_comfy_samplers,
|
||||
nested_tensor=types.SimpleNamespace(),
|
||||
model_management=fake_mm,
|
||||
)
|
||||
|
||||
sys.modules["torch"] = fake_torch
|
||||
sys.modules["numpy"] = fake_numpy
|
||||
sys.modules["PIL"] = fake_pil_module
|
||||
sys.modules["PIL.Image"] = fake_pil_image_module
|
||||
sys.modules["folder_paths"] = fake_folder_paths
|
||||
sys.modules["nodes"] = fake_nodes
|
||||
sys.modules["comfy"] = fake_comfy
|
||||
sys.modules["comfy.utils"] = fake_comfy_utils
|
||||
sys.modules["comfy.sample"] = fake_comfy.sample
|
||||
sys.modules["comfy.samplers"] = fake_comfy_samplers
|
||||
sys.modules["comfy.nested_tensor"] = fake_comfy.nested_tensor
|
||||
sys.modules["comfy.model_management"] = fake_mm
|
||||
sys.modules["latent_preview"] = types.SimpleNamespace()
|
||||
sys.modules["node_helpers"] = types.SimpleNamespace()
|
||||
sys.modules["folder_paths"] = types.SimpleNamespace(
|
||||
get_folder_paths=lambda name: [],
|
||||
get_filename_list=lambda name: [],
|
||||
get_full_path=lambda name, filename: None,
|
||||
get_temp_directory=lambda: "/tmp",
|
||||
get_output_directory=lambda: "/tmp",
|
||||
models_dir="/tmp",
|
||||
)
|
||||
|
||||
cls.image_module = importlib.import_module("dumas_image_nodes")
|
||||
cls.module = importlib.import_module("dumas_h3_longvideos")
|
||||
|
||||
@classmethod
|
||||
@@ -88,737 +96,40 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
else:
|
||||
sys.modules[name] = module
|
||||
|
||||
def test_extract_wardrobe_is_cached(self):
|
||||
fn = self.module.extract_wardrobe
|
||||
fn.cache_clear()
|
||||
def test_dumas_key_wraps_upstream_node(self):
|
||||
mappings = self.module.NODE_CLASS_MAPPINGS
|
||||
|
||||
beat = "walks forward\nwardrobe: red jacket, grey shorts\nlooks back"
|
||||
self.assertEqual(fn(beat), ("walks forward\nlooks back", "red jacket, grey shorts"))
|
||||
self.assertEqual(fn(beat), ("walks forward\nlooks back", "red jacket, grey shorts"))
|
||||
self.assertGreater(fn.cache_info().hits, 0)
|
||||
self.assertIs(mappings["DumasH3LongVideos"], self.module.H3LongVideos)
|
||||
self.assertIs(mappings["H3LongVideos"], self.module.H3LongVideos)
|
||||
self.assertIs(mappings["H3LongVideosREF2VA"], self.module.H3LongVideos)
|
||||
|
||||
def test_dialogue_helpers_keep_existing_outputs_and_cache(self):
|
||||
spans_cache = self.module._dialogue_spans_cached
|
||||
sec_fn = self.module.dialogue_seconds
|
||||
words_fn = self.module.dialogue_words
|
||||
def test_upstream_schema_is_exposed_under_dumas_key(self):
|
||||
node_cls = self.module.NODE_CLASS_MAPPINGS["DumasH3LongVideos"]
|
||||
schema = node_cls.INPUT_TYPES()
|
||||
|
||||
spans_cache.cache_clear()
|
||||
sec_fn.cache_clear()
|
||||
words_fn.cache_clear()
|
||||
self.assertIn("prompt", schema["required"])
|
||||
self.assertTrue(schema["required"]["prompt"][1]["forceInput"])
|
||||
self.assertIn("first_frame", schema["optional"])
|
||||
self.assertIn("ref_image_1", 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"))
|
||||
|
||||
beat = 'Mara says, "Open it now." Jon replies, "Do it."'
|
||||
self.assertEqual(self.module.dialogue_spans(beat), [3, 2])
|
||||
self.assertEqual(words_fn(beat), 5)
|
||||
self.assertAlmostEqual(sec_fn(beat), 3.5)
|
||||
self.assertAlmostEqual(sec_fn(beat, pad=False), 2.5)
|
||||
def test_handoff_context_claim_names_reference_range(self):
|
||||
upstream = importlib.import_module("dumas_h3_longvideos_upstream")
|
||||
|
||||
self.module.dialogue_spans(beat)
|
||||
sec_fn(beat)
|
||||
words_fn(beat)
|
||||
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))
|
||||
|
||||
self.assertGreater(spans_cache.cache_info().hits, 0)
|
||||
self.assertGreater(sec_fn.cache_info().hits, 0)
|
||||
self.assertGreater(words_fn.cache_info().hits, 0)
|
||||
|
||||
def test_directive_and_estimate_helpers_are_cached(self):
|
||||
directive_fn = self.module.beat_seconds_directive
|
||||
estimate_fn = self.module.estimate_beat_seconds
|
||||
action_fn = self.module.action_clauses
|
||||
class _NullContext:
|
||||
def __enter__(self):
|
||||
return None
|
||||
|
||||
directive_fn.cache_clear()
|
||||
estimate_fn.cache_clear()
|
||||
action_fn.cache_clear()
|
||||
|
||||
beat = 'seconds: 7.5\nShe opens the hatch and climbs inside.'
|
||||
self.assertEqual(directive_fn(beat), 7.5)
|
||||
self.assertEqual(action_fn(beat), 2)
|
||||
self.assertAlmostEqual(estimate_fn(beat), 7.0)
|
||||
|
||||
directive_fn(beat)
|
||||
action_fn(beat)
|
||||
estimate_fn(beat)
|
||||
|
||||
self.assertGreater(directive_fn.cache_info().hits, 0)
|
||||
self.assertGreater(action_fn.cache_info().hits, 0)
|
||||
self.assertGreater(estimate_fn.cache_info().hits, 0)
|
||||
|
||||
def test_per_shot_directive_helpers_parse_new_controls(self):
|
||||
beat = (
|
||||
"ref_mode: every shot + handoff ref\n"
|
||||
"ref_noise_aug: 0.87\n"
|
||||
"continuity: keyframe carry\n"
|
||||
"The courier waits under the sign."
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.module.beat_ref_mode_directive(beat),
|
||||
"every shot + handoff ref",
|
||||
)
|
||||
self.assertEqual(self.module.beat_ref_noise_aug_directive(beat), 0.87)
|
||||
self.assertEqual(
|
||||
self.module.beat_continuity_directive(beat),
|
||||
"keyframe carry",
|
||||
)
|
||||
|
||||
def test_detail_pass_refines_video_but_preserves_audio(self):
|
||||
class FakeTensor:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def detach(self):
|
||||
return self
|
||||
|
||||
def to(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
class FakeNestedTensor:
|
||||
def __init__(self, parts):
|
||||
self._parts = tuple(parts)
|
||||
self.is_nested = True
|
||||
|
||||
def unbind(self):
|
||||
return self._parts
|
||||
|
||||
calls = []
|
||||
first_out = {"samples": FakeNestedTensor((FakeTensor("v1"), FakeTensor("a1")))}
|
||||
second_out = {"samples": FakeNestedTensor((FakeTensor("v2"), FakeTensor("a2")))}
|
||||
|
||||
original_common_ksampler = self.module.nodes.common_ksampler
|
||||
original_build = self.module._build_shot_conditioning
|
||||
original_evict = self.module._evict_all_but
|
||||
original_decode_video = self.module._decode_video
|
||||
original_decode_audio = self.module._decode_audio
|
||||
original_cleanup = self.module._deep_cleanup
|
||||
original_nested = getattr(self.module.comfy.nested_tensor, "NestedTensor", None)
|
||||
try:
|
||||
self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor
|
||||
|
||||
def common_ksampler(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return (first_out if len(calls) == 1 else second_out,)
|
||||
|
||||
self.module.nodes.common_ksampler = common_ksampler
|
||||
self.module._build_shot_conditioning = lambda *_args, **_kwargs: (
|
||||
"cond",
|
||||
{"samples": FakeNestedTensor((FakeTensor("basev"), FakeTensor("basea")))},
|
||||
)
|
||||
self.module._evict_all_but = lambda *_args, **_kwargs: None
|
||||
self.module._decode_video = lambda _vae, out_latent, *_args, **_kwargs: out_latent
|
||||
self.module._decode_audio = lambda _vae, out_latent: out_latent
|
||||
self.module._deep_cleanup = lambda: None
|
||||
|
||||
result = self.module.H3LongVideos()._render(
|
||||
model=object(),
|
||||
clip=types.SimpleNamespace(
|
||||
tokenize=lambda text, **kwargs: text,
|
||||
encode_from_tokens_scheduled=lambda tokens: tokens,
|
||||
),
|
||||
vae=object(),
|
||||
audio_vae=object(),
|
||||
negative="negative",
|
||||
prompt="beat",
|
||||
w=128,
|
||||
h=64,
|
||||
ln=24,
|
||||
fps=24,
|
||||
tiled=False,
|
||||
sa=(123, 20, 1.0, "res_multistep", "simple", 1.0),
|
||||
handoff=None,
|
||||
detail_pass=True,
|
||||
detail_sampler_name="euler",
|
||||
detail_scheduler="beta",
|
||||
detail_steps=5,
|
||||
detail_denoise=0.4,
|
||||
)
|
||||
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertIsNot(calls[1][0][8], first_out)
|
||||
self.assertIs(calls[1][0][8]["samples"], first_out["samples"])
|
||||
self.assertEqual(calls[1][0][4], "euler")
|
||||
self.assertEqual(calls[1][0][5], "beta")
|
||||
self.assertAlmostEqual(calls[1][1]["denoise"], 0.4)
|
||||
self.assertEqual(result[1], first_out)
|
||||
self.assertEqual(result[2][0].name, "v2")
|
||||
self.assertEqual(result[2][1].name, "a1")
|
||||
self.assertEqual(result[0]["samples"].unbind()[0].name, "v2")
|
||||
self.assertEqual(result[0]["samples"].unbind()[-1].name, "a1")
|
||||
finally:
|
||||
self.module.nodes.common_ksampler = original_common_ksampler
|
||||
self.module._build_shot_conditioning = original_build
|
||||
self.module._evict_all_but = original_evict
|
||||
self.module._decode_video = original_decode_video
|
||||
self.module._decode_audio = original_decode_audio
|
||||
self.module._deep_cleanup = original_cleanup
|
||||
if original_nested is None:
|
||||
delattr(self.module.comfy.nested_tensor, "NestedTensor")
|
||||
else:
|
||||
self.module.comfy.nested_tensor.NestedTensor = original_nested
|
||||
|
||||
def test_detail_pass_treats_falsey_strings_as_disabled(self):
|
||||
calls = []
|
||||
original_common_ksampler = self.module.nodes.common_ksampler
|
||||
original_build = self.module._build_shot_conditioning
|
||||
original_evict = self.module._evict_all_but
|
||||
original_decode_video = self.module._decode_video
|
||||
original_decode_audio = self.module._decode_audio
|
||||
original_cleanup = self.module._deep_cleanup
|
||||
try:
|
||||
self.module.nodes.common_ksampler = lambda *args, **kwargs: (calls.append((args, kwargs)) or {"samples": "latent"},)
|
||||
self.module._build_shot_conditioning = lambda *_args, **_kwargs: ("cond", {"samples": "base"})
|
||||
self.module._evict_all_but = lambda *_args, **_kwargs: None
|
||||
self.module._decode_video = lambda _vae, out_latent, *_args, **_kwargs: out_latent
|
||||
self.module._decode_audio = lambda _vae, out_latent: out_latent
|
||||
self.module._deep_cleanup = lambda: None
|
||||
|
||||
self.module.H3LongVideos()._render(
|
||||
model=object(),
|
||||
clip=object(),
|
||||
vae=object(),
|
||||
audio_vae=object(),
|
||||
negative="negative",
|
||||
prompt="beat",
|
||||
w=128,
|
||||
h=64,
|
||||
ln=24,
|
||||
fps=24,
|
||||
tiled=False,
|
||||
sa=(123, 20, 1.0, "res_multistep", "simple", 1.0),
|
||||
handoff=None,
|
||||
detail_pass="false",
|
||||
)
|
||||
|
||||
self.assertEqual(len(calls), 1)
|
||||
finally:
|
||||
self.module.nodes.common_ksampler = original_common_ksampler
|
||||
self.module._build_shot_conditioning = original_build
|
||||
self.module._evict_all_but = original_evict
|
||||
self.module._decode_video = original_decode_video
|
||||
self.module._decode_audio = original_decode_audio
|
||||
self.module._deep_cleanup = original_cleanup
|
||||
|
||||
def test_distribute_generations_canonicalizes_per_shot_audio_and_anchor_directives(self):
|
||||
generations = self.module.distribute_generations(
|
||||
"",
|
||||
[
|
||||
"anchor_add: harsh sodium spill, wet asphalt reflections\n"
|
||||
"soundscape: distant traffic hiss, loose sign rattle\n"
|
||||
"music: low pulsing synth tension\n"
|
||||
"continuity: hard cut\n"
|
||||
"ref_mode: every shot\n"
|
||||
"ref_noise_aug: 0.88\n"
|
||||
"A courier waits under the streetlight."
|
||||
],
|
||||
"global rain",
|
||||
"global score",
|
||||
)
|
||||
|
||||
block = generations[0]
|
||||
self.assertIn("harsh sodium spill, wet asphalt reflections", block)
|
||||
self.assertIn("overall_soundscape: distant traffic hiss, loose sign rattle", block)
|
||||
self.assertIn("non_diegetic_music: low pulsing synth tension", block)
|
||||
self.assertNotIn("\nsoundscape:", block)
|
||||
self.assertNotIn("\nmusic:", block)
|
||||
self.assertNotIn("\ncontinuity:", block)
|
||||
self.assertNotIn("\nref_mode:", block)
|
||||
self.assertNotIn("\nref_noise_aug:", block)
|
||||
self.assertNotIn("\nanchor_add:", block)
|
||||
|
||||
def test_has_speech_cache_respects_written_text_filter(self):
|
||||
fn = self.module.has_speech
|
||||
fn.cache_clear()
|
||||
|
||||
written = 'She reads the sign marked "EXIT" and keeps walking.'
|
||||
spoken = 'She says, "Exit now." and points to the door.'
|
||||
self.assertFalse(fn(written))
|
||||
self.assertTrue(fn(spoken))
|
||||
fn(written)
|
||||
fn(spoken)
|
||||
self.assertGreaterEqual(fn.cache_info().hits, 2)
|
||||
|
||||
def test_resolve_tagged_refs_preserves_sparse_socket_numbers(self):
|
||||
refs = [
|
||||
None,
|
||||
{"kind": "character", "image": "img2", "name": "Jon"},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
{"kind": "character", "image": "img7", "name": "Mara"},
|
||||
None,
|
||||
{"kind": "location", "image": "img9", "name": "Watchtower"},
|
||||
]
|
||||
|
||||
text, references, dropped = self.module.resolve_tagged_refs(
|
||||
"Mara <Picture 7> turns toward Jon <Picture 2> while <Picture 9> watches.",
|
||||
refs,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
text,
|
||||
"Mara <Picture 2> turns toward Jon <Picture 1> while <Picture 3> watches.",
|
||||
)
|
||||
self.assertEqual(
|
||||
[self.module._reference_image(ref) for ref in references],
|
||||
["img2", "img7", "img9"],
|
||||
)
|
||||
self.assertEqual(dropped, [])
|
||||
|
||||
def test_resolve_tagged_refs_drops_unconnected_sparse_slots(self):
|
||||
refs = [
|
||||
None,
|
||||
{"kind": "character", "image": "img2", "name": "Jon"},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
{"kind": "character", "image": "img7", "name": "Mara"},
|
||||
None,
|
||||
None,
|
||||
]
|
||||
|
||||
text, references, dropped = self.module.resolve_tagged_refs(
|
||||
"Use <Picture 7>, skip <Picture 4>, keep <Picture 2>.",
|
||||
refs,
|
||||
)
|
||||
|
||||
self.assertEqual(text, "Use <Picture 2>, skip, keep <Picture 1>.")
|
||||
self.assertEqual(
|
||||
[self.module._reference_image(ref) for ref in references],
|
||||
["img2", "img7"],
|
||||
)
|
||||
self.assertEqual(dropped, [4])
|
||||
|
||||
def test_resolve_prompt_refs_keeps_named_character_images_alongside_tagged_location(self):
|
||||
refs = [
|
||||
{"kind": "character", "image": "img1", "name": "Mara"},
|
||||
{"kind": "character", "image": "img2", "name": "Jon"},
|
||||
{"kind": "location", "image": "img3", "name": "Hangar"},
|
||||
]
|
||||
|
||||
text, references, dropped = self.module.resolve_prompt_refs(
|
||||
"Mara and Jon argue inside <Picture 3>.",
|
||||
refs,
|
||||
)
|
||||
|
||||
self.assertEqual(text, "Mara and Jon argue inside <Picture 1>.")
|
||||
self.assertEqual(
|
||||
[self.module._reference_image(ref) for ref in references],
|
||||
["img3", "img1", "img2"],
|
||||
)
|
||||
self.assertEqual(dropped, [])
|
||||
|
||||
def test_shot_references_uses_all_connected_sparse_slots(self):
|
||||
refs = [
|
||||
None,
|
||||
{"kind": "character", "image": "img2"},
|
||||
None,
|
||||
{"kind": "character", "image": "img4"},
|
||||
None,
|
||||
None,
|
||||
{"kind": "location", "image": "img7"},
|
||||
None,
|
||||
None,
|
||||
]
|
||||
|
||||
for mode, shot_index in (("auto ref2v", 0), ("first shot", 0), ("every shot", 3)):
|
||||
self.assertEqual(
|
||||
[self.module._reference_image(ref) for ref in self.module.shot_references(refs, mode, shot_index, None)],
|
||||
["img2", "img4", "img7"],
|
||||
)
|
||||
|
||||
def test_input_types_expose_nine_ref_slots(self):
|
||||
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
|
||||
|
||||
for index in range(1, 10):
|
||||
self.assertIn(f"ref_{index}", optional)
|
||||
|
||||
def test_input_types_keep_legacy_ref_image_aliases(self):
|
||||
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
|
||||
|
||||
for index in range(1, 10):
|
||||
self.assertIn(f"ref_image_{index}", optional)
|
||||
|
||||
def test_run_defaults_match_declared_ref_widget_defaults(self):
|
||||
node = self.module.H3LongVideos()
|
||||
optional = node.INPUT_TYPES()["optional"]
|
||||
params = inspect.signature(node.run).parameters
|
||||
|
||||
self.assertEqual(params["ref_mode"].default, optional["ref_mode"][1]["default"])
|
||||
self.assertEqual(params["ref_image_size"].default, optional["ref_image_size"][1]["default"])
|
||||
self.assertEqual(params["ref_noise_aug"].default, optional["ref_noise_aug"][1]["default"])
|
||||
|
||||
def test_node_appends_per_beat_list_outputs_without_reordering_existing_slots(self):
|
||||
self.assertEqual(
|
||||
self.module.H3LongVideos.RETURN_NAMES[-2:],
|
||||
("beat_images", "beat_audio"),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.module.H3LongVideos.OUTPUT_IS_LIST[-2:],
|
||||
(True, True),
|
||||
)
|
||||
|
||||
def test_reference_context_matches_character_names_and_location_tags(self):
|
||||
refs = [
|
||||
{
|
||||
"kind": "character",
|
||||
"image": "img1",
|
||||
"name": "Mara",
|
||||
"aliases": ["Xtina"],
|
||||
"description": "silver hair",
|
||||
"wardrobe": "red jacket",
|
||||
"general": "wears a long grey coat",
|
||||
"facts": {
|
||||
"gender": "female",
|
||||
"age": "41",
|
||||
"nationality": "English",
|
||||
"occupation": "a detective",
|
||||
"height_feet": "6",
|
||||
"height_inches": "2",
|
||||
"accent": "English",
|
||||
},
|
||||
},
|
||||
{"kind": "location", "image": "img2", "name": "Hangar", "description": "wet concrete floor"},
|
||||
]
|
||||
|
||||
context = self.module._reference_context_for_text(
|
||||
"[Generation 1] Mara crosses the room toward <Picture 2>.",
|
||||
refs,
|
||||
)
|
||||
|
||||
self.assertIn("Character facts for Mara:", context)
|
||||
self.assertIn("also known as Xtina", context)
|
||||
self.assertIn("female", context)
|
||||
self.assertIn("41 years old", context)
|
||||
self.assertIn("English", context)
|
||||
self.assertIn("works as a detective", context)
|
||||
self.assertIn("6 foot 2 tall", context)
|
||||
self.assertIn("speaks with a English accent", context)
|
||||
self.assertIn("Persistent appearance for Mara: silver hair.", context)
|
||||
self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context)
|
||||
self.assertIn("Character notes for Mara: wears a long grey coat.", context)
|
||||
|
||||
def test_run_uses_legacy_ref_image_inputs_when_new_slots_are_empty(self):
|
||||
calls = {}
|
||||
original_parse_resolution = self.module.parse_resolution
|
||||
original_connected_refs = self.module._connected_refs
|
||||
original_reference_character_memory = self.module._reference_character_memory
|
||||
original_vram_gb = self.module.vram_gb
|
||||
original_dit_resident_gb = self.module.dit_resident_gb
|
||||
original_lora_overhead_gb = self.module.lora_overhead_gb
|
||||
original_resolve_shot_frames = self.module.resolve_shot_frames
|
||||
original_lora_active = self.module.lora_active
|
||||
original_sla_pairing = self.module.sla_pairing
|
||||
original_apply_h3_model_sampling = self.module.apply_h3_model_sampling
|
||||
original_split_paragraphs = self.module.split_paragraphs
|
||||
original_expand_beats = self.module.expand_beats
|
||||
original_anchor_warnings = self.module.anchor_warnings
|
||||
original_anchor_contributes_nothing = self.module.anchor_contributes_nothing
|
||||
original_anchor_is_action_beat = self.module.anchor_is_action_beat
|
||||
original_distribute_generations = self.module.distribute_generations
|
||||
original_continuity_warnings = self.module.continuity_warnings
|
||||
original_speech_flags = self.module.speech_flags
|
||||
original_annotate_script_debug = self.module.annotate_script_debug
|
||||
original_empty_av_latent = self.module._empty_av_latent
|
||||
original_torch_zeros = getattr(self.module.torch, "zeros", None)
|
||||
try:
|
||||
self.module.torch.zeros = lambda shape: shape
|
||||
self.module.parse_resolution = lambda _resolution: (640, 360)
|
||||
self.module._connected_refs = lambda refs: [ref for ref in refs if ref is not None]
|
||||
self.module._reference_character_memory = lambda refs: (calls.setdefault("refs", tuple(refs)), "")[1]
|
||||
self.module.vram_gb = lambda: (0, 0)
|
||||
self.module.dit_resident_gb = lambda _model: 0
|
||||
self.module.lora_overhead_gb = lambda _model: 0
|
||||
self.module.resolve_shot_frames = lambda *args, **kwargs: (53, "")
|
||||
self.module.lora_active = lambda _model: False
|
||||
self.module.sla_pairing = lambda *_args, **_kwargs: ("", False, "")
|
||||
self.module.apply_h3_model_sampling = lambda model, *_args: (model, "")
|
||||
self.module.split_paragraphs = lambda _prompt, _sep: ["Anchor.", "Beat."]
|
||||
self.module.expand_beats = lambda beat_paras, _split: (list(beat_paras), "")
|
||||
self.module.anchor_warnings = lambda _anchor: []
|
||||
self.module.anchor_contributes_nothing = lambda *_args, **_kwargs: False
|
||||
self.module.anchor_is_action_beat = lambda *_args, **_kwargs: False
|
||||
self.module.distribute_generations = lambda _anchor, beats, *_args, **_kwargs: list(beats)
|
||||
self.module.continuity_warnings = lambda _gens: []
|
||||
self.module.speech_flags = lambda _beats: []
|
||||
self.module.annotate_script_debug = lambda *_args, **_kwargs: "script"
|
||||
self.module._empty_av_latent = lambda *_args, **_kwargs: ({"samples": "latent"}, 5)
|
||||
|
||||
clip = types.SimpleNamespace(
|
||||
tokenize=lambda text, **kwargs: text,
|
||||
encode_from_tokens_scheduled=lambda tokens: tokens,
|
||||
)
|
||||
|
||||
result = self.module.H3LongVideos().run(
|
||||
model=object(),
|
||||
clip=clip,
|
||||
vae=object(),
|
||||
audio_vae=object(),
|
||||
prompt="Anchor only.",
|
||||
resolution="16:9",
|
||||
steps=6,
|
||||
cfg=1,
|
||||
sampler_name="res_multistep",
|
||||
scheduler="simple",
|
||||
seed=1,
|
||||
plan_only=True,
|
||||
ref_image_1={"image": "legacy-1"},
|
||||
ref_image_3={"image": "legacy-3"},
|
||||
)
|
||||
|
||||
self.assertEqual(calls["refs"][0]["image"], "legacy-1")
|
||||
self.assertIsNone(calls["refs"][1])
|
||||
self.assertEqual(calls["refs"][2]["image"], "legacy-3")
|
||||
self.assertEqual(result[2].count("ref2va: 2 reference image(s)"), 1)
|
||||
finally:
|
||||
self.module.parse_resolution = original_parse_resolution
|
||||
self.module._connected_refs = original_connected_refs
|
||||
self.module._reference_character_memory = original_reference_character_memory
|
||||
self.module.vram_gb = original_vram_gb
|
||||
self.module.dit_resident_gb = original_dit_resident_gb
|
||||
self.module.lora_overhead_gb = original_lora_overhead_gb
|
||||
self.module.resolve_shot_frames = original_resolve_shot_frames
|
||||
self.module.lora_active = original_lora_active
|
||||
self.module.sla_pairing = original_sla_pairing
|
||||
self.module.apply_h3_model_sampling = original_apply_h3_model_sampling
|
||||
self.module.split_paragraphs = original_split_paragraphs
|
||||
self.module.expand_beats = original_expand_beats
|
||||
self.module.anchor_warnings = original_anchor_warnings
|
||||
self.module.anchor_contributes_nothing = original_anchor_contributes_nothing
|
||||
self.module.anchor_is_action_beat = original_anchor_is_action_beat
|
||||
self.module.distribute_generations = original_distribute_generations
|
||||
self.module.continuity_warnings = original_continuity_warnings
|
||||
self.module.speech_flags = original_speech_flags
|
||||
self.module.annotate_script_debug = original_annotate_script_debug
|
||||
self.module._empty_av_latent = original_empty_av_latent
|
||||
if original_torch_zeros is None:
|
||||
delattr(self.module.torch, "zeros")
|
||||
else:
|
||||
self.module.torch.zeros = original_torch_zeros
|
||||
|
||||
def test_reference_context_matches_tagged_character_without_name_in_text(self):
|
||||
refs = [
|
||||
{"kind": "character", "image": "img1", "name": "Mara", "description": "silver hair", "wardrobe": "red jacket"},
|
||||
]
|
||||
|
||||
context = self.module._reference_context_for_text(
|
||||
"[Generation 1] <Picture 1> walks into the room.",
|
||||
refs,
|
||||
)
|
||||
|
||||
self.assertIn("Persistent appearance for Mara: silver hair.", context)
|
||||
self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context)
|
||||
|
||||
def test_reference_context_injects_immediately_after_generation_label(self):
|
||||
block = (
|
||||
"[Generation 1] Classic sitcom lighting and staging. "
|
||||
"Duke walks into the room."
|
||||
)
|
||||
context = "Character facts for Duke: female, 25 years old."
|
||||
|
||||
result = self.module._inject_reference_context(block, context)
|
||||
|
||||
self.assertEqual(
|
||||
result,
|
||||
"[Generation 1] Character facts for Duke: female, 25 years old. "
|
||||
"Classic sitcom lighting and staging. "
|
||||
"Duke walks into the room.",
|
||||
)
|
||||
|
||||
def test_reference_character_memory_uses_character_wardrobe_only(self):
|
||||
refs = [
|
||||
{"kind": "character", "image": "img1", "name": "Mara", "wardrobe": "red jacket, black boots"},
|
||||
{"kind": "location", "image": "img2", "name": "Hangar", "description": "wet concrete floor", "wardrobe": "should be ignored"},
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
self.module._reference_character_memory(refs),
|
||||
"Mara = red jacket, black boots",
|
||||
)
|
||||
|
||||
def test_ref_mode_defaults_are_ref2v_biased(self):
|
||||
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
|
||||
|
||||
self.assertEqual(optional["ref_mode"][1]["default"], "auto ref2v")
|
||||
self.assertEqual(optional["ref_noise_aug"][1]["default"], 0.95)
|
||||
|
||||
def test_only_canonical_h3_long_videos_node_is_exposed(self):
|
||||
self.assertEqual(
|
||||
self.module.NODE_CLASS_MAPPINGS,
|
||||
{"DumasH3LongVideos": self.module.H3LongVideos},
|
||||
)
|
||||
self.assertEqual(
|
||||
self.module.NODE_DISPLAY_NAME_MAPPINGS,
|
||||
{"DumasH3LongVideos": "Dumas H3 Long Videos (FL2VA + REF2VA)"},
|
||||
)
|
||||
|
||||
def test_compose_persistent_does_not_expand_ambiguous_plural_to_full_cast(self):
|
||||
active = self.module.parse_wardrobe(
|
||||
"Maya = she, red jacket\n"
|
||||
"Jon = he, navy overalls\n"
|
||||
"Becca = she, green coat"
|
||||
)
|
||||
|
||||
shot = self.module.compose_persistent(
|
||||
"Both of them walk to the door.",
|
||||
active,
|
||||
"",
|
||||
speaking=False,
|
||||
)
|
||||
|
||||
self.assertEqual(shot, "Both of them walk to the door.")
|
||||
|
||||
def test_compose_persistent_keeps_two_person_plural_binding(self):
|
||||
active = self.module.parse_wardrobe(
|
||||
"Maya = she, red jacket\n"
|
||||
"Jon = he, navy overalls"
|
||||
)
|
||||
|
||||
shot = self.module.compose_persistent(
|
||||
"They walk to the door.",
|
||||
active,
|
||||
"",
|
||||
speaking=False,
|
||||
)
|
||||
|
||||
self.assertIn("Maya (red jacket)", shot)
|
||||
self.assertIn("Jon (navy overalls)", shot)
|
||||
self.assertIn("They walk to the door.", shot)
|
||||
|
||||
def test_compose_persistent_all_three_characters_binds_full_cast(self):
|
||||
active = self.module.parse_wardrobe(
|
||||
"Maya = she, red jacket\n"
|
||||
"Jon = he, navy overalls\n"
|
||||
"Becca = she, green coat"
|
||||
)
|
||||
|
||||
shot = self.module.compose_persistent(
|
||||
"The three characters walk to the door.",
|
||||
active,
|
||||
"",
|
||||
speaking=False,
|
||||
)
|
||||
|
||||
self.assertIn("Maya (red jacket)", shot)
|
||||
self.assertIn("Jon (navy overalls)", shot)
|
||||
self.assertIn("Becca (green coat)", shot)
|
||||
|
||||
def test_plan_only_returns_joined_generations_on_script_socket_with_anchor_override(self):
|
||||
module = self.module
|
||||
node = module.H3LongVideos()
|
||||
|
||||
class _Clip:
|
||||
def tokenize(self, text):
|
||||
return text
|
||||
|
||||
def encode_from_tokens_scheduled(self, tokens):
|
||||
return tokens
|
||||
|
||||
class _TorchStub:
|
||||
@staticmethod
|
||||
def zeros(shape):
|
||||
return ("zeros", shape)
|
||||
|
||||
original_torch = module.torch
|
||||
original_vram_gb = module.vram_gb
|
||||
original_dit_resident_gb = module.dit_resident_gb
|
||||
original_lora_overhead_gb = module.lora_overhead_gb
|
||||
original_check_vae_wiring = module.check_vae_wiring
|
||||
original_check_text_encoder = module.check_text_encoder
|
||||
original_apply_h3_model_sampling = module.apply_h3_model_sampling
|
||||
original_sla_pairing = module.sla_pairing
|
||||
original_lora_hint_notes = module.lora_hint_notes
|
||||
original_schedule_balance_note = module.schedule_balance_note
|
||||
original_kernel_backend_note = module.kernel_backend_note
|
||||
original_audio_scale_note = module.audio_scale_note
|
||||
original_quant_accel_note = module.quant_accel_note
|
||||
original_lora_active = module.lora_active
|
||||
original_resolve_shot_frames = module.resolve_shot_frames
|
||||
original_plan_beat_frames = module.plan_beat_frames
|
||||
original_dialogue_fit_warnings = module.dialogue_fit_warnings
|
||||
original_dialogue_filler_warnings = module.dialogue_filler_warnings
|
||||
original_distribute_generations = module.distribute_generations
|
||||
original_continuity_warnings = module.continuity_warnings
|
||||
original_empty_av_latent = module._empty_av_latent
|
||||
try:
|
||||
module.torch = _TorchStub()
|
||||
module.vram_gb = lambda: (0.0, 0.0)
|
||||
module.dit_resident_gb = lambda _model: 0.0
|
||||
module.lora_overhead_gb = lambda _model: 0.0
|
||||
module.check_vae_wiring = lambda *_args, **_kwargs: None
|
||||
module.check_text_encoder = lambda *_args, **_kwargs: None
|
||||
module.apply_h3_model_sampling = lambda model, *_args, **_kwargs: (model, "")
|
||||
module.sla_pairing = lambda *_args, **_kwargs: ("", False, "")
|
||||
module.lora_hint_notes = lambda *_args, **_kwargs: []
|
||||
module.schedule_balance_note = lambda *_args, **_kwargs: ""
|
||||
module.kernel_backend_note = lambda *_args, **_kwargs: ""
|
||||
module.audio_scale_note = lambda *_args, **_kwargs: ""
|
||||
module.quant_accel_note = lambda *_args, **_kwargs: ""
|
||||
module.lora_active = lambda _model: False
|
||||
module.resolve_shot_frames = lambda *_args, **_kwargs: (73, "")
|
||||
module.plan_beat_frames = lambda beats, fps, budget, per_beat=True: ([73] * len(beats), [])
|
||||
module.dialogue_fit_warnings = lambda *_args, **_kwargs: []
|
||||
module.dialogue_filler_warnings = lambda *_args, **_kwargs: []
|
||||
module.distribute_generations = lambda anchor, beats, *_args, **_kwargs: [
|
||||
f"[Generation 1] {anchor}. {beats[0]}",
|
||||
f"[Generation 2] {anchor}. {beats[1]}{module.ANATOMY_STATE}",
|
||||
]
|
||||
module.continuity_warnings = lambda _gens: []
|
||||
module._empty_av_latent = lambda *_args, **_kwargs: ({"samples": "latent"},)
|
||||
|
||||
result = node.run(
|
||||
model=object(),
|
||||
clip=_Clip(),
|
||||
vae=object(),
|
||||
audio_vae=object(),
|
||||
prompt="Francine stands alone.\n\nFrancine and Frankie walk together.",
|
||||
resolution="16:9",
|
||||
steps=20,
|
||||
cfg=1.0,
|
||||
sampler_name="res_multistep",
|
||||
scheduler="simple",
|
||||
seed=1,
|
||||
anchor_override="editorial room, soft practical lighting",
|
||||
character_memory="Francine = white top\nFrankie = black jacket",
|
||||
plan_only=True,
|
||||
)
|
||||
|
||||
self.assertIn("# anatomy_guard: injected on shot(s) 2", result[3])
|
||||
self.assertIn("# shot 1 refs: none", result[3])
|
||||
self.assertIn("# shot 2 refs: none", result[3])
|
||||
self.assertIn(
|
||||
"[Generation 1] editorial room, soft practical lighting. Francine stands alone.",
|
||||
result[3],
|
||||
)
|
||||
self.assertIn(
|
||||
"[Generation 2] editorial room, soft practical lighting. Francine and Frankie walk together.",
|
||||
result[3],
|
||||
)
|
||||
self.assertIn("2 beat(s)", result[2])
|
||||
self.assertIn("2 shot(s)", result[2])
|
||||
self.assertIn("ANATOMY -- guard injected on shot(s) 2", result[2])
|
||||
self.assertEqual(result[-2:], ([], []))
|
||||
finally:
|
||||
module.torch = original_torch
|
||||
module.vram_gb = original_vram_gb
|
||||
module.dit_resident_gb = original_dit_resident_gb
|
||||
module.lora_overhead_gb = original_lora_overhead_gb
|
||||
module.check_vae_wiring = original_check_vae_wiring
|
||||
module.check_text_encoder = original_check_text_encoder
|
||||
module.apply_h3_model_sampling = original_apply_h3_model_sampling
|
||||
module.sla_pairing = original_sla_pairing
|
||||
module.lora_hint_notes = original_lora_hint_notes
|
||||
module.schedule_balance_note = original_schedule_balance_note
|
||||
module.kernel_backend_note = original_kernel_backend_note
|
||||
module.audio_scale_note = original_audio_scale_note
|
||||
module.quant_accel_note = original_quant_accel_note
|
||||
module.lora_active = original_lora_active
|
||||
module.resolve_shot_frames = original_resolve_shot_frames
|
||||
module.plan_beat_frames = original_plan_beat_frames
|
||||
module.dialogue_fit_warnings = original_dialogue_fit_warnings
|
||||
module.dialogue_filler_warnings = original_dialogue_filler_warnings
|
||||
module.distribute_generations = original_distribute_generations
|
||||
module.continuity_warnings = original_continuity_warnings
|
||||
module._empty_av_latent = original_empty_av_latent
|
||||
def __exit__(self, *_exc):
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+365
-12
@@ -245,7 +245,6 @@ class DumasImageNodeTests(unittest.TestCase):
|
||||
|
||||
result = node.build_reference(
|
||||
image=image,
|
||||
picture_id="2",
|
||||
character_id="char_dave",
|
||||
name="Dave",
|
||||
alias="The Locksmith",
|
||||
@@ -270,10 +269,10 @@ class DumasImageNodeTests(unittest.TestCase):
|
||||
"id": "char-dave",
|
||||
"name": "Dave",
|
||||
"aliases": ["The Locksmith"],
|
||||
"picture_id": 2,
|
||||
"picture_label": "<Picture 2>",
|
||||
"picture_id": None,
|
||||
"picture_label": "",
|
||||
"image": image,
|
||||
"summary": "Dave shown in <Picture 2>.",
|
||||
"summary": "Dave reference.",
|
||||
"description": "Square jaw, tired eyes, cropped brown hair.",
|
||||
"wardrobe": "weathered red flight jacket, grey cargo shorts, black boots",
|
||||
"general": "wears a long grey coat",
|
||||
@@ -289,13 +288,16 @@ class DumasImageNodeTests(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_character_reference_input_types_do_not_expose_picture_id(self):
|
||||
required = self.image_nodes.DumasCharacterReferenceNode.INPUT_TYPES()["required"]
|
||||
self.assertNotIn("picture_id", required)
|
||||
|
||||
def test_character_reference_handles_missing_optional_fields(self):
|
||||
node = self.image_nodes.DumasCharacterReferenceNode()
|
||||
image = FakeTensorBatch()
|
||||
|
||||
result = node.build_reference(
|
||||
image=image,
|
||||
picture_id="4",
|
||||
character_id="",
|
||||
name="",
|
||||
alias="",
|
||||
@@ -313,8 +315,8 @@ class DumasImageNodeTests(unittest.TestCase):
|
||||
|
||||
reference = result[0]
|
||||
self.assertEqual(reference["kind"], "character")
|
||||
self.assertEqual(reference["picture_id"], 4)
|
||||
self.assertEqual(reference["picture_label"], "<Picture 4>")
|
||||
self.assertIsNone(reference["picture_id"])
|
||||
self.assertEqual(reference["picture_label"], "")
|
||||
self.assertEqual(reference["wardrobe"], "")
|
||||
self.assertEqual(reference["general"], "")
|
||||
self.assertEqual(reference["facts"]["age"], "")
|
||||
@@ -325,7 +327,6 @@ class DumasImageNodeTests(unittest.TestCase):
|
||||
|
||||
result = node.build_reference(
|
||||
image=image,
|
||||
picture_id="9",
|
||||
location_id="coffee-shop-01",
|
||||
name="Coffee Shop",
|
||||
alias="Cafe Interior",
|
||||
@@ -340,10 +341,10 @@ class DumasImageNodeTests(unittest.TestCase):
|
||||
"id": "coffee-shop-01",
|
||||
"name": "Coffee Shop",
|
||||
"aliases": ["Cafe Interior"],
|
||||
"picture_id": 9,
|
||||
"picture_label": "<Picture 9>",
|
||||
"picture_id": None,
|
||||
"picture_label": "",
|
||||
"image": image,
|
||||
"summary": "Coffee Shop shown in <Picture 9>.",
|
||||
"summary": "Coffee Shop reference.",
|
||||
"description": "Warm tungsten lighting, narrow counter, rainy front window.",
|
||||
"wardrobe": "",
|
||||
"general": "Evening ambience, cramped but cozy.",
|
||||
@@ -351,6 +352,345 @@ class DumasImageNodeTests(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_location_reference_input_types_do_not_expose_picture_id(self):
|
||||
required = self.image_nodes.DumasLocationReferenceNode.INPUT_TYPES()["required"]
|
||||
self.assertNotIn("picture_id", required)
|
||||
|
||||
def test_character_helper_restores_image_and_text_outputs(self):
|
||||
node = self.image_nodes.DumasCharacterHelperNode()
|
||||
image1 = FakeTensorBatch()
|
||||
image2 = FakeTensorBatch()
|
||||
|
||||
result = node.build_character_text(
|
||||
image1=image1,
|
||||
image2=image2,
|
||||
image1_picture_id="1",
|
||||
image2_picture_id="2",
|
||||
character_id="char_dave",
|
||||
name="Dave",
|
||||
alias="The Locksmith",
|
||||
gender="male",
|
||||
age="41",
|
||||
nationality="English",
|
||||
occupation="a detective",
|
||||
height_feet="6",
|
||||
height_inches="2",
|
||||
accent="English",
|
||||
general="Moves carefully and notices every exit",
|
||||
wardrobe="weathered red flight jacket, grey cargo shorts, black boots",
|
||||
)
|
||||
|
||||
self.assertIs(result[0], image1)
|
||||
self.assertIs(result[1], image2)
|
||||
self.assertIn("<Picture 1> and <Picture 2> reference the same character", result[2])
|
||||
self.assertIn("Dave is also known as The Locksmith", result[2])
|
||||
self.assertIn("is 41 years old", result[2])
|
||||
self.assertEqual(result[3], "Dave = weathered red flight jacket, grey cargo shorts, black boots")
|
||||
self.assertIs(result[4]["image"], image1)
|
||||
self.assertIs(result[5]["image"], image2)
|
||||
self.assertEqual(result[4]["id"], "char-dave")
|
||||
self.assertEqual(result[5]["id"], "char-dave")
|
||||
self.assertEqual(result[4]["name"], "Dave")
|
||||
self.assertEqual(result[4]["aliases"], ["The Locksmith"])
|
||||
self.assertEqual(result[4]["facts"]["age"], "41")
|
||||
self.assertEqual(result[4]["facts"]["height_feet"], "6")
|
||||
self.assertEqual(result[4]["facts"]["height_inches"], "2")
|
||||
self.assertEqual(result[4]["wardrobe"], "weathered red flight jacket, grey cargo shorts, black boots")
|
||||
self.assertEqual(len(result), 6)
|
||||
|
||||
def test_location_helper_matches_character_helper_shape_without_wardrobe(self):
|
||||
node = self.image_nodes.DumasLocationHelperNode()
|
||||
image1 = FakeTensorBatch()
|
||||
image2 = FakeTensorBatch()
|
||||
|
||||
result = node.build_location_text(
|
||||
image1=image1,
|
||||
image2=image2,
|
||||
image1_picture_id="3",
|
||||
image2_picture_id="4",
|
||||
location_id="coffee-shop-01",
|
||||
name="Coffee Shop",
|
||||
alias="Cafe Interior",
|
||||
description="Warm tungsten lighting, narrow counter, rainy front window",
|
||||
general="Evening ambience, cramped but cozy",
|
||||
)
|
||||
|
||||
self.assertIs(result[0], image1)
|
||||
self.assertIs(result[1], image2)
|
||||
self.assertIn("<Picture 3> and <Picture 4> reference the same location", result[2])
|
||||
self.assertIn("Coffee Shop is also known as Cafe Interior", result[2])
|
||||
self.assertIn("Warm tungsten lighting, narrow counter, rainy front window.", result[2])
|
||||
self.assertIn("Evening ambience, cramped but cozy.", result[2])
|
||||
self.assertIs(result[3]["image"], image1)
|
||||
self.assertIs(result[4]["image"], image2)
|
||||
self.assertEqual(result[3]["kind"], "location")
|
||||
self.assertEqual(result[4]["kind"], "location")
|
||||
self.assertEqual(result[3]["id"], "coffee-shop-01")
|
||||
self.assertEqual(result[4]["id"], "coffee-shop-01")
|
||||
self.assertEqual(result[3]["name"], "Coffee Shop")
|
||||
self.assertEqual(result[3]["aliases"], ["Cafe Interior"])
|
||||
self.assertEqual(result[3]["description"], "Warm tungsten lighting, narrow counter, rainy front window")
|
||||
self.assertEqual(result[3]["general"], "Evening ambience, cramped but cozy")
|
||||
self.assertEqual(len(result), 5)
|
||||
|
||||
def test_soundscape_helper_defaults_to_selected_preset_description(self):
|
||||
node = self.image_nodes.DumasSoundscapeHelperNode()
|
||||
|
||||
result = node.build_soundscape("rainy street", "")
|
||||
|
||||
self.assertEqual(result[0], "steady rain, wet pavement, distant traffic hum")
|
||||
|
||||
def test_background_music_helper_defaults_to_selected_preset_description(self):
|
||||
node = self.image_nodes.DumasBackgroundMusicHelperNode()
|
||||
|
||||
result = node.build_bgm("subtle tension", "")
|
||||
|
||||
self.assertEqual(result[0], "low, restrained tension bed with sparse pulses and no vocals")
|
||||
|
||||
def test_h3_prompt_curator_compacts_named_references(self):
|
||||
node = self.image_nodes.DumasH3PromptCuratorNode()
|
||||
dave_image = FakeTensorBatch()
|
||||
cafe_image = FakeTensorBatch()
|
||||
van_image = FakeTensorBatch()
|
||||
dave = self.image_nodes.make_reference(
|
||||
kind="character",
|
||||
image=dave_image,
|
||||
name="Dave",
|
||||
aliases="The Locksmith",
|
||||
description="tired eyes, cropped brown hair",
|
||||
wardrobe="red flight jacket",
|
||||
)
|
||||
cafe = self.image_nodes.make_reference(
|
||||
kind="location",
|
||||
image=cafe_image,
|
||||
name="Coffee Shop",
|
||||
description="warm tungsten lighting and rainy windows",
|
||||
)
|
||||
van = self.image_nodes.make_reference(
|
||||
kind="location",
|
||||
image=van_image,
|
||||
name="Blue Van",
|
||||
description="scuffed blue delivery van",
|
||||
)
|
||||
|
||||
result = node.curate_prompt(
|
||||
action_prompt="Dave runs from the Coffee Shop into the rain.",
|
||||
anatomy_guard="auto",
|
||||
subject_count_guard="auto",
|
||||
anchor="grounded handheld thriller",
|
||||
soundscape="steady rain",
|
||||
bgm="low suspense music",
|
||||
ref_1=dave,
|
||||
ref_2=van,
|
||||
ref_3=cafe,
|
||||
)
|
||||
|
||||
prompt = result[0]
|
||||
self.assertIn("<Picture 1> Dave", prompt)
|
||||
self.assertIn("<Picture 2> Coffee Shop", prompt)
|
||||
self.assertIn("Action: Dave runs from the Coffee Shop into the rain.", prompt)
|
||||
self.assertIn("Anatomy guard:", prompt)
|
||||
self.assertIn("Subject count guard:", prompt)
|
||||
self.assertIn("overall_soundscape: steady rain", prompt)
|
||||
self.assertIn("background_music: low suspense music", prompt)
|
||||
self.assertIn("exactly one named character: <Picture 1> Dave", prompt)
|
||||
self.assertIs(result[1], dave_image)
|
||||
self.assertIs(result[2], cafe_image)
|
||||
self.assertIsNone(result[3])
|
||||
self.assertEqual(result[10], 2)
|
||||
self.assertIn("input 3-><Picture 2> Coffee Shop", result[11])
|
||||
self.assertEqual(result[12], "grounded handheld thriller")
|
||||
self.assertEqual(result[13], "steady rain")
|
||||
self.assertEqual(result[14], "low suspense music")
|
||||
self.assertIs(result[15], dave_image)
|
||||
self.assertIs(result[16], cafe_image)
|
||||
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):
|
||||
node = self.image_nodes.DumasH3PromptCuratorNode()
|
||||
image1 = FakeTensorBatch()
|
||||
image3 = FakeTensorBatch()
|
||||
unused = FakeTensorBatch()
|
||||
first = self.image_nodes.make_reference(kind="character", image=image1, name="Maya")
|
||||
second = self.image_nodes.make_reference(kind="location", image=unused, name="Lobby")
|
||||
third = self.image_nodes.make_reference(kind="location", image=image3, name="Rooftop")
|
||||
|
||||
result = node.curate_prompt(
|
||||
action_prompt="<Picture 1> Maya crosses to <ref3> as the wind rises.",
|
||||
anatomy_guard="off",
|
||||
subject_count_guard="off",
|
||||
ref_1=first,
|
||||
ref_2=second,
|
||||
ref_3=third,
|
||||
)
|
||||
|
||||
prompt = result[0]
|
||||
self.assertIn("<Picture 1> Maya crosses to <Picture 2>", prompt)
|
||||
self.assertNotIn("<Picture 3>", prompt)
|
||||
self.assertIs(result[1], image1)
|
||||
self.assertIs(result[2], image3)
|
||||
self.assertIsNone(result[3])
|
||||
self.assertEqual(result[10], 2)
|
||||
|
||||
def test_h3_prompt_curator_can_force_subject_count_without_character_refs(self):
|
||||
node = self.image_nodes.DumasH3PromptCuratorNode()
|
||||
|
||||
result = node.curate_prompt(
|
||||
action_prompt="A locked-off shot of the empty corridor.",
|
||||
anatomy_guard="off",
|
||||
subject_count_guard="on",
|
||||
)
|
||||
|
||||
self.assertIn("Subject count guard:", result[0])
|
||||
self.assertIn("Only include the people explicitly described", result[0])
|
||||
self.assertEqual(result[10], 0)
|
||||
|
||||
def test_h3_prompt_curator_treats_helper_image_pair_as_one_character(self):
|
||||
helper = self.image_nodes.DumasCharacterHelperNode()
|
||||
curator = self.image_nodes.DumasH3PromptCuratorNode()
|
||||
image1 = FakeTensorBatch()
|
||||
image2 = FakeTensorBatch()
|
||||
helper_result = helper.build_character_text(
|
||||
image1=image1,
|
||||
image2=image2,
|
||||
image1_picture_id="1",
|
||||
image2_picture_id="2",
|
||||
character_id="char_dave",
|
||||
name="Dave",
|
||||
alias="The Locksmith",
|
||||
gender="male",
|
||||
age="41",
|
||||
nationality="English",
|
||||
occupation="detective",
|
||||
height_feet="6",
|
||||
height_inches="2",
|
||||
accent="English",
|
||||
general="Tired eyes, cropped brown hair",
|
||||
wardrobe="weathered red flight jacket",
|
||||
)
|
||||
|
||||
result = curator.curate_prompt(
|
||||
action_prompt="Dave checks the locked door.",
|
||||
anatomy_guard="on",
|
||||
subject_count_guard="auto",
|
||||
ref_1=helper_result[4],
|
||||
ref_2=helper_result[5],
|
||||
)
|
||||
|
||||
self.assertIs(result[1], image1)
|
||||
self.assertIs(result[2], image2)
|
||||
self.assertEqual(result[10], 2)
|
||||
self.assertIn("Character facts for <Picture 1> Dave", result[0])
|
||||
self.assertIn("41 years old", result[0])
|
||||
self.assertIn("6 foot 2 tall", result[0])
|
||||
self.assertIn("exactly one named character: <Picture 1> Dave", result[0])
|
||||
self.assertNotIn("exactly 2 named characters", result[0])
|
||||
|
||||
def test_h3_prompt_curator_uses_location_helper_references_by_name(self):
|
||||
helper = self.image_nodes.DumasLocationHelperNode()
|
||||
curator = self.image_nodes.DumasH3PromptCuratorNode()
|
||||
image1 = FakeTensorBatch()
|
||||
image2 = FakeTensorBatch()
|
||||
helper_result = helper.build_location_text(
|
||||
image1=image1,
|
||||
image2=image2,
|
||||
image1_picture_id="1",
|
||||
image2_picture_id="2",
|
||||
location_id="coffee_shop",
|
||||
name="Coffee Shop",
|
||||
alias="Cafe Interior",
|
||||
description="Warm tungsten lighting, narrow counter, rainy front window",
|
||||
general="Evening ambience, cramped but cozy",
|
||||
)
|
||||
|
||||
result = curator.curate_prompt(
|
||||
action_prompt="A slow push through the Coffee Shop as rain streaks the windows.",
|
||||
anatomy_guard="on",
|
||||
subject_count_guard="auto",
|
||||
ref_1=helper_result[3],
|
||||
ref_2=helper_result[4],
|
||||
)
|
||||
|
||||
self.assertIs(result[1], image1)
|
||||
self.assertIs(result[2], image2)
|
||||
self.assertEqual(result[10], 2)
|
||||
self.assertIn("<Picture 1> Coffee Shop", result[0])
|
||||
self.assertIn("<Picture 2> Coffee Shop", result[0])
|
||||
self.assertIn("Location context for <Picture 1> Coffee Shop", result[0])
|
||||
self.assertIn("Warm tungsten lighting", result[0])
|
||||
self.assertNotIn("Subject count guard:", result[0])
|
||||
|
||||
def test_h3_prompt_curator_defaults_anatomy_guard_to_on(self):
|
||||
required = self.image_nodes.DumasH3PromptCuratorNode.INPUT_TYPES()["required"]
|
||||
|
||||
self.assertEqual(required["anatomy_guard"][1]["default"], "on")
|
||||
|
||||
def test_helper_node_mappings_use_general_purpose_helpers(self):
|
||||
mappings = self.image_nodes.NODE_CLASS_MAPPINGS
|
||||
display = self.image_nodes.NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
self.assertIs(mappings["DumasCharacterHelper"], self.image_nodes.DumasCharacterHelperNode)
|
||||
self.assertIs(mappings["DumasLocationHelper"], self.image_nodes.DumasLocationHelperNode)
|
||||
self.assertIs(mappings["DumasSoundscapeHelper"], self.image_nodes.DumasSoundscapeHelperNode)
|
||||
self.assertIs(mappings["DumasBackgroundMusicHelper"], self.image_nodes.DumasBackgroundMusicHelperNode)
|
||||
self.assertIs(mappings["DumasH3PromptCurator"], self.image_nodes.DumasH3PromptCuratorNode)
|
||||
self.assertEqual(display["DumasCharacterHelper"], "Dumas Character Helper")
|
||||
self.assertEqual(display["DumasLocationHelper"], "Dumas Location Helper")
|
||||
self.assertEqual(display["DumasSoundscapeHelper"], "Dumas Soundscape Helper")
|
||||
self.assertEqual(display["DumasBackgroundMusicHelper"], "Dumas Background Music Helper")
|
||||
self.assertEqual(display["DumasH3PromptCurator"], "Dumas H3 Prompt Curator")
|
||||
|
||||
def test_h3_prompt_curator_uses_documented_reference_limits(self):
|
||||
node = self.image_nodes.DumasH3PromptCuratorNode()
|
||||
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[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[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):
|
||||
image = FakeTensorBatch()
|
||||
|
||||
reference = self.image_nodes.normalize_reference(
|
||||
{
|
||||
"kind": "character",
|
||||
"name": "Dave",
|
||||
"image": image,
|
||||
"summary": "Dave reference.",
|
||||
},
|
||||
picture_id=3,
|
||||
allow_image_fallback=False,
|
||||
)
|
||||
|
||||
self.assertEqual(reference["picture_id"], 3)
|
||||
self.assertEqual(reference["picture_label"], "<Picture 3>")
|
||||
self.assertEqual(reference["summary"], "Dave shown in <Picture 3>.")
|
||||
|
||||
def test_normalize_reference_keeps_custom_summary_when_socket_picture_id_is_added(self):
|
||||
image = FakeTensorBatch()
|
||||
|
||||
reference = self.image_nodes.normalize_reference(
|
||||
{
|
||||
"kind": "character",
|
||||
"name": "Dave",
|
||||
"image": image,
|
||||
"summary": "Primary hero look for the opening close-up.",
|
||||
},
|
||||
picture_id=3,
|
||||
allow_image_fallback=False,
|
||||
)
|
||||
|
||||
self.assertEqual(reference["picture_id"], 3)
|
||||
self.assertEqual(reference["summary"], "Primary hero look for the opening close-up.")
|
||||
|
||||
def test_anchor_style_node_exposes_requested_presets(self):
|
||||
input_types = self.image_nodes.DumasAnchorStyleNode.INPUT_TYPES()
|
||||
options = input_types["required"]["anchor_style"][0]
|
||||
@@ -375,7 +715,20 @@ class DumasImageNodeTests(unittest.TestCase):
|
||||
|
||||
self.assertIn("found-footage", result[0])
|
||||
self.assertIn("real time", result[0])
|
||||
self.assertIn("persistent camera language", result[0])
|
||||
self.assertNotIn("persistent camera language", result[0])
|
||||
|
||||
def test_anchor_style_node_strips_legacy_persistent_anchor_note(self):
|
||||
node = self.image_nodes.DumasAnchorStyleNode()
|
||||
legacy = (
|
||||
"Gritty handheld realism. Keep this anchor focused on persistent camera "
|
||||
"language, lighting, texture, environment treatment, and tone; do not "
|
||||
"name characters or describe one-off actions."
|
||||
)
|
||||
|
||||
result = node.build_anchor("cinematic action movie", legacy)
|
||||
|
||||
self.assertEqual(result[0], "Gritty handheld realism.")
|
||||
self.assertNotIn("persistent camera language", result[0])
|
||||
|
||||
def test_anchor_style_node_prefers_manual_description_edits(self):
|
||||
node = self.image_nodes.DumasAnchorStyleNode()
|
||||
|
||||
Reference in New Issue
Block a user