Vendor Dumas H3 long video nodes

This commit is contained in:
2026-08-25 19:15:36 +00:00
parent 30c7128810
commit d9de0243fc
6 changed files with 6977 additions and 0 deletions
+16
View File
@@ -33,6 +33,22 @@
- Outputs: `plan`, `image1`..`image9`, `connected_images`
- Reads back the nine optional images for a selected MiniMax H3 plan scene, for example by connecting the current `clip_index`.
- `Dumas H3 Long Videos (FL2VA + REF2VA)`
- Inputs: H3 model stack, prompt socket, optional `first_frame`, optional `ref_image_1`..`ref_image_4`, plus the upstream long-video control surface for pacing, continuity, audio, overlays, and guards
- Outputs: `images`, `audio`, `info`, `script`, `frames_per_shot`, `total_frames`, `shots`, `video_seconds`, `fps`, `fps_int`, `latent`, `soundscape`
- First-pass Dumas port of the `MiniMax-H3-Longvideos` sampler, brought in as a local starting point for long-form H3 chaining work.
- Keeps the upstream split-beats / handoff / ref-routing behavior close to source so future Dumas-specific improvements can be compared against a known baseline.
- `Dumas H3 Shot Length`
- Inputs: `shot_seconds`, `fps`, optional `cap_to_h3_max`
- Outputs: `seconds`, `frames`, `info`
- Emits one H3-safe shot length as both seconds and a grid-aligned frame count for wiring into the long-video sampler and preview helpers.
- `Dumas H3 Model Inspector`
- Input: `model`
- Outputs: `format`, `report`
- Reports the detected H3 base precision / quant format and the relevant compute-capability hints for the current card.
- `Dumas Character Helper`
- Inputs: `image1`, `image2`, `image1_picture_id`, `image2_picture_id`, `character_id`, `name`, `alias`, `gender`, `age`, `nationality`, `occupation`, `height_feet`, `height_inches`, `accent`, `general`
- Outputs: `image1`, `image2`, `character_text`
+18
View File
@@ -10,14 +10,32 @@ from .dumas_json_nodes import (
NODE_CLASS_MAPPINGS as JSON_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as JSON_NODE_DISPLAY_NAME_MAPPINGS,
)
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_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,
)
from .dumas_h3_inspector import (
NODE_CLASS_MAPPINGS as H3_INSPECTOR_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as H3_INSPECTOR_NODE_DISPLAY_NAME_MAPPINGS,
)
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_SHOT_LENGTH_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(H3_INSPECTOR_NODE_CLASS_MAPPINGS)
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_SHOT_LENGTH_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(H3_INSPECTOR_NODE_DISPLAY_NAME_MAPPINGS)
WEB_DIRECTORY = "./js"
+177
View File
@@ -0,0 +1,177 @@
"""
H3 Model Inspector (detect the base precision / quant format)
==============================================================
Reads the loaded MODEL and reports which precision/quant format the H3 DiT is
stored in: BF16, FP8 (e4m3 / e5m2), INT8 (+ convrot), NVFP4, MXFP8,
ConvRot-W4A4, W4A8, or a mix. Report-only — a manual hint you read and act on.
WHY IT'S FUTURE-PROOF (incl. MXFP8 "once one comes out")
--------------------------------------------------------
It doesn't sniff dtypes and guess. ComfyUI tags every quantized layer at load
with module.quant_format, using fixed strings it already recognizes:
nvfp4, mxfp8, float8_e4m3fn, float8_e5m2, int8_tensorwise, convrot_w4a4,
asym_w4a8_int8 (see comfy/ops.py).
This node reads that tag. MXFP8 is already a recognized format in ComfyUI
(comfy/ops.py + comfy/float.py + model_management.supports_mxfp8_compute), so
the day someone ships an MXFP8 H3 checkpoint, this node labels it correctly
with no change. Any brand-new tag lands under "other: <tag>" instead of
crashing, so it degrades gracefully.
It also reports whether YOUR card can run NVFP4 / MXFP8 natively
(model_management.supports_nvfp4_compute / supports_mxfp8_compute).
NOT detected: pruned vs full (that's an architecture axis — factorized AdaLN —
not a quant format). Reported as a caveat, not guessed.
INSTALL: drop into ComfyUI/custom_nodes/, restart.
Node: MiniMax-H3 -> Model Inspector.
"""
# ---- pure helpers (no torch; unit-testable) -------------------------------
_FRIENDLY = {
"float8_e4m3fn": "FP8 (e4m3)",
"float8_e5m2": "FP8 (e5m2)",
"mxfp8": "MXFP8",
"nvfp4": "NVFP4",
"int8_tensorwise": "INT8",
"int8_tensorwise+convrot": "INT8 convrot",
"convrot_w4a4": "ConvRot W4A4 (int4)",
"asym_w4a8_int8": "W4A8 (int4/int8)",
"bf16": "BF16",
"fp16": "FP16",
}
# implication note per format, tied to a Blackwell 16GB context
_IMPLICATION = {
"NVFP4": "native on Blackwell (sm_120); half the size of INT8.",
"MXFP8": "needs Blackwell + torch >= 2.10 for native compute.",
"FP8 (e4m3)": "fp8 storage; runs on Ada/Blackwell.",
"FP8 (e5m2)": "fp8 storage; runs on Ada/Blackwell.",
"INT8": "int8 storage.",
"INT8 convrot": "int8+ConvRot — needs working sm_120 kernels (absent on some 50-series setups).",
"ConvRot W4A4 (int4)": "4-bit ConvRot; requires the matching custom nodes/branch.",
"W4A8 (int4/int8)": "4-bit weight / 8-bit activation.",
"BF16": "full precision; largest footprint, cleanest LoRA apply.",
"FP16": "half precision.",
}
def friendly(fmt):
return _FRIENDLY.get(fmt, f"other: {fmt}")
def summarize(counts):
"""counts: {raw_format: n}. Returns (label, per_format_summary_lines).
Label = the dominant NON-bf16/fp16 quant format if any (the main blocks),
else the dominant plain dtype."""
quant = {k: v for k, v in counts.items() if k not in ("bf16", "fp16")}
lines = []
for raw, n in sorted(counts.items(), key=lambda kv: -kv[1]):
lines.append(f" {friendly(raw)}: {n} layer(s)")
if quant:
top = max(quant.items(), key=lambda kv: kv[1])[0]
label = friendly(top)
elif counts:
top = max(counts.items(), key=lambda kv: kv[1])[0]
label = friendly(top)
else:
label = "unknown"
return label, lines
# ---- ComfyUI node ---------------------------------------------------------
def _detect(model):
"""Walk the DiT modules, tally quant_format tags (and dtype for the rest).
Returns (label, counts, report_lines). Imports torch/mm lazily so the pure
helpers above stay importable without a ComfyUI runtime."""
import torch
import comfy.model_management as mm
# locate the diffusion model inside the ModelPatcher
dm = getattr(getattr(model, "model", None), "diffusion_model", None)
if dm is None:
dm = getattr(model, "model", None) or model
def dtype_label(dt):
return {
torch.bfloat16: "bf16", torch.float16: "fp16",
torch.float8_e4m3fn: "float8_e4m3fn", torch.float8_e5m2: "float8_e5m2",
torch.int8: "int8_tensorwise",
}.get(dt, str(dt).replace("torch.", ""))
counts = {}
if hasattr(dm, "modules"):
for m in dm.modules():
fmt = getattr(m, "quant_format", None)
if fmt is not None:
# distinguish int8 convrot via the packed weight's params
if fmt == "int8_tensorwise":
params = getattr(getattr(m, "weight", None), "_params", None)
if getattr(params, "convrot", False):
fmt = "int8_tensorwise+convrot"
counts[fmt] = counts.get(fmt, 0) + 1
continue
w = getattr(m, "weight", None)
if w is not None and hasattr(w, "dtype"):
counts[dtype_label(w.dtype)] = counts.get(dtype_label(w.dtype), 0) + 1
label, lines = summarize(counts)
# hardware capability for the relevant 4-bit/8-bit formats
try:
nv = mm.supports_nvfp4_compute()
except Exception:
nv = None
try:
mx = mm.supports_mxfp8_compute()
except Exception:
mx = None
report = [f"Detected base precision: {label}"]
report += lines
impl = _IMPLICATION.get(label)
if impl:
report.append(f" -> {impl}")
report.append(f" card supports NVFP4 compute: {nv}; MXFP8 compute: {mx}")
if label == "MXFP8" and mx is False:
report.append(" WARNING: MXFP8 file but this card/torch can't run it natively.")
if label == "NVFP4" and nv is False:
report.append(" WARNING: NVFP4 file but this card can't run it natively.")
report.append(" (pruned-vs-full is a separate architecture axis; not detected here.)")
return label, counts, "\n".join(report)
class H3ModelInspector:
CATEGORY = "Dumas/MiniMax"
FUNCTION = "inspect"
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("format", "report")
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
return {"required": {"model": ("MODEL",)}}
def inspect(self, model):
label, _counts, report = _detect(model)
return (label, report)
NODE_CLASS_MAPPINGS = {"DumasH3ModelInspector": H3ModelInspector}
NODE_DISPLAY_NAME_MAPPINGS = {"DumasH3ModelInspector": "Dumas H3 Model Inspector"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
if __name__ == "__main__":
# exercise the pure aggregation logic with mocked layer tallies
cases = {
"NVFP4 file (200 main + bf16 rest)": {"nvfp4": 200, "bf16": 132},
"INT8 convrot": {"int8_tensorwise+convrot": 170, "bf16": 30},
"plain bf16": {"bf16": 340},
"FP8": {"float8_e4m3fn": 200, "bf16": 140},
"MXFP8 (future file)": {"mxfp8": 200, "bf16": 132},
"some unknown new tag": {"fp6_e3m2": 200, "bf16": 132},
}
for name, counts in cases.items():
label, lines = summarize(counts)
print(f"{name:38s} -> {label}")
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
"""
PIL text overlays for H3 Long Videos -- watermark and intro title.
Text is COMPOSITED onto the decoded frames, never asked of the model. H3 (like
every video diffusion model) renders text as plausible-looking letterforms that
drift, warp and re-spell themselves frame to frame; a watermark that changes
shape every frame is worse than none. Compositing gives pixel-identical text on
every frame at zero sampling cost, and keeps the words out of the prompt where
they would otherwise steal conditioning from the actual shot.
Both overlays are WHITE text drawn on a fully transparent RGBA layer, then
alpha-blended over the video -- so only the glyphs themselves land on the frame
and the picture shows through everywhere else.
Everything here is best-effort: any failure returns the frames untouched with a
note, because a cosmetic overlay must never lose a finished render.
"""
import torch
BLEND_CHUNK = 64 # frames blended per slice -- bounds peak RAM on long chains
# Auto-fit: text is wrapped, then shrunk in FIT_SHRINK steps until the block fits
# inside the margins. MIN_FONT_PX is the point below which the text would be
# unreadable anyway, so the loop stops there and lets PIL clip rather than spin.
MIN_FONT_PX = 8
FIT_SHRINK = 0.92
FIT_STEPS = 48
# Fonts to try when the requested one cannot be loaded. PIL resolves bare names
# against the system font directory, so "arial.ttf" works on Windows as-is.
FONT_FALLBACKS = ("arial.ttf", "segoeui.ttf", "DejaVuSans.ttf", "LiberationSans-Regular.ttf")
# Anchor -> (x, y) as a fraction of the free space: 0 = hard against the left/top
# margin, 1 = hard against the right/bottom, 0.5 = centered.
POSITIONS = {
"bottom-right": (1.0, 1.0),
"bottom-left": (0.0, 1.0),
"bottom-center": (0.5, 1.0),
"top-right": (1.0, 0.0),
"top-left": (0.0, 0.0),
"top-center": (0.5, 0.0),
"center": (0.5, 0.5),
"lower-third": (0.5, 0.72),
}
def _load_font(name, px):
"""A truetype font at px, falling back through the known-present faces and
finally to PIL's bitmap default (which ignores size -- ugly, but never fatal)."""
from PIL import ImageFont
px = max(8, int(px))
for cand in ([name] if name else []) + list(FONT_FALLBACKS):
try:
return ImageFont.truetype(cand, px)
except Exception:
continue
return ImageFont.load_default()
def _measure(draw, text, font, stroke_px, spacing):
"""(x0, y0, x1, y1) of a multi-line block, tolerant of older Pillow builds."""
try:
return draw.multiline_textbbox((0, 0), text, font=font, align="center",
stroke_width=stroke_px, spacing=spacing)
except TypeError: # older Pillow: no stroke/spacing kwargs
return draw.multiline_textbbox((0, 0), text, font=font, align="center")
def _wrap(draw, text, font, max_w, stroke_px, spacing):
"""Greedy word-wrap every hard line to max_w. A single word wider than the
frame cannot be broken -- the shrink loop in render_text_layer handles that."""
out = []
for hard in text.split("\n"):
words = hard.split()
if not words:
out.append("")
continue
cur = words[0]
for wd in words[1:]:
trial = cur + " " + wd
b = _measure(draw, trial, font, stroke_px, spacing)
if b[2] - b[0] <= max_w:
cur = trial
else:
out.append(cur)
cur = wd
out.append(cur)
return "\n".join(out)
def _fit(draw, text, font_name, px, max_w, max_h, stroke_px, line_spacing, wrap=True):
"""Largest size at or below px whose wrapped block fits (max_w, max_h).
Without this, a title is drawn at the requested size and whatever runs past the
frame is simply CLIPPED by PIL -- silently, with no error and no note. That is
the whole "overlays don't work at other resolutions" failure: the size is a
percentage, so the same text that fits 1344x768 overflows a 512-wide portrait
canvas and loses its outer characters."""
px = max(MIN_FONT_PX, int(px))
for _ in range(FIT_STEPS):
font = _load_font(font_name, px)
spacing = int(max(0.0, px * (line_spacing - 1.0)))
fitted = _wrap(draw, text, font, max_w, stroke_px, spacing) if wrap else text
box = _measure(draw, fitted, font, stroke_px, spacing)
if (box[2] - box[0] <= max_w and box[3] - box[1] <= max_h) or px <= MIN_FONT_PX:
return font, fitted, box, spacing, px
px = max(MIN_FONT_PX, int(px * FIT_SHRINK))
return font, fitted, box, spacing, px
def render_text_layer(width, height, text, font_px, position="bottom-right",
margin_pct=3.0, font_name="", stroke_px=0, line_spacing=1.15,
wrap=True):
"""White text on a transparent RGBA canvas the size of one frame.
The block is WRAPPED and SHRUNK until it fits inside the margins, so the same
settings render legibly on every supported preset -- portrait canvases and the
512 tier included -- instead of being clipped at the frame edge.
Returns (rgb, alpha, bbox): rgb [H,W,3] float 0..1, alpha [H,W,1] float 0..1
(zero everywhere except the glyphs and their optional stroke), and the tight
(x0, y0, x1, y1) box of non-transparent pixels so the blend only has to touch
the region the text actually occupies. None when there is nothing to draw."""
from PIL import Image, ImageDraw
import numpy as np
text = (text or "").strip()
if not text:
return None
img = Image.new("RGBA", (int(width), int(height)), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
stroke_px = max(0, int(stroke_px))
margin = int(min(width, height) * max(0.0, margin_pct) / 100.0)
ax, ay = POSITIONS.get(position, POSITIONS["bottom-right"])
# Measure first, so the block is placed by its real size rather than a guess --
# and fit it to the space the margins actually leave.
max_w = max(1, int(width) - 2 * margin)
max_h = max(1, int(height) - 2 * margin)
font, text, box, spacing, font_px = _fit(draw, text, font_name, font_px, max_w, max_h,
stroke_px, line_spacing, wrap)
tw, th = box[2] - box[0], box[3] - box[1]
free_w = max(0, int(width) - 2 * margin - tw)
free_h = max(0, int(height) - 2 * margin - th)
x = margin + free_w * ax - box[0]
y = margin + free_h * ay - box[1]
kwargs = dict(font=font, fill=(255, 255, 255, 255), align="center")
if stroke_px:
kwargs.update(stroke_width=stroke_px, stroke_fill=(0, 0, 0, 255))
try:
draw.multiline_text((x, y), text, spacing=spacing, **kwargs)
except TypeError:
draw.multiline_text((x, y), text, **kwargs)
arr = np.asarray(img, dtype=np.float32) / 255.0 # [H, W, 4]
alpha = arr[..., 3:4]
if not alpha.any():
return None
# Tight bbox of drawn pixels: blending a whole 1344x768 frame for a corner
# watermark would cost ~50x more work on a 3000-frame chain.
ys, xs = np.nonzero(alpha[..., 0] > 0.0)
bbox = (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)
return (torch.from_numpy(arr[..., :3].copy()),
torch.from_numpy(alpha.copy()),
bbox)
def blend_layer(frames, layer, frame_alpha=None, opacity=1.0):
"""Alpha-composite a rendered layer over frames [N,H,W,3] in 0..1, in place.
frame_alpha is an optional per-frame multiplier (length N) -- that is what
makes an intro title hold and then fade instead of sitting on the whole
video. Frames whose multiplier is 0 are skipped entirely."""
if layer is None:
return frames
rgb, alpha, (x0, y0, x1, y1) = layer
n = frames.shape[0]
if frame_alpha is None:
frame_alpha = torch.ones(n, dtype=torch.float32)
frame_alpha = frame_alpha.to(torch.float32).clamp(0.0, 1.0) * float(opacity)
a_crop = alpha[y0:y1, x0:x1, :].to(frames.dtype)
c_crop = rgb[y0:y1, x0:x1, :].to(frames.dtype)
live = (frame_alpha > 0).nonzero().flatten().tolist()
for s in range(0, len(live), BLEND_CHUNK):
idx = live[s:s + BLEND_CHUNK]
fa = frame_alpha[idx].to(frames.dtype).view(-1, 1, 1, 1)
sub = frames[idx, y0:y1, x0:x1, :]
a = a_crop * fa
frames[idx, y0:y1, x0:x1, :] = sub * (1.0 - a) + c_crop * a
return frames
def hold_fade_alpha(total_frames, hold_frames, fade_frames):
"""Per-frame opacity for an intro: full through hold_frames, then a linear
ramp to zero over fade_frames, then nothing. Returns a length-N tensor."""
a = torch.zeros(int(total_frames), dtype=torch.float32)
hold = max(0, min(int(hold_frames), int(total_frames)))
a[:hold] = 1.0
fade = max(0, min(int(fade_frames), int(total_frames) - hold))
if fade:
a[hold:hold + fade] = torch.linspace(1.0, 0.0, fade + 2)[1:-1]
return a
def apply_overlays(frames, fps, watermark="", wm_position="bottom-right", wm_size_pct=4.0,
wm_opacity=0.75, wm_margin_pct=3.0, intro="", intro_seconds=3.0,
intro_fade=0.6, intro_size_pct=9.0, intro_position="center",
font_name="", stroke_px=0):
"""Composite the watermark (every frame) and the intro title (first seconds
only, then faded out). Returns (frames, note). Never raises -- a cosmetic
overlay must not be able to destroy a finished render."""
notes = []
if frames is None or frames.ndim != 4 or frames.shape[0] == 0:
return frames, ""
n, h, w = frames.shape[0], frames.shape[1], frames.shape[2]
frames = frames.contiguous()
# Size from the SHORT edge, not the height. Height is the long edge on every
# portrait preset, so a height-based percentage drew 9:16 text ~1.75x larger
# than the same setting at 16:9 -- on the canvas with the LEAST room for it.
# The short edge makes one setting mean the same apparent size at every ratio.
short = min(int(w), int(h))
if (watermark or "").strip():
try:
layer = render_text_layer(w, h, watermark, short * max(0.5, wm_size_pct) / 100.0,
wm_position, wm_margin_pct, font_name, stroke_px)
if layer is not None:
blend_layer(frames, layer, None, wm_opacity)
notes.append(f"watermark composited ({wm_position}, {wm_opacity:.0%})")
except Exception as e:
notes.append(f"watermark skipped ({type(e).__name__}: {e})")
if (intro or "").strip():
try:
layer = render_text_layer(w, h, intro, short * max(0.5, intro_size_pct) / 100.0,
intro_position, 6.0, font_name, stroke_px)
if layer is not None:
hold = round(max(0.0, float(intro_seconds)) * fps)
fade = round(max(0.0, float(intro_fade)) * fps)
fa = hold_fade_alpha(n, hold, fade)
if fa.max() > 0:
blend_layer(frames, layer, fa, 1.0)
notes.append(f"intro title composited ({hold}f hold + {fade}f fade)")
else:
notes.append("intro title skipped (no hold or fade frames)")
except Exception as e:
notes.append(f"intro title skipped ({type(e).__name__}: {e})")
return frames, "; ".join(notes)
+66
View File
@@ -0,0 +1,66 @@
"""
H3 Shot Length (single model-free source for shot length)
==========================================================
Holds ONE shot-length value and emits it as both seconds and a grid-aligned
H3 frame count. Because it never reads the model, it can sit upstream of
Kijai's Model Preview Override without creating a cycle -- unlike the preview
node or the sampler, which read the model to compute their frame counts and so
cannot feed anything that produces the model.
Wire:
H3 Shot Length (seconds) -> H3 Long Videos FL2VA (shot_seconds)
H3 Shot Length (frames) -> Model Preview Override (preview_frames)
One value, entered once here, drives both -- no manual re-entry, no cycle.
Note: this is a FIXED shot length you choose. The sampler's *auto* (VRAM-
picked) length can't be used for preview_frames, because computing it requires
reading the model, which is the very dependency that creates the loop. Set the
sampler's shot_seconds from this node's `seconds` output so the two agree.
"""
H3_MAX_FRAMES = 362
def align_up_grid(n):
n = max(5, int(n))
while n % 17 != 5:
n += 1
return n
class H3ShotLength:
CATEGORY = "Dumas/MiniMax"
FUNCTION = "emit"
RETURN_TYPES = ("FLOAT", "INT", "STRING")
RETURN_NAMES = ("seconds", "frames", "info")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"shot_seconds": ("FLOAT", {"default": 5.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}),
},
"optional": {
"cap_to_h3_max": ("BOOLEAN", {"default": True,
"tooltip": "Clamp frames to 362 (~15s), H3's single-clip maximum."}),
},
}
def emit(self, shot_seconds, fps, cap_to_h3_max=True):
fps = max(1, int(fps))
frames = align_up_grid(round(float(shot_seconds) * fps))
capped = cap_to_h3_max and frames > H3_MAX_FRAMES
if capped:
frames = H3_MAX_FRAMES
info = (f"{shot_seconds:g}s/shot @ {fps}fps -> {frames} frames"
f"{' (capped 362)' if capped else ''}")
return (round(float(shot_seconds), 3), frames, info)
NODE_CLASS_MAPPINGS = {"DumasH3ShotLength": H3ShotLength}
NODE_DISPLAY_NAME_MAPPINGS = {"DumasH3ShotLength": "Dumas H3 Shot Length"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]