Vendor Dumas H3 long video nodes
This commit is contained in:
@@ -33,6 +33,22 @@
|
|||||||
- Outputs: `plan`, `image1`..`image9`, `connected_images`
|
- 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`.
|
- 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`
|
- `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`
|
- 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`
|
- Outputs: `image1`, `image2`, `character_text`
|
||||||
|
|||||||
+18
@@ -10,14 +10,32 @@ from .dumas_json_nodes import (
|
|||||||
NODE_CLASS_MAPPINGS as JSON_NODE_CLASS_MAPPINGS,
|
NODE_CLASS_MAPPINGS as JSON_NODE_CLASS_MAPPINGS,
|
||||||
NODE_DISPLAY_NAME_MAPPINGS as JSON_NODE_DISPLAY_NAME_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 = {}
|
||||||
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_SHOT_LENGTH_NODE_CLASS_MAPPINGS)
|
||||||
|
NODE_CLASS_MAPPINGS.update(H3_INSPECTOR_NODE_CLASS_MAPPINGS)
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
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_SHOT_LENGTH_NODE_DISPLAY_NAME_MAPPINGS)
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS.update(H3_INSPECTOR_NODE_DISPLAY_NAME_MAPPINGS)
|
||||||
|
|
||||||
WEB_DIRECTORY = "./js"
|
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