Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,8 +43,14 @@
|
||||
- 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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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."}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,292 @@ 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_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"
|
||||
|
||||
Reference in New Issue
Block a user