Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+27
-66
@@ -1149,81 +1149,42 @@ These two belong together.
|
||||
|
||||
If you change `shift_video`, you usually need to change `shift_audio` in proportion.
|
||||
|
||||
## Group 8: Detail Pass
|
||||
## Group 8: Latent Upscale
|
||||
|
||||
This is the optional second pass.
|
||||
This is the optional latent refinement stage, used before decode.
|
||||
|
||||
### `detail_pass`
|
||||
|
||||
Enables the refinement pass.
|
||||
The long-video node now expects a separate `Dumas H3 Latent Upscale Params` node for this stage.
|
||||
Wire that node into the `latent_upscale_param` input when you want the shot to be upscaled and lightly
|
||||
re-sampled before decode.
|
||||
|
||||
What this really means:
|
||||
|
||||
- the node renders the beat once
|
||||
- then runs a second sampler pass over that result
|
||||
- the goal is to polish, not to invent a whole different shot
|
||||
|
||||
### `detail_sampler_name`
|
||||
|
||||
Sampler for the refinement pass.
|
||||
|
||||
### `detail_scheduler`
|
||||
|
||||
Scheduler for the refinement pass.
|
||||
|
||||
### `detail_steps`
|
||||
|
||||
Extra steps for the refinement pass.
|
||||
|
||||
What this really means:
|
||||
|
||||
- more steps gives the second pass more opportunity to change the image
|
||||
- that can help detail
|
||||
- but after a point it stops being "cleanup" and starts becoming "rewrite"
|
||||
|
||||
### `detail_denoise`
|
||||
|
||||
How strongly the refinement pass is allowed to rewrite the beat.
|
||||
|
||||
What this really means:
|
||||
|
||||
- low denoise = polish what is already there
|
||||
- high denoise = let the second pass substantially alter what is already there
|
||||
|
||||
### How The Detail-Pass Settings Work Together
|
||||
|
||||
The detail pass starts from the first-pass result and tries to polish it.
|
||||
|
||||
Gentle settings:
|
||||
|
||||
- low to medium `detail_steps`
|
||||
- low `detail_denoise`
|
||||
|
||||
Aggressive settings:
|
||||
|
||||
- high `detail_steps`
|
||||
- high `detail_denoise`
|
||||
|
||||
Aggressive settings can improve texture, but they can also:
|
||||
|
||||
- change faces
|
||||
- pull away from references
|
||||
- break continuity
|
||||
|
||||
That is why this group should be read as one combined strength control:
|
||||
|
||||
- `detail_pass` decides whether the second pass exists
|
||||
- `detail_steps` decides how long it keeps working
|
||||
- `detail_denoise` decides how free it is to change things
|
||||
- `detail_sampler_name` and `detail_scheduler` shape how that rewrite behaves
|
||||
- the sampled latent is upscaled in latent space to the target size
|
||||
- the conditioning is rebuilt at that target size
|
||||
- the node then runs a short refinement pass over the upscaled latent with the sampler, scheduler, step count, denoise, and megapixel target you picked on the latent-upscale params node
|
||||
- if the target is larger than the spatial tile size, that refinement pass is processed in spatial batches using the same tile defaults as the upstream latent-split node
|
||||
- the spatial stitch mode follows the upstream overlap controls, including `linear`, `smoothstep`, `overwrite`, and `midpoint`
|
||||
- the node also carries the upstream split compatibility knobs (`chunk_length`, `temporal_overlap`, `resize_conditioning`, and `anchor_strength`) so the control surface stays in one place
|
||||
|
||||
Good starting point:
|
||||
|
||||
- `detail_pass = on`
|
||||
- `detail_sampler_name = euler`
|
||||
- `detail_scheduler = beta`
|
||||
- `detail_steps = 4` to `8`
|
||||
- `detail_denoise = 0.20` to `0.35`
|
||||
- use the `model` mode when you want the strongest latent detail recovery
|
||||
- use the interpolation mode when you want a cheaper resize-only path
|
||||
- start with `euler_ancestral`, `simple`, `2` steps, and `0.2` denoise
|
||||
- leave width and height at `0` unless you want an exact override; otherwise `megapixels` drives the target size
|
||||
- for long shots on smaller cards, start with `chunk_length = 85` and `temporal_overlap = 17` so the latent upscaler works in shorter temporal passes
|
||||
- leave the spatial tile inputs at their defaults first: `512x512` tiles, `64` overlap, `0` fade width, `earlier` overlap mode
|
||||
- keep `linear` blend first unless you want to reproduce a specific upstream stitch style
|
||||
- leave the split compatibility knobs alone unless you specifically need to mirror the upstream node behavior
|
||||
|
||||
The important part is that this stage is still a latent pass, not a pixel-space resize:
|
||||
|
||||
- it happens before decode
|
||||
- it can change structure more than a normal image upscale
|
||||
- it is the place to recover detail without adding another full detail-pass toggle
|
||||
|
||||
If you do not wire the helper node, the long-video node skips latent upscale entirely and renders as before.
|
||||
|
||||
## Group 9: Performance, Decode, And Upscale
|
||||
|
||||
|
||||
@@ -43,13 +43,27 @@
|
||||
- Prompt `<Picture N>` tags now map to the actual ref socket numbers you wire, even with gaps such as only `ref_2` and `ref_7` connected.
|
||||
- Character refs now contribute appearance and wardrobe context from the same structured object, while location refs contribute environment context from theirs.
|
||||
- The default ref2v bias is now stronger: `ref_mode` defaults to `auto ref2v` so untagged prompts condition every shot instead of only shot 1, and `ref_noise_aug` defaults to `0.95` rather than the upstream-literal `0.999`.
|
||||
- `Dumas H3 Latent Upscale Params` provides the optional pre-decode latent refinement stage for the long-video node.
|
||||
- Per-shot directives now support `continuity:`, `ref_mode:`, `ref_noise_aug:`, `anchor_add:`, `soundscape:`, and `music:` in addition to the existing timing and wardrobe directives.
|
||||
|
||||
- `Dumas H3 Latent Upscale Params`
|
||||
- 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.
|
||||
|
||||
- `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`, `reference_description`, `original_ref_description_1` through `original_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, selected structured references in compacted order, the compiled reference-description block used inside the prompt, and one individual description per selected reference.
|
||||
- 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`
|
||||
- Outputs: `seconds`, `frames`, `info`
|
||||
@@ -70,12 +84,34 @@
|
||||
- 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`
|
||||
@@ -233,7 +269,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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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
+752
-214
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,
|
||||
|
||||
+1017
-6
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);
|
||||
|
||||
@@ -53,11 +53,10 @@ const GROUPS = [
|
||||
},
|
||||
{
|
||||
id: "finish",
|
||||
label: "Upscale/Detail",
|
||||
label: "Upscale",
|
||||
defaultCollapsed: true,
|
||||
widgets: [
|
||||
"upscale", "upscale_model", "upscale_target_short_edge", "upscale_batch",
|
||||
"detail_pass", "detail_sampler_name", "detail_scheduler", "detail_steps", "detail_denoise",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
"PIL.Image",
|
||||
"folder_paths",
|
||||
"dumas_image_nodes",
|
||||
"dumas_h3_latent_upscale",
|
||||
"dumas_h3_longvideos",
|
||||
)
|
||||
}
|
||||
@@ -207,7 +208,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
"retry_elapsed": 1.2,
|
||||
"attempts": 2,
|
||||
"sample": 8.0,
|
||||
"detail_sample": 0.5,
|
||||
"latent_upscale_sample": 0.5,
|
||||
"decode_video": 2.1,
|
||||
"decode_audio": 0.4,
|
||||
"cleanup": 0.2,
|
||||
@@ -230,11 +231,11 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
self.assertIn("decode audio 0.7s", note)
|
||||
self.assertIn("cleanup 0.3s", note)
|
||||
self.assertIn("retry elapsed 1.2s", note)
|
||||
self.assertIn("detail 0.5s", note)
|
||||
self.assertIn("latent upscale 0.5s", note)
|
||||
self.assertIn("retries 1", note)
|
||||
self.assertIn("slowest shot 1 12.4s", note)
|
||||
|
||||
def test_detail_pass_refines_video_but_preserves_audio(self):
|
||||
def test_latent_upscale_refines_video_but_preserves_audio(self):
|
||||
class FakeTensor:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
@@ -263,6 +264,8 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
original_decode_video = self.module._decode_video
|
||||
original_decode_audio = self.module._decode_audio
|
||||
original_cleanup = self.module._deep_cleanup
|
||||
original_upscale = self.module._upscale_latent_video
|
||||
original_copy_sample = self.module._copy_sample_latent
|
||||
original_nested = getattr(self.module.comfy.nested_tensor, "NestedTensor", None)
|
||||
try:
|
||||
self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor
|
||||
@@ -277,6 +280,8 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
{"samples": FakeNestedTensor((FakeTensor("basev"), FakeTensor("basea")))},
|
||||
)
|
||||
self.module._evict_all_but = lambda *_args, **_kwargs: None
|
||||
self.module._upscale_latent_video = lambda video, param: (FakeTensor("upv"), 8, 16)
|
||||
self.module._copy_sample_latent = lambda sampled: sampled["samples"].unbind()
|
||||
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
|
||||
@@ -298,18 +303,25 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
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,
|
||||
latent_upscale_param={
|
||||
"mode": "model",
|
||||
"model_name": "upscale.safetensors",
|
||||
"device": "cpu",
|
||||
"precision": "fp16",
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "simple",
|
||||
"steps": 2,
|
||||
"denoise": 0.4,
|
||||
"megapixels": 1.5,
|
||||
},
|
||||
)
|
||||
|
||||
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.assertEqual(calls[1][0][8]["samples"].unbind()[0].name, "upv")
|
||||
self.assertEqual(calls[1][0][2], 2)
|
||||
self.assertEqual(calls[1][0][4], "euler_ancestral")
|
||||
self.assertEqual(calls[1][0][5], "simple")
|
||||
self.assertAlmostEqual(calls[1][1]["denoise"], 0.4)
|
||||
self.assertEqual(result[1], first_out)
|
||||
self.assertEqual(result[2][0].name, "v2")
|
||||
@@ -323,12 +335,14 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
self.module._decode_video = original_decode_video
|
||||
self.module._decode_audio = original_decode_audio
|
||||
self.module._deep_cleanup = original_cleanup
|
||||
self.module._upscale_latent_video = original_upscale
|
||||
self.module._copy_sample_latent = original_copy_sample
|
||||
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_decodes_audio_before_video_and_cleans_up(self):
|
||||
def test_latent_upscale_decodes_audio_before_video_and_cleans_up(self):
|
||||
class FakeTensor:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
@@ -348,6 +362,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
return self._parts
|
||||
|
||||
order = []
|
||||
build_calls = []
|
||||
first_out = {"samples": FakeNestedTensor((FakeTensor("v1"), FakeTensor("a1")))}
|
||||
second_out = {"samples": FakeNestedTensor((FakeTensor("v2"), FakeTensor("a2")))}
|
||||
|
||||
@@ -357,12 +372,16 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
original_decode_video = self.module._decode_video
|
||||
original_decode_audio = self.module._decode_audio
|
||||
original_cleanup = self.module._deep_cleanup
|
||||
original_upscale = self.module._upscale_latent_video
|
||||
original_copy_sample = self.module._copy_sample_latent
|
||||
original_unload = getattr(self.module.mm, "unload_model_and_clones", None)
|
||||
original_unload_all = self.module.mm.unload_all_models
|
||||
original_nested = getattr(self.module.comfy.nested_tensor, "NestedTensor", None)
|
||||
try:
|
||||
self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor
|
||||
|
||||
def common_ksampler(*args, **kwargs):
|
||||
order.append("detail_sample" if len(order) else "sample")
|
||||
order.append("latent_upscale_sample" if len(order) else "sample")
|
||||
return (first_out if len([x for x in order if x.endswith("sample")]) == 1 else second_out,)
|
||||
|
||||
def decode_audio(_vae, out_latent):
|
||||
@@ -379,12 +398,31 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
def cleanup():
|
||||
order.append("cleanup")
|
||||
|
||||
def unload_model_and_clones(*_args, **_kwargs):
|
||||
order.append("unload_h3_failed")
|
||||
raise RuntimeError("model wrapper does not expose clone metadata")
|
||||
|
||||
def unload_all_models(*_args, **_kwargs):
|
||||
order.append("unload_all")
|
||||
|
||||
def upscale_latent_video(video, param):
|
||||
order.append("upscale")
|
||||
return FakeTensor("upv"), 8, 16
|
||||
|
||||
def build_conditioning(*_args, **_kwargs):
|
||||
build_calls.append(True)
|
||||
return (
|
||||
[["cond", {}]],
|
||||
{"samples": FakeNestedTensor((FakeTensor("basev"), FakeTensor("basea")))},
|
||||
)
|
||||
|
||||
self.module.nodes.common_ksampler = common_ksampler
|
||||
self.module._build_shot_conditioning = lambda *_args, **_kwargs: (
|
||||
"cond",
|
||||
{"samples": FakeNestedTensor((FakeTensor("basev"), FakeTensor("basea")))},
|
||||
)
|
||||
self.module._build_shot_conditioning = build_conditioning
|
||||
self.module._evict_all_but = lambda *_args, **_kwargs: None
|
||||
self.module.mm.unload_model_and_clones = unload_model_and_clones
|
||||
self.module.mm.unload_all_models = unload_all_models
|
||||
self.module._upscale_latent_video = upscale_latent_video
|
||||
self.module._copy_sample_latent = lambda sampled: sampled["samples"].unbind()
|
||||
self.module._decode_video = decode_video
|
||||
self.module._decode_audio = decode_audio
|
||||
self.module._deep_cleanup = cleanup
|
||||
@@ -406,17 +444,25 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
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,
|
||||
latent_upscale_param={
|
||||
"mode": "model",
|
||||
"model_name": "upscale.safetensors",
|
||||
"device": "cuda",
|
||||
"precision": "fp16",
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "simple",
|
||||
"steps": 2,
|
||||
"denoise": 0.4,
|
||||
"megapixels": 1.5,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(order[0], "sample")
|
||||
self.assertEqual(order[1], "detail_sample")
|
||||
self.assertLess(order.index("unload_all"), order.index("upscale"))
|
||||
self.assertLess(order.index("upscale"), order.index("latent_upscale_sample"))
|
||||
self.assertLess(order.index("audio"), order.index("video"))
|
||||
self.assertEqual(order[-1], "cleanup")
|
||||
self.assertEqual(len(build_calls), 1)
|
||||
finally:
|
||||
self.module.nodes.common_ksampler = original_common_ksampler
|
||||
self.module._build_shot_conditioning = original_build
|
||||
@@ -424,12 +470,25 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
self.module._decode_video = original_decode_video
|
||||
self.module._decode_audio = original_decode_audio
|
||||
self.module._deep_cleanup = original_cleanup
|
||||
self.module._upscale_latent_video = original_upscale
|
||||
self.module._copy_sample_latent = original_copy_sample
|
||||
if original_unload is None:
|
||||
delattr(self.module.mm, "unload_model_and_clones")
|
||||
else:
|
||||
self.module.mm.unload_model_and_clones = original_unload
|
||||
self.module.mm.unload_all_models = original_unload_all
|
||||
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):
|
||||
def test_latent_refine_tiles_do_not_rebuild_conditioning(self):
|
||||
source = inspect.getsource(self.module.H3LongVideos._render)
|
||||
tile_branch = source[source.index("for col_index, c0 in enumerate(cols):"):]
|
||||
self.assertIn("_crop_conditioning_to_tile", tile_branch)
|
||||
self.assertNotIn("_build_shot_conditioning(", tile_branch)
|
||||
|
||||
def test_latent_upscale_off_skips_second_pass(self):
|
||||
calls = []
|
||||
original_common_ksampler = self.module.nodes.common_ksampler
|
||||
original_build = self.module._build_shot_conditioning
|
||||
@@ -437,6 +496,8 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
original_decode_video = self.module._decode_video
|
||||
original_decode_audio = self.module._decode_audio
|
||||
original_cleanup = self.module._deep_cleanup
|
||||
original_upscale = self.module._upscale_latent_video
|
||||
original_copy_sample = self.module._copy_sample_latent
|
||||
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"})
|
||||
@@ -444,6 +505,8 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
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._upscale_latent_video = lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("should not run"))
|
||||
self.module._copy_sample_latent = lambda sampled: sampled
|
||||
|
||||
self.module.H3LongVideos()._render(
|
||||
model=object(),
|
||||
@@ -459,7 +522,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
tiled=False,
|
||||
sa=(123, 20, 1.0, "res_multistep", "simple", 1.0),
|
||||
handoff=None,
|
||||
detail_pass="false",
|
||||
latent_upscale_param={"mode": "off"},
|
||||
)
|
||||
|
||||
self.assertEqual(len(calls), 1)
|
||||
@@ -470,6 +533,8 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
self.module._decode_video = original_decode_video
|
||||
self.module._decode_audio = original_decode_audio
|
||||
self.module._deep_cleanup = original_cleanup
|
||||
self.module._upscale_latent_video = original_upscale
|
||||
self.module._copy_sample_latent = original_copy_sample
|
||||
|
||||
def test_distribute_generations_canonicalizes_per_shot_audio_and_anchor_directives(self):
|
||||
generations = self.module.distribute_generations(
|
||||
@@ -736,6 +801,12 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
self.assertNotIn(f"ref_image_{index}", optional)
|
||||
self.assertNotIn("per_beat_length", optional)
|
||||
self.assertNotIn("cleanup_between_shots", optional)
|
||||
self.assertNotIn("detail_pass", optional)
|
||||
self.assertNotIn("detail_sampler_name", optional)
|
||||
self.assertNotIn("detail_scheduler", optional)
|
||||
self.assertNotIn("detail_steps", optional)
|
||||
self.assertNotIn("detail_denoise", optional)
|
||||
self.assertIn("latent_upscale_param", optional)
|
||||
|
||||
def test_shot_seconds_tooltip_describes_ceiling_behavior(self):
|
||||
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
|
||||
@@ -743,7 +814,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
|
||||
self.assertIn("GLOBAL per-shot maximum", tooltip)
|
||||
self.assertIn("A beat's own `seconds:` directive can still ask for less", tooltip)
|
||||
self.assertIn("honoring it; may spill to system RAM (slow) or OOM", tooltip)
|
||||
self.assertIn("let the render fail instead of shrinking it", tooltip)
|
||||
|
||||
def test_resolve_shot_frames_honors_forced_request_over_budget(self):
|
||||
original_estimate_shot_frames = self.module.estimate_shot_frames
|
||||
@@ -928,12 +999,23 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
plan_only=True,
|
||||
ref_1={"image": "live-1"},
|
||||
ref_3={"image": "live-3"},
|
||||
latent_upscale_param={
|
||||
"mode": "interp",
|
||||
"method": "bilinear",
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "simple",
|
||||
"steps": 2,
|
||||
"denoise": 0.2,
|
||||
"megapixels": 1.5,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(calls["refs"][0]["image"], "live-1")
|
||||
self.assertIsNone(calls["refs"][1])
|
||||
self.assertEqual(calls["refs"][2]["image"], "live-3")
|
||||
self.assertEqual(result[2].count("ref2va: 2 reference image(s)"), 1)
|
||||
self.assertIn("latent upscale:", result[2])
|
||||
self.assertIn("euler_ancestral/simple", result[2])
|
||||
finally:
|
||||
self.module.parse_resolution = original_parse_resolution
|
||||
self.module._connected_refs = original_connected_refs
|
||||
@@ -1061,6 +1143,389 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
{"DumasH3LongVideos": "Dumas H3 Long Videos (FL2VA + REF2VA)"},
|
||||
)
|
||||
|
||||
def test_latent_upscale_params_node_is_exposed(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
required = latent.H3LatentUpscaleParams.INPUT_TYPES()["required"]
|
||||
|
||||
self.assertEqual(
|
||||
latent.NODE_CLASS_MAPPINGS,
|
||||
{"DumasH3LatentUpscaleParams": latent.H3LatentUpscaleParams},
|
||||
)
|
||||
self.assertEqual(
|
||||
latent.NODE_DISPLAY_NAME_MAPPINGS,
|
||||
{"DumasH3LatentUpscaleParams": "Dumas H3 Latent Upscale Params"},
|
||||
)
|
||||
self.assertEqual(required["sampler_name"][1]["default"], "euler_ancestral")
|
||||
self.assertEqual(required["scheduler"][1]["default"], "simple")
|
||||
self.assertEqual(required["steps"][1]["default"], 2)
|
||||
self.assertEqual(required["denoise"][1]["default"], 0.2)
|
||||
self.assertEqual(required["megapixels"][1]["default"], 1.0)
|
||||
self.assertEqual(required["tile_width"][1]["default"], 512)
|
||||
self.assertEqual(required["tile_height"][1]["default"], 512)
|
||||
self.assertEqual(required["overlap"][1]["default"], 64)
|
||||
self.assertEqual(required["fade_width"][1]["default"], 32)
|
||||
self.assertEqual(required["fade_height"][1]["default"], 32)
|
||||
self.assertEqual(required["overlap_mode"][1]["default"], "earlier")
|
||||
self.assertEqual(required["overlap_blend"][1]["default"], "linear")
|
||||
self.assertEqual(required["tile_size_mode"][1]["default"], "specific_size")
|
||||
self.assertEqual(required["grid_rows"][1]["default"], 2)
|
||||
self.assertEqual(required["grid_cols"][1]["default"], 2)
|
||||
self.assertEqual(required["spatial_w_overlap"][1]["default"], 128)
|
||||
self.assertEqual(required["spatial_h_overlap"][1]["default"], 128)
|
||||
self.assertEqual(required["min_tile_size"][1]["default"], 256)
|
||||
self.assertEqual(required["masked_area_noise"][1]["default"], 0.0)
|
||||
self.assertFalse(required["brightness_match"][1]["default"])
|
||||
self.assertEqual(required["dynamic_fade"][1]["default"], "off")
|
||||
self.assertEqual(required["dynamic_fade_min"][1]["default"], 32)
|
||||
self.assertEqual(required["chunk_length"][1]["default"], 85)
|
||||
self.assertEqual(required["temporal_overlap"][1]["default"], 17)
|
||||
self.assertFalse(required["resize_conditioning"][1]["default"])
|
||||
self.assertEqual(required["anchor_strength"][1]["default"], 0.999)
|
||||
|
||||
def test_latent_upscale_mode_infers_legacy_model_payloads(self):
|
||||
self.assertEqual(self.module._latent_upscale_mode({"model_name": "foo.safetensors"}), "model")
|
||||
self.assertEqual(self.module._latent_upscale_mode({"method": "bilinear"}), "interp")
|
||||
self.assertEqual(self.module._latent_upscale_mode({"mode": "model"}), "model")
|
||||
self.assertEqual(self.module._latent_upscale_mode({}), "off")
|
||||
|
||||
def test_tag_oom_stage_marks_oom_exceptions(self):
|
||||
exc = RuntimeError("CUDA out of memory")
|
||||
tagged = self.module._tag_oom_stage(exc, "latent_upscale")
|
||||
self.assertIs(tagged, exc)
|
||||
self.assertEqual(getattr(tagged, "_h3_stage", ""), "latent_upscale")
|
||||
|
||||
def test_shrink_model_tile_param_reduces_tile_size(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
smaller = latent._shrink_model_tile_param({
|
||||
"tile_size_mode": "specific_size",
|
||||
"tile_width": 512,
|
||||
"tile_height": 512,
|
||||
"overlap": 64,
|
||||
"fade_width": 32,
|
||||
"fade_height": 32,
|
||||
})
|
||||
self.assertIsNotNone(smaller)
|
||||
self.assertEqual(smaller["tile_size_mode"], "rows_cols")
|
||||
self.assertEqual(smaller["grid_rows"], 4)
|
||||
self.assertEqual(smaller["grid_cols"], 4)
|
||||
self.assertEqual(smaller["spatial_w_overlap"], 0)
|
||||
self.assertEqual(smaller["spatial_h_overlap"], 0)
|
||||
self.assertEqual(smaller["fade_width"], 0)
|
||||
self.assertEqual(smaller["fade_height"], 0)
|
||||
self.assertEqual(smaller["min_tile_size"], 32)
|
||||
|
||||
def test_shrink_model_tile_param_rows_cols_resets_overlap(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
smaller = latent._shrink_model_tile_param({
|
||||
"tile_size_mode": "rows_cols",
|
||||
"grid_rows": 4,
|
||||
"grid_cols": 4,
|
||||
"spatial_w_overlap": 128,
|
||||
"spatial_h_overlap": 128,
|
||||
"fade_width": 64,
|
||||
"fade_height": 64,
|
||||
"min_tile_size": 256,
|
||||
})
|
||||
self.assertIsNotNone(smaller)
|
||||
self.assertEqual(smaller["grid_rows"], 8)
|
||||
self.assertEqual(smaller["grid_cols"], 8)
|
||||
self.assertEqual(smaller["spatial_w_overlap"], 0)
|
||||
self.assertEqual(smaller["spatial_h_overlap"], 0)
|
||||
self.assertEqual(smaller["fade_width"], 0)
|
||||
self.assertEqual(smaller["fade_height"], 0)
|
||||
self.assertEqual(smaller["min_tile_size"], 32)
|
||||
|
||||
def test_shrink_model_tile_param_rows_cols_can_reach_thirty_two(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
smaller = latent._shrink_model_tile_param({
|
||||
"tile_size_mode": "rows_cols",
|
||||
"grid_rows": 16,
|
||||
"grid_cols": 16,
|
||||
"spatial_w_overlap": 0,
|
||||
"spatial_h_overlap": 0,
|
||||
"fade_width": 0,
|
||||
"fade_height": 0,
|
||||
"min_tile_size": 32,
|
||||
})
|
||||
self.assertIsNotNone(smaller)
|
||||
self.assertEqual(smaller["grid_rows"], 32)
|
||||
self.assertEqual(smaller["grid_cols"], 32)
|
||||
|
||||
def test_temporal_segments_split_long_sequences(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
bounds = latent._temporal_segments(36, 85, 17)
|
||||
self.assertGreater(len(bounds), 1)
|
||||
self.assertEqual(bounds[0][0], 0)
|
||||
self.assertEqual(bounds[-1][2], 36)
|
||||
|
||||
def test_shrink_temporal_param_reduces_chunk_length(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
smaller = latent._shrink_temporal_param({
|
||||
"chunk_length": 85,
|
||||
"temporal_overlap": 17,
|
||||
})
|
||||
self.assertIsNotNone(smaller)
|
||||
self.assertEqual(smaller["chunk_length"], 17)
|
||||
self.assertEqual(smaller["temporal_overlap"], 0)
|
||||
|
||||
def test_cuda_model_temporal_params_keep_splitting_saved_workflows(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
chunk_length, temporal_overlap = latent._effective_temporal_params({
|
||||
"mode": "model",
|
||||
"device": "cuda",
|
||||
"chunk_length": 85,
|
||||
"temporal_overlap": 17,
|
||||
}, frame_count=124)
|
||||
self.assertEqual(chunk_length, 85)
|
||||
self.assertEqual(temporal_overlap, 17)
|
||||
|
||||
def test_cuda_model_temporal_params_keep_short_saved_workflows_until_oom(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
chunk_length, temporal_overlap = latent._effective_temporal_params({
|
||||
"mode": "model",
|
||||
"device": "cuda",
|
||||
"chunk_length": 85,
|
||||
"temporal_overlap": 17,
|
||||
}, frame_count=85)
|
||||
self.assertEqual(chunk_length, 85)
|
||||
self.assertEqual(temporal_overlap, 17)
|
||||
|
||||
def test_cuda_model_oom_retries_temporal_before_spatial_fallback(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
|
||||
calls = []
|
||||
original_tiled = latent._upscale_video_model_tiled
|
||||
original_shrink_model = latent._shrink_model_tile_param
|
||||
try:
|
||||
def tiled(_video, param):
|
||||
calls.append(("tiled", param.get("chunk_length"), param.get("tile_size_mode")))
|
||||
raise RuntimeError("out of memory")
|
||||
|
||||
latent._upscale_video_model_tiled = tiled
|
||||
latent._shrink_model_tile_param = (
|
||||
lambda param: calls.append(("shrink_spatial", param.get("tile_size_mode"))) or None
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "smaller temporal chunk"):
|
||||
latent.upscale_video_model(
|
||||
"video",
|
||||
{
|
||||
"mode": "model",
|
||||
"device": "cuda",
|
||||
"model_name": "upscale.safetensors",
|
||||
"chunk_length": 85,
|
||||
"temporal_overlap": 17,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(calls[0], ("tiled", 85, None))
|
||||
self.assertNotIn(("shrink_spatial", None), calls)
|
||||
finally:
|
||||
latent._upscale_video_model_tiled = original_tiled
|
||||
latent._shrink_model_tile_param = original_shrink_model
|
||||
|
||||
def test_interp_temporal_params_preserve_upstream_defaults(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
chunk_length, temporal_overlap = latent._effective_temporal_params({
|
||||
"mode": "interp",
|
||||
"device": "cuda",
|
||||
"chunk_length": 85,
|
||||
"temporal_overlap": 17,
|
||||
})
|
||||
self.assertEqual(chunk_length, 85)
|
||||
self.assertEqual(temporal_overlap, 17)
|
||||
|
||||
def test_unload_upscale_model_defers_while_held(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
|
||||
class FakeParam:
|
||||
device = "cuda"
|
||||
|
||||
class FakeModel:
|
||||
def __init__(self):
|
||||
self.moves = []
|
||||
|
||||
def parameters(self):
|
||||
return iter((FakeParam(),))
|
||||
|
||||
def to(self, device):
|
||||
self.moves.append(device)
|
||||
return self
|
||||
|
||||
cache_key = "upscale.safetensors::cuda::fp16"
|
||||
original_cache_value = latent._MODEL_CACHE.get(cache_key)
|
||||
original_hold_depth = latent._MODEL_HOLD_DEPTH
|
||||
fake_model = FakeModel()
|
||||
try:
|
||||
latent._MODEL_CACHE[cache_key] = fake_model
|
||||
latent._MODEL_HOLD_DEPTH = 0
|
||||
with latent._hold_upscale_model_loaded():
|
||||
latent.unload_upscale_model("upscale.safetensors", "cuda", "fp16")
|
||||
self.assertEqual(fake_model.moves, [])
|
||||
|
||||
latent.unload_upscale_model("upscale.safetensors", "cuda", "fp16")
|
||||
self.assertEqual(fake_model.moves, ["cpu"])
|
||||
finally:
|
||||
latent._MODEL_HOLD_DEPTH = original_hold_depth
|
||||
if original_cache_value is None:
|
||||
latent._MODEL_CACHE.pop(cache_key, None)
|
||||
else:
|
||||
latent._MODEL_CACHE[cache_key] = original_cache_value
|
||||
|
||||
def test_model_upscale_releases_cached_model_after_pass(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
calls = []
|
||||
|
||||
original_temporal = latent._upscale_video_temporal_chunks
|
||||
original_unload_now = latent._unload_upscale_model_now
|
||||
original_cuda = latent.torch.cuda
|
||||
original_device = getattr(latent.torch, "device", None)
|
||||
try:
|
||||
latent.torch.cuda = types.SimpleNamespace(is_available=lambda: True)
|
||||
latent.torch.device = lambda value: value
|
||||
|
||||
def temporal(video, param, upscaler):
|
||||
calls.append(("temporal", latent._MODEL_HOLD_DEPTH))
|
||||
return "video", 8, 16
|
||||
|
||||
def unload_now(name, device, precision):
|
||||
calls.append(("unload", name, device, precision, latent._MODEL_HOLD_DEPTH))
|
||||
|
||||
latent._upscale_video_temporal_chunks = temporal
|
||||
latent._unload_upscale_model_now = unload_now
|
||||
|
||||
result = latent.upscale_latent_video("source", {
|
||||
"mode": "model",
|
||||
"model_name": "upscale.safetensors",
|
||||
"device": "cuda",
|
||||
"precision": "fp16",
|
||||
})
|
||||
|
||||
self.assertEqual(result, ("video", 8, 16))
|
||||
self.assertEqual(calls[0], ("temporal", 1))
|
||||
self.assertEqual(calls[1], ("unload", "upscale.safetensors", "cuda", "fp16", 1))
|
||||
self.assertEqual(latent._MODEL_HOLD_DEPTH, 0)
|
||||
finally:
|
||||
latent._upscale_video_temporal_chunks = original_temporal
|
||||
latent._unload_upscale_model_now = original_unload_now
|
||||
latent.torch.cuda = original_cuda
|
||||
if original_device is None:
|
||||
delattr(latent.torch, "device")
|
||||
else:
|
||||
latent.torch.device = original_device
|
||||
|
||||
def test_model_upscale_oom_falls_back_to_interp(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
calls = []
|
||||
|
||||
original_temporal = latent._upscale_video_temporal_chunks
|
||||
original_interp = latent.upscale_video_interp
|
||||
original_unload_now = latent._unload_upscale_model_now
|
||||
original_cuda = latent.torch.cuda
|
||||
original_device = getattr(latent.torch, "device", None)
|
||||
try:
|
||||
latent.torch.cuda = types.SimpleNamespace(
|
||||
is_available=lambda: True,
|
||||
empty_cache=lambda: calls.append(("empty_cache",)),
|
||||
)
|
||||
latent.torch.device = lambda value: value
|
||||
|
||||
def temporal(_video, _param, _upscaler):
|
||||
calls.append(("temporal", latent._MODEL_HOLD_DEPTH))
|
||||
raise RuntimeError("H3 latent upscale exhausted its GPU spatial fallbacks")
|
||||
|
||||
def interp(video, param):
|
||||
calls.append(("interp", video, param.get("mode"), param.get("method")))
|
||||
return "interp_video", 8, 16
|
||||
|
||||
def unload_now(name, device, precision):
|
||||
calls.append(("unload", name, device, precision, latent._MODEL_HOLD_DEPTH))
|
||||
|
||||
latent._upscale_video_temporal_chunks = temporal
|
||||
latent.upscale_video_interp = interp
|
||||
latent._unload_upscale_model_now = unload_now
|
||||
|
||||
result = latent.upscale_latent_video("source", {
|
||||
"mode": "model",
|
||||
"model_name": "upscale.safetensors",
|
||||
"method": "bilinear",
|
||||
"device": "cuda",
|
||||
"precision": "fp16",
|
||||
})
|
||||
|
||||
self.assertEqual(result, ("interp_video", 8, 16))
|
||||
self.assertEqual(calls[0], ("temporal", 1))
|
||||
self.assertEqual(calls[1], ("unload", "upscale.safetensors", "cuda", "fp16", 1))
|
||||
self.assertIn(("empty_cache",), calls)
|
||||
self.assertEqual(calls[-1], ("interp", "source", "interp", "bilinear"))
|
||||
self.assertEqual(latent._MODEL_HOLD_DEPTH, 0)
|
||||
finally:
|
||||
latent._upscale_video_temporal_chunks = original_temporal
|
||||
latent.upscale_video_interp = original_interp
|
||||
latent._unload_upscale_model_now = original_unload_now
|
||||
latent.torch.cuda = original_cuda
|
||||
if original_device is None:
|
||||
delattr(latent.torch, "device")
|
||||
else:
|
||||
latent.torch.device = original_device
|
||||
|
||||
def test_model_upscale_skips_learned_model_on_8gb_cuda(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
calls = []
|
||||
|
||||
original_temporal = latent._upscale_video_temporal_chunks
|
||||
original_interp = latent.upscale_video_interp
|
||||
original_cuda = latent.torch.cuda
|
||||
try:
|
||||
latent.torch.cuda = types.SimpleNamespace(
|
||||
is_available=lambda: True,
|
||||
mem_get_info=lambda: (1 * 1024 * 1024 * 1024, 8 * 1024 * 1024 * 1024),
|
||||
empty_cache=lambda: calls.append(("empty_cache",)),
|
||||
)
|
||||
|
||||
def temporal(_video, _param, _upscaler):
|
||||
calls.append(("temporal",))
|
||||
raise AssertionError("learned model path should be skipped on 8GB CUDA")
|
||||
|
||||
def interp(video, param):
|
||||
calls.append(("interp", video, param.get("mode"), param.get("method")))
|
||||
return "interp_video", 8, 16
|
||||
|
||||
latent._upscale_video_temporal_chunks = temporal
|
||||
latent.upscale_video_interp = interp
|
||||
|
||||
result = latent.upscale_latent_video("source", {
|
||||
"mode": "model",
|
||||
"model_name": "upscale.safetensors",
|
||||
"method": "bilinear",
|
||||
"device": "cuda",
|
||||
"precision": "fp16",
|
||||
})
|
||||
|
||||
self.assertEqual(result, ("interp_video", 8, 16))
|
||||
self.assertNotIn(("temporal",), calls)
|
||||
self.assertIn(("empty_cache",), calls)
|
||||
self.assertEqual(calls[-1], ("interp", "source", "interp", "bilinear"))
|
||||
finally:
|
||||
latent._upscale_video_temporal_chunks = original_temporal
|
||||
latent.upscale_video_interp = original_interp
|
||||
latent.torch.cuda = original_cuda
|
||||
|
||||
def test_upscale_video_model_raises_when_gpu_cannot_shrink(self):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
|
||||
original_tiled = latent._upscale_video_model_tiled
|
||||
original_shrink = latent._shrink_model_tile_param
|
||||
try:
|
||||
latent._shrink_model_tile_param = lambda _param: None
|
||||
latent._upscale_video_model_tiled = lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("out of memory"))
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "H3 latent upscale exhausted its GPU spatial fallbacks"):
|
||||
latent.upscale_video_model("video", {"device": "cuda", "precision": "fp16"})
|
||||
finally:
|
||||
latent._upscale_video_model_tiled = original_tiled
|
||||
latent._shrink_model_tile_param = original_shrink
|
||||
|
||||
def test_compose_persistent_does_not_expand_ambiguous_plural_to_full_cast(self):
|
||||
active = self.module.parse_wardrobe(
|
||||
"Maya = she, red jacket\n"
|
||||
|
||||
@@ -356,6 +356,312 @@ class DumasImageNodeTests(unittest.TestCase):
|
||||
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.assertEqual(result[15]["name"], "Dave")
|
||||
self.assertIs(result[15]["image"], dave_image)
|
||||
self.assertEqual(result[16]["name"], "Coffee Shop")
|
||||
self.assertIs(result[16]["image"], cafe_image)
|
||||
self.assertIsNone(result[17])
|
||||
self.assertIn("<Picture 1> Dave", result[24])
|
||||
self.assertIn("<Picture 2> Coffee Shop", result[24])
|
||||
self.assertIn("Location context for <Picture 2> Coffee Shop", result[24])
|
||||
self.assertIn("<Picture 1> Dave", result[25])
|
||||
self.assertNotIn("<Picture 2> Coffee Shop", result[25])
|
||||
self.assertIn("<Picture 2> Coffee Shop", result[26])
|
||||
self.assertIn("Location context for <Picture 2> Coffee Shop", result[26])
|
||||
self.assertEqual(result[27], "")
|
||||
|
||||
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), 34)
|
||||
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], "reference_description")
|
||||
self.assertEqual(
|
||||
node.RETURN_NAMES[25:34],
|
||||
tuple(f"original_ref_description_{i}" for i in range(1, 10)),
|
||||
)
|
||||
|
||||
def test_normalize_reference_upgrades_generic_summary_with_socket_picture_id(self):
|
||||
image = FakeTensorBatch()
|
||||
|
||||
@@ -417,6 +723,19 @@ class DumasImageNodeTests(unittest.TestCase):
|
||||
self.assertIn("real time", 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()
|
||||
custom = "Lo-fi pirate broadcast with smeared highlights and anxious zoom corrections."
|
||||
|
||||
Reference in New Issue
Block a user