Compare commits

...
67 Commits
Author SHA1 Message Date
chris.dumas e8599055a1 Force temporal chunks for CUDA latent upscale 2026-09-04 10:22:41 +00:00
chris.dumas 618a48e4d9 Harden latent upscale GPU cleanup 2026-09-04 10:05:24 +00:00
chris.dumas cbabcf8208 Offload H3 before latent upscale 2026-09-04 09:49:34 +00:00
chris.dumas fbfbe4ef5a Extend H3 spatial fallback ladder 2026-09-04 09:22:35 +00:00
chris.dumas 5b52793fad Clamp H3 spatial overlaps to tile size 2026-09-04 09:05:29 +00:00
chris.dumas 7e45c81589 Reset overlap in H3 spatial retries 2026-09-04 08:48:20 +00:00
chris.dumas 027865b9ca Tighten H3 spatial fallback retries 2026-09-04 08:27:54 +00:00
chris.dumas 9f0c546681 Remove latent upscale CPU fallback 2026-09-04 08:13:27 +00:00
chris.dumas 30ae81b86b Jump latent upscale fallback to spatial split 2026-09-04 08:00:06 +00:00
chris.dumas 179fa0778e Add CPU fallback for latent upscale OOM 2026-09-04 07:33:10 +00:00
chris.dumas a34c9eb4ff Reduce latent upscale VRAM pressure 2026-09-04 07:21:38 +00:00
chris.dumas 782a7d658b Fix temporal chunking to use H3 token grid 2026-09-04 07:06:08 +00:00
chris.dumas 04a61af874 Add temporal OOM backoff for latent upscale 2026-09-04 06:54:35 +00:00
chris.dumas a0d80bcc47 Add temporal chunking to latent upscale 2026-09-04 06:41:03 +00:00
chris.dumas fbb5f799cd Unload latent upscale model on OOM 2026-09-03 20:30:58 +00:00
chris.dumas 9dc3c405a6 Clamp latent upscale fallback tile minimum 2026-09-03 20:15:58 +00:00
chris.dumas 21ae4d62e0 Keep latent upscale shrink steps 32-aligned 2026-09-03 19:59:50 +00:00
chris.dumas 836a6eba33 Back off latent upscale tile size on OOM 2026-09-03 19:43:15 +00:00
chris.dumas 3b63d33ec7 Tile latent upscale model inference 2026-09-03 19:01:13 +00:00
chris.dumas c62921c3e8 Stop retrying latent upscale OOMs as sampling 2026-09-03 18:46:56 +00:00
chris.dumas 3ab6342ce5 Handle legacy latent upscale payloads 2026-09-03 18:33:01 +00:00
chris.dumas 51e03a39b7 Reduce latent upscale branch memory 2026-09-03 18:08:44 +00:00
chris.dumas f7b94ccaef Free first-pass latent before refinement 2026-09-03 16:34:12 +00:00
chris.dumas f6120a8500 Adopt full MMH3 spatial split controls 2026-09-03 16:15:43 +00:00
chris.dumas 0e119646ab Add remaining spatial split settings 2026-09-03 15:47:59 +00:00
chris.dumas c89570eae9 Expand latent upscale spatial stitch controls 2026-09-03 15:35:39 +00:00
chris.dumas 36d9f4369c Add spatial batching to latent upscale 2026-09-03 15:12:42 +00:00
chris.dumas 2cb3c694f0 Fix latent upscale summary scope leak 2026-09-03 14:37:24 +00:00
chris.dumas 5c12cd18a3 Expose latent upscale sampler controls 2026-09-03 13:05:32 +00:00
chris.dumas c1d937e0e2 Add H3 latent upscale refinement stage 2026-09-03 12:53:57 +00:00
chris.dumas 306816534b Bump package version to 0.1.1 2026-09-03 12:18:26 +00:00
chris.dumas e098e44bf1 Simplify H3 beat timing and cleanup 2026-09-03 08:56:43 +00:00
chris.dumas 769257513b Harden H3 detail-pass cleanup 2026-09-03 07:18:13 +00:00
chris.dumas ab6ba86d76 Fix H3 shot mode runtime error 2026-09-02 15:20:07 +00:00
chris.dumas ecd8ca6336 Align H3 prompt refs with beat numbering 2026-09-02 15:06:41 +00:00
chris.dumas 76b88b0c59 Clean up per-beat H3 script output 2026-09-02 14:52:58 +00:00
chris.dumas 62fbbdd98e Use socket picture labels in ref summaries 2026-09-02 14:44:54 +00:00
chris.dumas 85e15c910a Fix H3 section control interactions 2026-09-02 14:39:10 +00:00
chris.dumas 30400038c2 Add collapsible H3 long video sections 2026-09-02 13:25:54 +00:00
chris.dumas c8ddedbd57 Remove picture id from reference nodes 2026-09-02 13:20:38 +00:00
chris.dumas 6684c5f2e0 Remove legacy H3 ref image aliases 2026-09-02 13:06:25 +00:00
chris.dumas 08630964d5 Keep multiline beat prompts intact 2026-09-02 13:00:25 +00:00
chris.dumas 271e7de300 Fix tag-driven H3 ref routing 2026-09-02 12:55:51 +00:00
chris.dumas 130b268d1e Fix named reference lookup for long videos 2026-08-31 09:40:51 +00:00
chris.dumas a2dabaab7e Revert "Revert long videos ref routing to release"
This reverts commit 00985b73b3.
2026-08-30 21:37:15 +00:00
chris.dumas b7c3bba199 Revert "Restore missing named refs helper"
This reverts commit dfead66b9e.
2026-08-30 21:37:06 +00:00
chris.dumas 35ec12159e Revert "Retry long videos decode with tiling fallback"
This reverts commit 6a7bec5d03.
2026-08-30 21:36:58 +00:00
chris.dumas 58b1c77c19 Revert "Restore long videos release snapshot"
This reverts commit 4a0f5cfcd4.
2026-08-30 21:36:50 +00:00
chris.dumas 4a0f5cfcd4 Restore long videos release snapshot 2026-08-30 21:06:22 +00:00
chris.dumas 6a7bec5d03 Retry long videos decode with tiling fallback 2026-08-30 20:28:47 +00:00
chris.dumas dfead66b9e Restore missing named refs helper 2026-08-30 13:23:13 +00:00
chris.dumas 00985b73b3 Revert long videos ref routing to release 2026-08-30 10:07:45 +00:00
chris.dumas ffae1d87a5 Restore character ref regression tests 2026-08-29 12:20:38 +00:00
chris.dumas 74aca203ab Restore release-era character ref routing 2026-08-29 12:18:38 +00:00
chris.dumas e08281c12b Prioritize character refs over location refs 2026-08-29 11:36:53 +00:00
chris.dumas faaeb374e0 Document named ref routing guardrail 2026-08-29 11:02:21 +00:00
chris.dumas 0bac83a689 Fix named character ref routing 2026-08-29 10:51:01 +00:00
chris.dumas 08365c7df5 Add long videos timing breakdown 2026-08-29 00:22:48 +00:00
chris.dumas 9cbab72e86 Optimize long videos ref bookkeeping 2026-08-28 22:09:12 +00:00
chris.dumas e29d01ad24 Tighten tagged ref routing and anchor presets 2026-08-28 15:45:22 +00:00
chris.dumas ddd7dbb5af Harden and streamline H3 reference matching 2026-08-28 13:49:01 +00:00
chris.dumas 1ad68ee9e4 Stop ref wardrobe from fighting live character memory 2026-08-28 13:16:05 +00:00
chris.dumas e64ad0fae4 Expand H3 reference-node documentation 2026-08-28 12:48:33 +00:00
chris.dumas deb6f1d033 Clarify H3 long videos state behavior 2026-08-28 12:36:46 +00:00
chris.dumas 72d4d74dd3 Clarify H3 long videos guide behavior 2026-08-28 12:27:12 +00:00
chris.dumas c42cf436c5 Document H3 long videos node 2026-08-28 11:43:11 +00:00
chris.dumas eeca789c27 Tidy H3 long videos input layout 2026-08-28 11:36:39 +00:00
14 changed files with 5530 additions and 1326 deletions
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -38,12 +38,19 @@
- Outputs: `images`, `audio`, `info`, `script`, `frames_per_shot`, `total_frames`, `shots`, `video_seconds`, `fps`, `fps_int`, `latent`, `soundscape` - Outputs: `images`, `audio`, `info`, `script`, `frames_per_shot`, `total_frames`, `shots`, `video_seconds`, `fps`, `fps_int`, `latent`, `soundscape`
- First-pass Dumas port of the `MiniMax-H3-Longvideos` sampler, brought in as a local starting point for long-form H3 chaining work. - First-pass Dumas port of the `MiniMax-H3-Longvideos` sampler, brought in as a local starting point for long-form H3 chaining work.
- Keeps the upstream split-beats / handoff / ref-routing behavior close to source so future Dumas-specific improvements can be compared against a known baseline. - Keeps the upstream split-beats / handoff / ref-routing behavior close to source so future Dumas-specific improvements can be compared against a known baseline.
- Full user guide: [`H3_LONG_VIDEOS_GUIDE.md`](./H3_LONG_VIDEOS_GUIDE.md)
- Only the canonical `DumasH3LongVideos` node key is exposed now; the older FL2VA/REF2VA alias entries are no longer duplicated in the Add Node menu. - Only the canonical `DumasH3LongVideos` node key is exposed now; the older FL2VA/REF2VA alias entries are no longer duplicated in the Add Node menu.
- Prompt `<Picture N>` tags now map to the actual ref socket numbers you wire, even with gaps such as only `ref_2` and `ref_7` connected. - 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. - 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`. - 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. - 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` - `Dumas H3 Beat Prompt`
- Inputs: authored through the custom front-end beat editor - Inputs: authored through the custom front-end beat editor
- Output: `prompt` - Output: `prompt`
@@ -60,12 +67,12 @@
- Reports the detected H3 base precision / quant format and the relevant compute-capability hints for the current card. - Reports the detected H3 base precision / quant format and the relevant compute-capability hints for the current card.
- `Dumas Character Reference` - `Dumas Character Reference`
- Inputs: `image`, `picture_id`, `character_id`, `name`, `alias`, `gender`, `age`, `nationality`, `occupation`, `height_feet`, `height_inches`, `accent`, `description`, `general`, `wardrobe` - Inputs: `image`, `character_id`, `name`, `alias`, `gender`, `age`, `nationality`, `occupation`, `height_feet`, `height_inches`, `accent`, `description`, `general`, `wardrobe`
- Output: `reference` - Output: `reference`
- Builds one structured `REFERENCE` object carrying the conditioning image, identity description, wardrobe, general notes, and simple facts together. - Builds one structured `REFERENCE` object carrying the conditioning image, identity description, wardrobe, general notes, and simple facts together.
- `Dumas Location Reference` - `Dumas Location Reference`
- Inputs: `image`, `picture_id`, `location_id`, `name`, `alias`, `description`, `general` - Inputs: `image`, `location_id`, `name`, `alias`, `description`, `general`
- Output: `reference` - 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. - Builds one structured `REFERENCE` object for a location/environment so H3 can use the same socket type for both character and scenic refs.
+4 -5
View File
@@ -119,8 +119,10 @@ For locations:
- Optional list of alternate match names. - Optional list of alternate match names.
- `picture_id` - `picture_id`
- Optional integer representing the intended `<Picture N>` identity. - Optional integer representing the effective `<Picture N>` identity.
- This is authoring metadata, not the final socket position. - It is inferred by consumer nodes such as `Dumas H3 Long Videos` from the
connected socket position when that position is known.
- Producer nodes do not need a manual `picture_id` input.
- `picture_label` - `picture_label`
- Derived convenience text like `<Picture 1>`. - Derived convenience text like `<Picture 1>`.
@@ -167,7 +169,6 @@ Recommended name:
Inputs: Inputs:
- `image` - `image`
- `picture_id`
- `character_id` - `character_id`
- `name` - `name`
- `alias` - `alias`
@@ -202,7 +203,6 @@ Recommended name:
Inputs: Inputs:
- `image` - `image`
- `picture_id`
- `location_id` - `location_id`
- `name` - `name`
- `alias` - `alias`
@@ -383,4 +383,3 @@ That is the change that removes the current ambiguity between:
- character identity - character identity
- wardrobe data - wardrobe data
- location / environment description - location / environment description
+6
View File
@@ -14,6 +14,10 @@ from .dumas_h3_longvideos import (
NODE_CLASS_MAPPINGS as H3_LONGVIDEO_NODE_CLASS_MAPPINGS, NODE_CLASS_MAPPINGS as H3_LONGVIDEO_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as H3_LONGVIDEO_NODE_DISPLAY_NAME_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 ( from .dumas_h3_shot_length import (
NODE_CLASS_MAPPINGS as H3_SHOT_LENGTH_NODE_CLASS_MAPPINGS, NODE_CLASS_MAPPINGS as H3_SHOT_LENGTH_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as H3_SHOT_LENGTH_NODE_DISPLAY_NAME_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(JSON_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(IMAGE_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_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_SHOT_LENGTH_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(H3_INSPECTOR_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(H3_INSPECTOR_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(H3_BEAT_PROMPT_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(JSON_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(IMAGE_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_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_SHOT_LENGTH_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(H3_INSPECTOR_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) NODE_DISPLAY_NAME_MAPPINGS.update(H3_BEAT_PROMPT_NODE_DISPLAY_NAME_MAPPINGS)
+10 -1
View File
@@ -150,7 +150,16 @@ class H3ModelInspector:
@classmethod @classmethod
def INPUT_TYPES(cls): 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): def inspect(self, model):
label, _counts, report = _detect(model) label, _counts, report = _detect(model)
File diff suppressed because it is too large Load Diff
+1920 -1226
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -39,10 +39,11 @@ class H3ShotLength:
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
"required": { "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) " "tooltip": "Length of each shot. Feeds the sampler's shot_seconds AND (as frames) "
"the preview override. Max ~15s (362 frames)."}), "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}), "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": { "optional": {
"cap_to_h3_max": ("BOOLEAN", {"default": True, "cap_to_h3_max": ("BOOLEAN", {"default": True,
+13 -25
View File
@@ -26,10 +26,7 @@ _H3_PLAN_IMAGE_BINDINGS = OrderedDict()
_H3_PLAN_IMAGE_BINDINGS_CAP = 128 _H3_PLAN_IMAGE_BINDINGS_CAP = 128
_H3_PLAN_IMAGE_SLOTS = 9 _H3_PLAN_IMAGE_SLOTS = 9
_FOLDER_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tiff", ".tif") _FOLDER_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tiff", ".tif")
_ANCHOR_STYLE_H3_NOTE = ( _ANCHOR_STYLE_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."
)
_ANCHOR_STYLE_PRESETS = OrderedDict( _ANCHOR_STYLE_PRESETS = OrderedDict(
[ [
( (
@@ -889,6 +886,8 @@ def normalize_reference(value, picture_id=None, allow_image_fallback=True):
if image is None and allow_image_fallback: if image is None and allow_image_fallback:
image = value image = value
reference["image"] = image reference["image"] = image
existing_picture_id = _coerce_picture_id(reference.get("picture_id"))
previous_summary = _ensure_sentence(reference.get("summary") or "")
if picture_id is not None and not reference.get("picture_id"): if picture_id is not None and not reference.get("picture_id"):
reference["picture_id"] = _coerce_picture_id(picture_id) reference["picture_id"] = _coerce_picture_id(picture_id)
reference["picture_label"] = _reference_label(reference.get("picture_id")) reference["picture_label"] = _reference_label(reference.get("picture_id"))
@@ -896,10 +895,16 @@ def normalize_reference(value, picture_id=None, allow_image_fallback=True):
reference.setdefault("id", _reference_id("", reference.get("name"), reference["kind"])) reference.setdefault("id", _reference_id("", reference.get("name"), reference["kind"]))
reference.setdefault("name", "") reference.setdefault("name", "")
reference["aliases"] = _parse_aliases(reference.get("aliases")) reference["aliases"] = _parse_aliases(reference.get("aliases"))
reference["summary"] = _ensure_sentence( auto_summary_before = _ensure_sentence(
reference.get("summary") _reference_summary(reference["kind"], reference.get("name"), existing_picture_id)
or _reference_summary(reference["kind"], reference.get("name"), reference.get("picture_id"))
) )
auto_summary_after = _ensure_sentence(
_reference_summary(reference["kind"], reference.get("name"), reference.get("picture_id"))
)
if not previous_summary or previous_summary == auto_summary_before:
reference["summary"] = auto_summary_after
else:
reference["summary"] = previous_summary
reference["description"] = _normalize_free_text(reference.get("description")) reference["description"] = _normalize_free_text(reference.get("description"))
reference["wardrobe"] = _normalize_free_text(reference.get("wardrobe")) reference["wardrobe"] = _normalize_free_text(reference.get("wardrobe"))
reference["general"] = _normalize_free_text(reference.get("general")) reference["general"] = _normalize_free_text(reference.get("general"))
@@ -1614,13 +1619,6 @@ class DumasCharacterReferenceNode:
return { return {
"required": { "required": {
"image": ("IMAGE", {"tooltip": "Character reference image."}), "image": ("IMAGE", {"tooltip": "Character reference image."}),
"picture_id": (
["1", "2", "3", "4", "5", "6", "7", "8", "9"],
{
"default": "1",
"tooltip": "Authoring picture number for this reference.",
},
),
"character_id": ( "character_id": (
"STRING", "STRING",
{ {
@@ -1729,7 +1727,6 @@ class DumasCharacterReferenceNode:
def build_reference( def build_reference(
self, self,
image, image,
picture_id,
character_id, character_id,
name, name,
alias, alias,
@@ -1750,7 +1747,6 @@ class DumasCharacterReferenceNode:
explicit_id=character_id, explicit_id=character_id,
name=name, name=name,
aliases=alias, aliases=alias,
picture_id=picture_id,
description=description, description=description,
wardrobe=wardrobe, wardrobe=wardrobe,
general=general, general=general,
@@ -1782,13 +1778,6 @@ class DumasLocationReferenceNode:
return { return {
"required": { "required": {
"image": ("IMAGE", {"tooltip": "Location or environment reference image."}), "image": ("IMAGE", {"tooltip": "Location or environment reference image."}),
"picture_id": (
["1", "2", "3", "4", "5", "6", "7", "8", "9"],
{
"default": "1",
"tooltip": "Authoring picture number for this reference.",
},
),
"location_id": ( "location_id": (
"STRING", "STRING",
{ {
@@ -1832,7 +1821,7 @@ class DumasLocationReferenceNode:
} }
} }
def build_reference(self, image, picture_id, location_id, name, alias, description, general): def build_reference(self, image, location_id, name, alias, description, general):
return ( return (
make_reference( make_reference(
kind="location", kind="location",
@@ -1840,7 +1829,6 @@ class DumasLocationReferenceNode:
explicit_id=location_id, explicit_id=location_id,
name=name, name=name,
aliases=alias, aliases=alias,
picture_id=picture_id,
description=description, description=description,
general=general, general=general,
facts={}, facts={},
+50 -29
View File
@@ -277,7 +277,14 @@ class DumasJSONStringToObjectNode:
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
"required": { "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): def INPUT_TYPES(cls):
return { return {
"required": { "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): def INPUT_TYPES(cls):
return { return {
"required": { "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 { return {
"required": { "required": {
"json_object": ("JSON",), "json_object": ("JSON",),
"pretty": ("BOOLEAN", {"default": True}), "pretty": ("BOOLEAN", {"default": True, "tooltip": "Pretty-print the JSON with indentation."}),
"sort_keys": ("BOOLEAN", {"default": False}), "sort_keys": ("BOOLEAN", {"default": False, "tooltip": "Sort object keys alphabetically before serializing."}),
} }
} }
@@ -355,7 +376,7 @@ class DumasJSONGetValueNode:
return { return {
"required": { "required": {
"json_object": ("JSON",), "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 { return {
"required": { "required": {
"json_object": ("JSON",), "json_object": ("JSON",),
"path": ("STRING", {"default": "", "multiline": False}), "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": "null"}), "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 { return {
"required": { "required": {
"json_object": ("JSON",), "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 { return {
"required": { "required": {
"json_object": ("JSON",), "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 { return {
"required": { "required": {
"json_object": ("JSON",), "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): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"base_object": ("JSON",), "base_object": ("JSON", {"tooltip": "Base JSON object to start from."}),
"overlay_object": ("JSON",), "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): def INPUT_TYPES(cls):
return { return {
"required": { "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): def INPUT_TYPES(cls):
return { return {
"required": { "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): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"json_array": ("JSON",), "json_array": ("JSON", {"tooltip": "JSON array to append to."}),
"value_json": ("STRING", {"multiline": True, "default": "null"}), "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): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"json_array": ("JSON",), "json_array": ("JSON", {"tooltip": "JSON array to slice."}),
"start": ("INT", {"default": 0, "step": 1}), "start": ("INT", {"default": 0, "step": 1, "tooltip": "Zero-based start index."}),
"end": ("INT", {"default": 0, "step": 1}), "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}), "step": ("INT", {"default": 1, "step": 1, "min": 1, "tooltip": "Slice step size."}),
} }
} }
@@ -572,9 +593,9 @@ class DumasJSONArrayIteratorNode:
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"json_input": ("JSON",), "json_input": ("JSON", {"tooltip": "JSON array to iterate over."}),
"index": ("INT", {"default": 0, "min": 0, "step": 1}), "index": ("INT", {"default": 0, "min": 0, "step": 1, "tooltip": "Current zero-based index."}),
"mode": (["fixed", "incr", "decr"], {"default": "fixed"}), "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): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"json_input": ("JSON",), "json_input": ("JSON", {"tooltip": "JSON object whose key/value pairs should be iterated in insertion order."}),
"index": ("INT", {"default": 0, "min": 0, "step": 1}), "index": ("INT", {"default": 0, "min": 0, "step": 1, "tooltip": "Current zero-based index into the object's items."}),
"mode": (["fixed", "incr", "decr"], {"default": "fixed"}), "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): def INPUT_TYPES(cls):
return { return {
"required": { "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): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"flat_json_object": ("JSON",), "flat_json_object": ("JSON", {"tooltip": "Flat JSON object whose keys are dot-paths to rebuild into nested JSON."}),
} }
} }
+375
View File
@@ -0,0 +1,375 @@
import { app } from "/scripts/app.js";
import { applyAdaptiveCanvasOnly } from "../shared/nodes2.mjs";
const COMFY_CLASS = "DumasH3LongVideos";
const STATE_PROPERTY = "dumas_h3_longvideos_section_state";
const DOM_WIDGET_NAME = "dumas_h3_longvideos_sections";
const MIN_WIDTH = 520;
const MIN_HEIGHT = 280;
const GROUPS = [
{
id: "prompt",
label: "Prompt",
defaultCollapsed: false,
widgets: ["prompt", "resolution", "megapixels", "beat_split", "anchor_override", "shot_seconds", "plan_only", "fps"],
},
{
id: "refs",
label: "Refs",
defaultCollapsed: true,
widgets: ["ref_mode", "ref_image_size", "ref_noise_aug", "character_memory", "trim_seam", "vary_seed_per_shot", "handoff_offset"],
},
{
id: "sampling",
label: "Sampling",
defaultCollapsed: true,
widgets: [
"steps", "cfg", "sampler_name", "scheduler", "seed",
"apply_model_sampling", "shift_video", "shift_audio",
"vram_headroom_gb", "allow_res_backoff",
"decode_tile_frames", "decode_tile_size",
],
},
{
id: "audio",
label: "Audio",
defaultCollapsed: true,
widgets: [
"global_soundscape", "non_diegetic_music", "auto_soundscape",
"auto_silence_nonspeech", "allow_nonspeech_vocals",
"mute_nonspeech_audio", "mute_fade_ms",
],
},
{
id: "scene",
label: "Scene Logic",
defaultCollapsed: true,
widgets: [
"auto_wardrobe", "auto_props", "prevent_nudity", "exposed_terms",
"anatomy_guard", "subject_count_guard", "lock_restraints",
"contact_guard", "motion_guard", "solidity_guard",
],
},
{
id: "finish",
label: "Upscale",
defaultCollapsed: true,
widgets: [
"upscale", "upscale_model", "upscale_target_short_edge", "upscale_batch",
],
},
{
id: "overlay",
label: "Overlays",
defaultCollapsed: true,
widgets: [
"watermark_text", "watermark_position", "watermark_size", "watermark_opacity", "watermark_margin",
"intro_text", "intro_position", "intro_seconds", "intro_fade", "intro_size",
"overlay_font", "overlay_stroke",
],
},
];
function injectCSS() {
if (document.getElementById("dumas-h3lv-sections-css")) return;
const style = document.createElement("style");
style.id = "dumas-h3lv-sections-css";
style.textContent = `
.dh3lv-sections {
box-sizing: border-box;
width: 100%;
padding: 8px 10px 6px;
color: #e6e7eb;
font: 12px/1.35 "Segoe UI", sans-serif;
pointer-events: auto;
background: linear-gradient(180deg, rgba(33, 36, 42, 0.96), rgba(22, 24, 29, 0.96));
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.dh3lv-sections-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.dh3lv-sections-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #9da5b1;
}
.dh3lv-sections-actions {
display: flex;
gap: 6px;
}
.dh3lv-sections-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.dh3lv-chip,
.dh3lv-action {
appearance: none;
border: 1px solid #464d59;
background: #262c35;
color: #d7dce3;
border-radius: 999px;
padding: 5px 9px;
cursor: pointer;
font: inherit;
line-height: 1.1;
}
.dh3lv-chip[data-open="true"] {
background: #d96f2b;
border-color: #f09358;
color: #fff7f0;
}
.dh3lv-chip:hover,
.dh3lv-action:hover {
filter: brightness(1.06);
}
.dh3lv-count {
opacity: 0.78;
margin-left: 4px;
font-size: 11px;
}
`;
document.head.appendChild(style);
}
function defaultState() {
const state = {};
for (const group of GROUPS) state[group.id] = !group.defaultCollapsed;
return state;
}
function parseState(value) {
let parsed = value;
if (typeof parsed === "string") {
try {
parsed = JSON.parse(parsed);
} catch (_error) {
parsed = null;
}
}
const base = defaultState();
if (!parsed || typeof parsed !== "object") return base;
for (const group of GROUPS) {
if (typeof parsed[group.id] === "boolean") base[group.id] = parsed[group.id];
}
return base;
}
function readState(node) {
return parseState(node.properties?.[STATE_PROPERTY] || node._dh3lvSectionState || "");
}
function writeState(node, state) {
const normalized = parseState(state);
const serialized = JSON.stringify(normalized);
node._dh3lvSectionState = serialized;
node.properties = node.properties || {};
node.properties[STATE_PROPERTY] = serialized;
}
function findWidget(node, name) {
return (node.widgets || []).find((widget) => widget?.name === name) || null;
}
function isInteractiveTarget(target) {
return !!target?.closest?.("button, input, textarea, select, label");
}
function stopCanvasEvent(event) {
if (isInteractiveTarget(event.target)) event.stopPropagation();
}
function stopCanvasKeyboard(event) {
if (isInteractiveTarget(event.target)) event.stopImmediatePropagation();
}
function setWidgetHidden(widget, hidden) {
if (!widget) return;
if (!widget._dh3lvOriginal) {
widget._dh3lvOriginal = {
type: widget.type,
computeSize: widget.computeSize,
hidden: widget.hidden,
};
}
if (hidden) {
widget.type = "hidden";
widget.hidden = true;
widget.computeSize = () => [0, -4];
return;
}
widget.type = widget._dh3lvOriginal.type;
widget.hidden = !!widget._dh3lvOriginal.hidden;
widget.computeSize = widget._dh3lvOriginal.computeSize;
}
function applyVisibility(node) {
const state = readState(node);
for (const group of GROUPS) {
for (const name of group.widgets) {
const widget = findWidget(node, name);
if (!widget || widget.name === DOM_WIDGET_NAME) continue;
setWidgetHidden(widget, !state[group.id]);
}
}
}
function resizeNode(node) {
requestAnimationFrame(() => {
const size = node.computeSize?.();
if (Array.isArray(size)) {
node.size[0] = Math.max(MIN_WIDTH, size[0] || 0, node.size?.[0] || 0);
node.size[1] = Math.max(MIN_HEIGHT, size[1] || 0);
}
node.setDirtyCanvas?.(true, true);
});
}
function renderToolbar(node) {
const ui = node._dh3lvUI;
if (!ui) return;
const state = readState(node);
ui.list.innerHTML = "";
for (const group of GROUPS) {
const button = document.createElement("button");
button.type = "button";
button.className = "dh3lv-chip";
button.dataset.open = state[group.id] ? "true" : "false";
button.textContent = state[group.id] ? `Hide ${group.label}` : `Show ${group.label}`;
const count = document.createElement("span");
count.className = "dh3lv-count";
count.textContent = String(group.widgets.filter((name) => findWidget(node, name)).length);
button.appendChild(count);
button.addEventListener("click", () => {
const next = readState(node);
next[group.id] = !next[group.id];
writeState(node, next);
applyVisibility(node);
renderToolbar(node);
resizeNode(node);
});
ui.list.appendChild(button);
}
}
function setAll(node, open) {
const next = {};
for (const group of GROUPS) next[group.id] = !!open;
writeState(node, next);
applyVisibility(node);
renderToolbar(node);
resizeNode(node);
}
function setupNode(node) {
if (node._dh3lvUI) return;
injectCSS();
writeState(node, readState(node));
const root = document.createElement("div");
root.className = "dh3lv-sections";
const head = document.createElement("div");
head.className = "dh3lv-sections-head";
const title = document.createElement("div");
title.className = "dh3lv-sections-title";
title.textContent = "Sections";
const actions = document.createElement("div");
actions.className = "dh3lv-sections-actions";
const expandAll = document.createElement("button");
expandAll.type = "button";
expandAll.className = "dh3lv-action";
expandAll.textContent = "Expand All";
expandAll.addEventListener("click", () => setAll(node, true));
const collapseAll = document.createElement("button");
collapseAll.type = "button";
collapseAll.className = "dh3lv-action";
collapseAll.textContent = "Collapse Extras";
collapseAll.addEventListener("click", () => {
const next = defaultState();
writeState(node, next);
applyVisibility(node);
renderToolbar(node);
resizeNode(node);
});
actions.append(expandAll, collapseAll);
head.append(title, actions);
const list = document.createElement("div");
list.className = "dh3lv-sections-list";
root.append(head, list);
root.addEventListener("pointerdown", stopCanvasEvent);
root.addEventListener("mousedown", stopCanvasEvent);
root.addEventListener("click", stopCanvasEvent);
root.addEventListener("dblclick", stopCanvasEvent);
root.addEventListener("keydown", stopCanvasKeyboard, true);
node._dh3lvUI = { root, list };
const widget = node.addDOMWidget(DOM_WIDGET_NAME, "custom", root, {
getValue: () => null,
setValue: () => {},
serialize: false,
getMinHeight: () => 52,
hideOnZoom: false,
});
applyAdaptiveCanvasOnly(widget);
const widgets = node.widgets || [];
const index = widgets.indexOf(widget);
if (index > 0) {
widgets.splice(index, 1);
widgets.unshift(widget);
}
applyVisibility(node);
renderToolbar(node);
resizeNode(node);
}
app.registerExtension({
name: "Dumas.H3LongVideosSections",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== COMFY_CLASS) return;
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function onNodeCreated() {
const result = originalOnNodeCreated?.apply(this, arguments);
setupNode(this);
return result;
};
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function onConfigure() {
const result = originalConfigure?.apply(this, arguments);
setupNode(this);
writeState(this, readState(this));
applyVisibility(this);
renderToolbar(this);
resizeNode(this);
return result;
};
const originalSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function onSerialize(o) {
writeState(this, readState(this));
const result = originalSerialize?.apply(this, arguments);
if (o && this.properties?.[STATE_PROPERTY]) {
o.properties = o.properties || {};
o.properties[STATE_PROPERTY] = this.properties[STATE_PROPERTY];
}
return result;
};
},
});
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "dumasnodes" name = "dumasnodes"
description = "Dumas-branded generic utility nodes for ComfyUI, starting with JSON helpers." description = "Dumas-branded generic utility nodes for ComfyUI, starting with JSON helpers."
version = "0.1.0" version = "0.1.1"
license = { file = "LICENSE" } license = { file = "LICENSE" }
[project.urls] [project.urls]
+664 -22
View File
@@ -24,6 +24,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
"PIL.Image", "PIL.Image",
"folder_paths", "folder_paths",
"dumas_image_nodes", "dumas_image_nodes",
"dumas_h3_latent_upscale",
"dumas_h3_longvideos", "dumas_h3_longvideos",
) )
} }
@@ -160,7 +161,81 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
"keyframe carry", "keyframe carry",
) )
def test_detail_pass_refines_video_but_preserves_audio(self): def test_expand_beats_auto_preserves_multiline_paragraph_as_one_beat(self):
beats, note = self.module.expand_beats(
["wardrobe: Maya = red jacket\nMaya enters the room.\nShe sits at the table."],
"auto",
)
self.assertEqual(
beats,
["wardrobe: Maya = red jacket\nMaya enters the room.\nShe sits at the table."],
)
self.assertEqual(note, "")
def test_expand_beats_legacy_blank_line_value_falls_back_to_auto(self):
beats, note = self.module.expand_beats(
["Maya enters the room.\nShe sits at the table."],
"blank line",
)
self.assertEqual(
beats,
["Maya enters the room.\nShe sits at the table."],
)
self.assertEqual(note, "")
def test_expand_beats_each_line_still_splits_multiline_paragraphs(self):
beats, note = self.module.expand_beats(
["wardrobe: Maya = red jacket\nMaya enters the room.\nseconds: 8\nShe sits at the table."],
"each line",
)
self.assertEqual(
beats,
[
"wardrobe: Maya = red jacket\nMaya enters the room.",
"seconds: 8\nShe sits at the table.",
],
)
self.assertIn("beat_split 'each line' split 1 multi-line paragraph(s) into 2 beats", note)
def test_timing_summary_reports_retry_and_bucket_totals(self):
note = self.module._format_timing_note([
{
"shot": 1,
"total": 12.4,
"retry_elapsed": 1.2,
"attempts": 2,
"sample": 8.0,
"latent_upscale_sample": 0.5,
"decode_video": 2.1,
"decode_audio": 0.4,
"cleanup": 0.2,
},
{
"shot": 2,
"total": 7.6,
"retry_elapsed": 0.0,
"attempts": 1,
"sample": 6.5,
"decode_video": 0.5,
"decode_audio": 0.3,
"cleanup": 0.1,
},
])
self.assertIn("timing: 2 shot(s) total 20.0s", note)
self.assertIn("sample 14.5s", note)
self.assertIn("decode video 2.6s", note)
self.assertIn("decode audio 0.7s", note)
self.assertIn("cleanup 0.3s", note)
self.assertIn("retry elapsed 1.2s", note)
self.assertIn("latent upscale 0.5s", note)
self.assertIn("retries 1", note)
self.assertIn("slowest shot 1 12.4s", note)
def test_latent_upscale_refines_video_but_preserves_audio(self):
class FakeTensor: class FakeTensor:
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name
@@ -189,6 +264,8 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
original_decode_video = self.module._decode_video original_decode_video = self.module._decode_video
original_decode_audio = self.module._decode_audio original_decode_audio = self.module._decode_audio
original_cleanup = self.module._deep_cleanup 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) original_nested = getattr(self.module.comfy.nested_tensor, "NestedTensor", None)
try: try:
self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor
@@ -203,6 +280,8 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
{"samples": FakeNestedTensor((FakeTensor("basev"), FakeTensor("basea")))}, {"samples": FakeNestedTensor((FakeTensor("basev"), FakeTensor("basea")))},
) )
self.module._evict_all_but = lambda *_args, **_kwargs: None 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_video = lambda _vae, out_latent, *_args, **_kwargs: out_latent
self.module._decode_audio = lambda _vae, out_latent: out_latent self.module._decode_audio = lambda _vae, out_latent: out_latent
self.module._deep_cleanup = lambda: None self.module._deep_cleanup = lambda: None
@@ -224,18 +303,25 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
tiled=False, tiled=False,
sa=(123, 20, 1.0, "res_multistep", "simple", 1.0), sa=(123, 20, 1.0, "res_multistep", "simple", 1.0),
handoff=None, handoff=None,
detail_pass=True, latent_upscale_param={
detail_sampler_name="euler", "mode": "model",
detail_scheduler="beta", "model_name": "upscale.safetensors",
detail_steps=5, "device": "cpu",
detail_denoise=0.4, "precision": "fp16",
"sampler_name": "euler_ancestral",
"scheduler": "simple",
"steps": 2,
"denoise": 0.4,
"megapixels": 1.5,
},
) )
self.assertEqual(len(calls), 2) self.assertEqual(len(calls), 2)
self.assertIsNot(calls[1][0][8], first_out) self.assertIsNot(calls[1][0][8], first_out)
self.assertIs(calls[1][0][8]["samples"], first_out["samples"]) self.assertEqual(calls[1][0][8]["samples"].unbind()[0].name, "upv")
self.assertEqual(calls[1][0][4], "euler") self.assertEqual(calls[1][0][2], 2)
self.assertEqual(calls[1][0][5], "beta") self.assertEqual(calls[1][0][4], "euler_ancestral")
self.assertEqual(calls[1][0][5], "simple")
self.assertAlmostEqual(calls[1][1]["denoise"], 0.4) self.assertAlmostEqual(calls[1][1]["denoise"], 0.4)
self.assertEqual(result[1], first_out) self.assertEqual(result[1], first_out)
self.assertEqual(result[2][0].name, "v2") self.assertEqual(result[2][0].name, "v2")
@@ -249,12 +335,148 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
self.module._decode_video = original_decode_video self.module._decode_video = original_decode_video
self.module._decode_audio = original_decode_audio self.module._decode_audio = original_decode_audio
self.module._deep_cleanup = original_cleanup 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: if original_nested is None:
delattr(self.module.comfy.nested_tensor, "NestedTensor") delattr(self.module.comfy.nested_tensor, "NestedTensor")
else: else:
self.module.comfy.nested_tensor.NestedTensor = original_nested self.module.comfy.nested_tensor.NestedTensor = original_nested
def test_detail_pass_treats_falsey_strings_as_disabled(self): def test_latent_upscale_decodes_audio_before_video_and_cleans_up(self):
class FakeTensor:
def __init__(self, name):
self.name = name
def detach(self):
return self
def to(self, *args, **kwargs):
return self
class FakeNestedTensor:
def __init__(self, parts):
self._parts = tuple(parts)
self.is_nested = True
def unbind(self):
return self._parts
order = []
first_out = {"samples": FakeNestedTensor((FakeTensor("v1"), FakeTensor("a1")))}
second_out = {"samples": FakeNestedTensor((FakeTensor("v2"), FakeTensor("a2")))}
original_common_ksampler = self.module.nodes.common_ksampler
original_build = self.module._build_shot_conditioning
original_evict = self.module._evict_all_but
original_decode_video = self.module._decode_video
original_decode_audio = self.module._decode_audio
original_cleanup = self.module._deep_cleanup
original_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("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):
order.append("audio")
self.assertIs(out_latent, first_out)
return out_latent
def decode_video(_vae, out_latent, *_args, **_kwargs):
order.append("video")
self.assertIsNot(out_latent, first_out)
self.assertIs(out_latent["samples"].unbind()[0], second_out["samples"].unbind()[0])
return out_latent
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
self.module.nodes.common_ksampler = common_ksampler
self.module._build_shot_conditioning = lambda *_args, **_kwargs: (
"cond",
{"samples": FakeNestedTensor((FakeTensor("basev"), FakeTensor("basea")))},
)
self.module._evict_all_but = lambda *_args, **_kwargs: None
self.module.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
self.module.H3LongVideos()._render(
model=object(),
clip=types.SimpleNamespace(
tokenize=lambda text, **kwargs: text,
encode_from_tokens_scheduled=lambda tokens: tokens,
),
vae=object(),
audio_vae=object(),
negative="negative",
prompt="beat",
w=128,
h=64,
ln=24,
fps=24,
tiled=False,
sa=(123, 20, 1.0, "res_multistep", "simple", 1.0),
handoff=None,
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.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")
finally:
self.module.nodes.common_ksampler = original_common_ksampler
self.module._build_shot_conditioning = original_build
self.module._evict_all_but = original_evict
self.module._decode_video = original_decode_video
self.module._decode_audio = original_decode_audio
self.module._deep_cleanup = original_cleanup
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_latent_upscale_off_skips_second_pass(self):
calls = [] calls = []
original_common_ksampler = self.module.nodes.common_ksampler original_common_ksampler = self.module.nodes.common_ksampler
original_build = self.module._build_shot_conditioning original_build = self.module._build_shot_conditioning
@@ -262,6 +484,8 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
original_decode_video = self.module._decode_video original_decode_video = self.module._decode_video
original_decode_audio = self.module._decode_audio original_decode_audio = self.module._decode_audio
original_cleanup = self.module._deep_cleanup original_cleanup = self.module._deep_cleanup
original_upscale = self.module._upscale_latent_video
original_copy_sample = self.module._copy_sample_latent
try: try:
self.module.nodes.common_ksampler = lambda *args, **kwargs: (calls.append((args, kwargs)) or {"samples": "latent"},) self.module.nodes.common_ksampler = lambda *args, **kwargs: (calls.append((args, kwargs)) or {"samples": "latent"},)
self.module._build_shot_conditioning = lambda *_args, **_kwargs: ("cond", {"samples": "base"}) self.module._build_shot_conditioning = lambda *_args, **_kwargs: ("cond", {"samples": "base"})
@@ -269,6 +493,8 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
self.module._decode_video = lambda _vae, out_latent, *_args, **_kwargs: out_latent self.module._decode_video = lambda _vae, out_latent, *_args, **_kwargs: out_latent
self.module._decode_audio = lambda _vae, out_latent: out_latent self.module._decode_audio = lambda _vae, out_latent: out_latent
self.module._deep_cleanup = lambda: None 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( self.module.H3LongVideos()._render(
model=object(), model=object(),
@@ -284,7 +510,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
tiled=False, tiled=False,
sa=(123, 20, 1.0, "res_multistep", "simple", 1.0), sa=(123, 20, 1.0, "res_multistep", "simple", 1.0),
handoff=None, handoff=None,
detail_pass="false", latent_upscale_param={"mode": "off"},
) )
self.assertEqual(len(calls), 1) self.assertEqual(len(calls), 1)
@@ -295,6 +521,8 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
self.module._decode_video = original_decode_video self.module._decode_video = original_decode_video
self.module._decode_audio = original_decode_audio self.module._decode_audio = original_decode_audio
self.module._deep_cleanup = original_cleanup 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): def test_distribute_generations_canonicalizes_per_shot_audio_and_anchor_directives(self):
generations = self.module.distribute_generations( generations = self.module.distribute_generations(
@@ -407,6 +635,91 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
) )
self.assertEqual(dropped, []) self.assertEqual(dropped, [])
def test_resolve_prompt_refs_where_tagged_mode_stays_tag_only(self):
refs = [
{"kind": "character", "image": "img1", "name": "Mara"},
{"kind": "location", "image": "img2", "name": "Hangar"},
]
text, references, dropped = self.module.resolve_prompt_refs(
"Mara waits in the hangar near <Picture 2>.",
refs,
include_named=False,
)
self.assertEqual(text, "Mara waits in the hangar near <Picture 1>.")
self.assertEqual([self.module._reference_image(ref) for ref in references], ["img2"])
self.assertEqual(dropped, [])
def test_resolve_tag_driven_prompt_refs_keeps_untagged_shot_on_handoff(self):
refs = [
{"kind": "character", "image": "img1", "name": "Mara"},
{"kind": "location", "image": "img2", "name": "Hangar"},
]
text, references, dropped = self.module.resolve_tag_driven_prompt_refs(
"Mara waits in the hangar.",
refs,
)
self.assertEqual(text, "Mara waits in the hangar.")
self.assertEqual(references, [])
self.assertEqual(dropped, [])
def test_resolve_tag_driven_prompt_refs_keeps_named_refs_on_tagged_shot(self):
refs = [
{"kind": "character", "image": "img1", "name": "Mara"},
{"kind": "location", "image": "img2", "name": "Hangar"},
]
text, references, dropped = self.module.resolve_tag_driven_prompt_refs(
"Mara waits in the hangar near <Picture 2>.",
refs,
)
self.assertEqual(text, "Mara waits in the hangar near <Picture 1>.")
self.assertEqual(
[self.module._reference_image(ref) for ref in references],
["img2", "img1"],
)
self.assertEqual(dropped, [])
def test_resolve_shot_references_uses_named_characters_without_picture_tags(self):
refs = [
{"kind": "character", "image": "img1", "name": "Mara"},
{"kind": "character", "image": "img2", "name": "Jon"},
{"kind": "location", "image": "img3", "name": "Hangar"},
]
text, references, dropped, shot_tag_driven, mode_eff = self.module.resolve_shot_references(
"[Generation 1] Mara crosses the hangar.",
refs,
"auto ref2v",
0,
None,
)
self.assertEqual(text, "[Generation 1] Mara crosses the hangar.")
self.assertEqual([self.module._reference_image(ref) for ref in references], ["img1"])
self.assertEqual(dropped, [])
self.assertFalse(shot_tag_driven)
self.assertEqual(mode_eff, "auto ref2v")
def test_resolve_prompt_refs_prioritizes_characters_before_locations(self):
refs = [
{"kind": "location", "image": "img1", "name": "Hangar"},
{"kind": "character", "image": "img2", "name": "Mara"},
]
text, references, dropped = self.module.resolve_prompt_refs(
"[Generation 1] Mara waits in the hangar.",
refs,
)
self.assertEqual(text, "[Generation 1] Mara waits in the hangar.")
self.assertEqual([self.module._reference_image(ref) for ref in references], ["img2", "img1"])
self.assertEqual(dropped, [])
def test_shot_references_uses_all_connected_sparse_slots(self): def test_shot_references_uses_all_connected_sparse_slots(self):
refs = [ refs = [
None, None,
@@ -426,17 +739,81 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
["img2", "img4", "img7"], ["img2", "img4", "img7"],
) )
def test_annotate_script_refs_handles_named_characters_and_locations(self):
refs = [
{"kind": "character", "image": "img1", "name": "Mara"},
{"kind": "location", "image": "img2", "name": "Hangar"},
]
report = self.module.annotate_script_refs(
["Mara waits in the Hangar.", "Nobody else is here."],
refs,
)
self.assertIn("# shot 1 refs: Picture 1 Mara (by name); Picture 2 Hangar (by name)", report)
self.assertIn("# shot 2 refs: none", report)
def test_annotate_script_debug_groups_each_prompt_with_its_beat_info(self):
refs = [
{"kind": "character", "image": "img1", "name": "Mara"},
{"kind": "location", "image": "img2", "name": "Hangar"},
]
report = self.module.annotate_script_debug(
["[Generation 1] Mara waits in the Hangar.", "[Generation 2] Nobody else is here."],
[1],
"auto",
refs,
)
self.assertIn("Prompt 1\n[Generation 1] Mara waits in the Hangar.", report)
self.assertIn("Beat 1 info\nAnatomy guard: injected into this prompt", report)
self.assertIn("References used: <Picture 1> Mara (matched by name); <Picture 2> Hangar (matched by name)", report)
self.assertIn("Prompt 2\n[Generation 2] Nobody else is here.", report)
self.assertIn("Beat 2 info\nAnatomy guard: not injected for this prompt\nReferences used: none", report)
def test_input_types_expose_nine_ref_slots(self): def test_input_types_expose_nine_ref_slots(self):
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"] optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
for index in range(1, 10): for index in range(1, 10):
self.assertIn(f"ref_{index}", optional) self.assertIn(f"ref_{index}", optional)
def test_input_types_keep_legacy_ref_image_aliases(self): names = list(optional)
ref_positions = [names.index(f"ref_{index}") for index in range(1, 10)]
self.assertEqual(ref_positions, list(range(ref_positions[0], ref_positions[0] + 9)))
def test_input_types_do_not_expose_legacy_ref_image_aliases(self):
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"] optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
for index in range(1, 10): for index in range(1, 10):
self.assertIn(f"ref_image_{index}", optional) 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"]
tooltip = optional["shot_seconds"][1]["tooltip"]
self.assertIn("GLOBAL per-shot maximum", tooltip)
self.assertIn("A beat's own `seconds:` directive can still ask for less", 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
try:
self.module.estimate_shot_frames = lambda *_args, **_kwargs: 73
frames, note = self.module.resolve_shot_frames(10.0, 24, 16.0, 8.0, 1.5)
self.assertEqual(frames, 243)
self.assertIn("honoring it", note)
finally:
self.module.estimate_shot_frames = original_estimate_shot_frames
def test_run_defaults_match_declared_ref_widget_defaults(self): def test_run_defaults_match_declared_ref_widget_defaults(self):
node = self.module.H3LongVideos() node = self.module.H3LongVideos()
@@ -497,7 +874,54 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context) self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context)
self.assertIn("Character notes for Mara: wears a long grey coat.", context) self.assertIn("Character notes for Mara: wears a long grey coat.", context)
def test_run_uses_legacy_ref_image_inputs_when_new_slots_are_empty(self): def test_reference_context_matches_location_names_without_picture_tag(self):
refs = [
{"kind": "location", "image": "img2", "name": "Hangar", "aliases": ["loading bay"], "description": "wet concrete floor", "general": "cold industrial lighting"},
]
context = self.module._reference_context_for_text(
"[Generation 1] They argue in the hangar near the loading bay.",
refs,
)
self.assertIn("Location context for Hangar: wet concrete floor.", context)
self.assertIn("Location notes for Hangar: cold industrial lighting.", context)
def test_reference_context_uses_resolved_picture_numbers_for_per_beat_refs(self):
refs = [
{"kind": "character", "image": "img1", "name": "Bill", "description": "very tall", "facts": {"age": "25"}},
{"kind": "location", "image": "img2", "name": "Pub", "description": "warm wood bar"},
]
rewritten, resolved_refs, dropped = self.module.resolve_prompt_refs(
"[Generation 1] Bill leans on <Picture 2>.",
refs,
)
context = self.module._reference_context_for_text(
rewritten,
refs,
resolved_refs=resolved_refs,
)
self.assertEqual(dropped, [])
self.assertIn("Character facts for <Picture 2> Bill: 25 years old.", context)
self.assertIn("Persistent appearance for <Picture 2> Bill: very tall.", context)
self.assertIn("Location context for <Picture 1> Pub: warm wood bar.", context)
def test_reference_context_skips_ambiguous_name_matches(self):
refs = [
{"kind": "character", "image": "img1", "name": "Alex", "description": "short dark hair"},
{"kind": "character", "image": "img2", "name": "Alex", "description": "tall blond hair"},
]
context = self.module._reference_context_for_text(
"[Generation 1] Alex enters the room.",
refs,
)
self.assertEqual(context, "")
def test_run_uses_reference_slots_directly(self):
calls = {} calls = {}
original_parse_resolution = self.module.parse_resolution original_parse_resolution = self.module.parse_resolution
original_connected_refs = self.module._connected_refs original_connected_refs = self.module._connected_refs
@@ -561,14 +985,25 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
scheduler="simple", scheduler="simple",
seed=1, seed=1,
plan_only=True, plan_only=True,
ref_image_1={"image": "legacy-1"}, ref_1={"image": "live-1"},
ref_image_3={"image": "legacy-3"}, 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"], "legacy-1") self.assertEqual(calls["refs"][0]["image"], "live-1")
self.assertIsNone(calls["refs"][1]) self.assertIsNone(calls["refs"][1])
self.assertEqual(calls["refs"][2]["image"], "legacy-3") self.assertEqual(calls["refs"][2]["image"], "live-3")
self.assertEqual(result[2].count("ref2va: 2 reference image(s)"), 1) 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: finally:
self.module.parse_resolution = original_parse_resolution self.module.parse_resolution = original_parse_resolution
self.module._connected_refs = original_connected_refs self.module._connected_refs = original_connected_refs
@@ -608,6 +1043,51 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
self.assertIn("Persistent appearance for Mara: silver hair.", context) self.assertIn("Persistent appearance for Mara: silver hair.", context)
self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context) self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context)
def test_reference_context_can_skip_character_wardrobe_when_live_memory_is_explicit(self):
refs = [
{"kind": "character", "image": "img1", "name": "Mara", "description": "silver hair", "wardrobe": "red jacket"},
]
context = self.module._reference_context_for_text(
"[Generation 1] Mara walks into the room.",
refs,
include_character_wardrobe=False,
)
self.assertIn("Persistent appearance for Mara: silver hair.", context)
self.assertNotIn("Persistent wardrobe/style for Mara: red jacket.", context)
def test_resolve_prompt_refs_adds_named_location_refs(self):
refs = [
{"kind": "location", "image": "img2", "name": "Hangar", "description": "wet concrete floor"},
]
rewritten, matched, dropped = self.module.resolve_prompt_refs(
"[Generation 1] They wait in the hangar.",
refs,
)
self.assertEqual(rewritten, "[Generation 1] They wait in the hangar.")
self.assertEqual(dropped, [])
self.assertEqual(len(matched), 1)
self.assertEqual(matched[0]["name"], "Hangar")
def test_resolve_tagged_refs_drops_reference_without_image(self):
refs = [
{"kind": "character", "image": None, "name": "Mara"},
{"kind": "character", "image": "img2", "name": "Jon"},
]
rewritten, matched, dropped = self.module.resolve_tagged_refs(
"[Generation 1] <Picture 1> faces <Picture 2>.",
refs,
)
self.assertEqual(rewritten, "[Generation 1] faces <Picture 1>.")
self.assertEqual(dropped, [1])
self.assertEqual(len(matched), 1)
self.assertEqual(matched[0]["name"], "Jon")
def test_reference_context_injects_immediately_after_generation_label(self): def test_reference_context_injects_immediately_after_generation_label(self):
block = ( block = (
"[Generation 1] Classic sitcom lighting and staging. " "[Generation 1] Classic sitcom lighting and staging. "
@@ -651,6 +1131,168 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
{"DumasH3LongVideos": "Dumas H3 Long Videos (FL2VA + REF2VA)"}, {"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"], 17)
self.assertEqual(required["temporal_overlap"][1]["default"], 0)
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_cap_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,
})
self.assertEqual(chunk_length, 17)
self.assertEqual(temporal_overlap, 0)
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_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): def test_compose_persistent_does_not_expand_ambiguous_plural_to_full_cast(self):
active = self.module.parse_wardrobe( active = self.module.parse_wardrobe(
"Maya = she, red jacket\n" "Maya = she, red jacket\n"
@@ -755,7 +1397,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
module.quant_accel_note = lambda *_args, **_kwargs: "" module.quant_accel_note = lambda *_args, **_kwargs: ""
module.lora_active = lambda _model: False module.lora_active = lambda _model: False
module.resolve_shot_frames = lambda *_args, **_kwargs: (73, "") module.resolve_shot_frames = lambda *_args, **_kwargs: (73, "")
module.plan_beat_frames = lambda beats, fps, budget, per_beat=True: ([73] * len(beats), []) module.plan_beat_frames = lambda beats, fps, budget: ([73] * len(beats), [])
module.dialogue_fit_warnings = lambda *_args, **_kwargs: [] module.dialogue_fit_warnings = lambda *_args, **_kwargs: []
module.dialogue_filler_warnings = lambda *_args, **_kwargs: [] module.dialogue_filler_warnings = lambda *_args, **_kwargs: []
module.distribute_generations = lambda anchor, beats, *_args, **_kwargs: [ module.distribute_generations = lambda anchor, beats, *_args, **_kwargs: [
@@ -782,9 +1424,9 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
plan_only=True, plan_only=True,
) )
self.assertIn("# anatomy_guard: injected on shot(s) 2", result[3]) self.assertIn("Prompt 1", result[3])
self.assertIn("# shot 1 refs: none", result[3]) self.assertIn("Beat 1 info\nAnatomy guard: not injected for this prompt\nReferences used: none", result[3])
self.assertIn("# shot 2 refs: none", result[3]) self.assertIn("Beat 2 info\nAnatomy guard: injected into this prompt\nReferences used: none", result[3])
self.assertIn( self.assertIn(
"[Generation 1] editorial room, soft practical lighting. Francine stands alone.", "[Generation 1] editorial room, soft practical lighting. Francine stands alone.",
result[3], result[3],
+52 -12
View File
@@ -245,7 +245,6 @@ class DumasImageNodeTests(unittest.TestCase):
result = node.build_reference( result = node.build_reference(
image=image, image=image,
picture_id="2",
character_id="char_dave", character_id="char_dave",
name="Dave", name="Dave",
alias="The Locksmith", alias="The Locksmith",
@@ -270,10 +269,10 @@ class DumasImageNodeTests(unittest.TestCase):
"id": "char-dave", "id": "char-dave",
"name": "Dave", "name": "Dave",
"aliases": ["The Locksmith"], "aliases": ["The Locksmith"],
"picture_id": 2, "picture_id": None,
"picture_label": "<Picture 2>", "picture_label": "",
"image": image, "image": image,
"summary": "Dave shown in <Picture 2>.", "summary": "Dave reference.",
"description": "Square jaw, tired eyes, cropped brown hair.", "description": "Square jaw, tired eyes, cropped brown hair.",
"wardrobe": "weathered red flight jacket, grey cargo shorts, black boots", "wardrobe": "weathered red flight jacket, grey cargo shorts, black boots",
"general": "wears a long grey coat", "general": "wears a long grey coat",
@@ -289,13 +288,16 @@ class DumasImageNodeTests(unittest.TestCase):
}, },
) )
def test_character_reference_input_types_do_not_expose_picture_id(self):
required = self.image_nodes.DumasCharacterReferenceNode.INPUT_TYPES()["required"]
self.assertNotIn("picture_id", required)
def test_character_reference_handles_missing_optional_fields(self): def test_character_reference_handles_missing_optional_fields(self):
node = self.image_nodes.DumasCharacterReferenceNode() node = self.image_nodes.DumasCharacterReferenceNode()
image = FakeTensorBatch() image = FakeTensorBatch()
result = node.build_reference( result = node.build_reference(
image=image, image=image,
picture_id="4",
character_id="", character_id="",
name="", name="",
alias="", alias="",
@@ -313,8 +315,8 @@ class DumasImageNodeTests(unittest.TestCase):
reference = result[0] reference = result[0]
self.assertEqual(reference["kind"], "character") self.assertEqual(reference["kind"], "character")
self.assertEqual(reference["picture_id"], 4) self.assertIsNone(reference["picture_id"])
self.assertEqual(reference["picture_label"], "<Picture 4>") self.assertEqual(reference["picture_label"], "")
self.assertEqual(reference["wardrobe"], "") self.assertEqual(reference["wardrobe"], "")
self.assertEqual(reference["general"], "") self.assertEqual(reference["general"], "")
self.assertEqual(reference["facts"]["age"], "") self.assertEqual(reference["facts"]["age"], "")
@@ -325,7 +327,6 @@ class DumasImageNodeTests(unittest.TestCase):
result = node.build_reference( result = node.build_reference(
image=image, image=image,
picture_id="9",
location_id="coffee-shop-01", location_id="coffee-shop-01",
name="Coffee Shop", name="Coffee Shop",
alias="Cafe Interior", alias="Cafe Interior",
@@ -340,10 +341,10 @@ class DumasImageNodeTests(unittest.TestCase):
"id": "coffee-shop-01", "id": "coffee-shop-01",
"name": "Coffee Shop", "name": "Coffee Shop",
"aliases": ["Cafe Interior"], "aliases": ["Cafe Interior"],
"picture_id": 9, "picture_id": None,
"picture_label": "<Picture 9>", "picture_label": "",
"image": image, "image": image,
"summary": "Coffee Shop shown in <Picture 9>.", "summary": "Coffee Shop reference.",
"description": "Warm tungsten lighting, narrow counter, rainy front window.", "description": "Warm tungsten lighting, narrow counter, rainy front window.",
"wardrobe": "", "wardrobe": "",
"general": "Evening ambience, cramped but cozy.", "general": "Evening ambience, cramped but cozy.",
@@ -351,6 +352,45 @@ class DumasImageNodeTests(unittest.TestCase):
}, },
) )
def test_location_reference_input_types_do_not_expose_picture_id(self):
required = self.image_nodes.DumasLocationReferenceNode.INPUT_TYPES()["required"]
self.assertNotIn("picture_id", required)
def test_normalize_reference_upgrades_generic_summary_with_socket_picture_id(self):
image = FakeTensorBatch()
reference = self.image_nodes.normalize_reference(
{
"kind": "character",
"name": "Dave",
"image": image,
"summary": "Dave reference.",
},
picture_id=3,
allow_image_fallback=False,
)
self.assertEqual(reference["picture_id"], 3)
self.assertEqual(reference["picture_label"], "<Picture 3>")
self.assertEqual(reference["summary"], "Dave shown in <Picture 3>.")
def test_normalize_reference_keeps_custom_summary_when_socket_picture_id_is_added(self):
image = FakeTensorBatch()
reference = self.image_nodes.normalize_reference(
{
"kind": "character",
"name": "Dave",
"image": image,
"summary": "Primary hero look for the opening close-up.",
},
picture_id=3,
allow_image_fallback=False,
)
self.assertEqual(reference["picture_id"], 3)
self.assertEqual(reference["summary"], "Primary hero look for the opening close-up.")
def test_anchor_style_node_exposes_requested_presets(self): def test_anchor_style_node_exposes_requested_presets(self):
input_types = self.image_nodes.DumasAnchorStyleNode.INPUT_TYPES() input_types = self.image_nodes.DumasAnchorStyleNode.INPUT_TYPES()
options = input_types["required"]["anchor_style"][0] options = input_types["required"]["anchor_style"][0]
@@ -375,7 +415,7 @@ class DumasImageNodeTests(unittest.TestCase):
self.assertIn("found-footage", result[0]) self.assertIn("found-footage", result[0])
self.assertIn("real time", result[0]) self.assertIn("real time", result[0])
self.assertIn("persistent camera language", result[0]) self.assertNotIn("persistent camera language", result[0])
def test_anchor_style_node_prefers_manual_description_edits(self): def test_anchor_style_node_prefers_manual_description_edits(self):
node = self.image_nodes.DumasAnchorStyleNode() node = self.image_nodes.DumasAnchorStyleNode()