Vendor Dumas H3 long video nodes
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user