Files

304 lines
18 KiB
Python

"""The MiniMax H3 · Reference to Video pipeline, as a loadable preset.
Not the app's one true default -- `modules/system/pipeline` already owns that,
and a second module silently claiming `default_pipeline` would make which one
actually starts depend on load order. This is offered as a PRESET instead: a
slot list a person picks from the pipeline window, same shape as the default,
loaded into the very same editable graph (`core/graph.py`'s wire/replace/remove
apply to it exactly as they do to anything else).
The chain mirrors what this app's own H3 user actually runs by hand today:
loaders, MiniMaxH3SigmaShift patching the model's flow shifts, an
FunUIAspectSize node turning a dropped-in picture into canvas-multiple
width/height ints -- but the picture supplies only its ASPECT RATIO, not the
resolution. The scale is the project's width and height, role-mounted at
"project.video" exactly like the default pipeline's own width/height: a
project setting, typed once, not implied by whatever a phone camera happened
to produce. Those two resulting ints feed
MiniMaxH3ReferenceToVideo's width/height, and Reference-to-Video's own
conditioning+latent feeds a plain sampler at CFG 1 (H3 wants no negative
guidance) into FunUIDecode and a video/audio mux.
The image and each reference are FunUILoadMedia nodes: fed by a `media_id`
this app sets from what is picked in the Media bin, at "assets.*" roles that
mount no on-screen field of their own (nothing here `offer()`s an `assets.*`
region) -- the same "a role naming a place nobody offers is simply not shown"
rule as everywhere else, used here on purpose so boot.js can still find these
slots BY ROLE (never by this preset's own slot ids) without the app rendering
a raw text box asking someone to type a media id by hand.
"""
#: How many references (of any kind) this preset has a slot for: R2V takes up to 9 pictures, 3 videos
#: (each with its soundtrack) and 3 sounds. More slots only add optional ones, and a saved project gets
#: the new ones when it opens.
MAX_REFERENCES = 15
def h3_reference_to_video():
slots = [
{"id": "model", "group": "Loaders", "node": "FunUIDiffusionModelLoader", "inputs": {
"weight_dtype": "default", "compute_dtype": "default", "attention": "default"}},
{"id": "clip", "group": "Loaders", "node": "FunUICLIPLoader",
"inputs": {"type": "minimax"}},
{"id": "vae", "group": "Loaders", "node": "FunUIVAELoader",
"inputs": {"dtype": "bfloat16"}},
{"id": "audio_vae", "group": "Loaders", "node": "FunUIVAELoader",
"inputs": {"dtype": "bfloat16"}},
# Always present, does nothing until a LoRA is picked; chain more through Models & Pipeline.
{"id": "lora", "group": "Loaders", "node": "FunUILoraLoader",
"inputs": {"model": ["model", 0], "lora_name": "None", "strength_model": 1.0}},
{"id": "shift", "group": "Sampling", "node": "MiniMaxH3SigmaShift",
"roles": [{"at": "generation.model", "input": "shift_video", "label": "Video shift"},
{"at": "generation.model", "input": "shift_audio", "label": "Audio shift"}],
"inputs": {"model": ["lora", 0], "shift_video": 12.0, "shift_audio": 3.0}},
{"id": "source_image", "group": "Reference media", "node": "FunUILoadMedia",
# Unhosted: see the module docstring. boot.js finds this slot by this
# role's name and sets `media_id` from the scene's own picked image,
# the same way "generation.prompt" is found by name and set from the
# scene's own text -- neither is addressed by this preset's slot id.
# `blank_like`: with no picture (text-only), buildInputs gives this node the size of that
# slot's target_width/height, so a plain grey stand-in carries the project's own shape.
"roles": [{"at": "assets.source_image", "input": "media_id",
"blank_like": {"slot": "aspect", "width": "width", "height": "height"}}],
"inputs": {"media_id": "", "blank_width": 0, "blank_height": 0}},
# Width and height are the SCALE, set in Project settings like any other project resolution -- not the
# dropped picture's own size (a 12-megapixel phone photo must not become the generation's resolution). The
# picture supplies the SHAPE; `basis` (Project settings > Resolution is the) says what the scale means: the
# pixel count, the short side or the long side.
{"id": "aspect", "group": "Reference media", "node": "FunUIAspectSize",
"roles": [{"at": "project.video", "input": "width", "label": "Width", "was": "target_width"},
{"at": "project.video", "input": "height", "label": "Height", "was": "target_height"},
{"at": "project.resolution_basis", "input": "basis"}],
"inputs": {"image": ["source_image", 0], "width": 1344, "height": 768, "multiple_of": 32, "basis": "short"}},
# Off by default: the prompt passes straight through and the model never
# runs. Before the markup node because a rewrite changes the words the
# markup points at -- (word:1.5) typed with this on is sent to the model
# as text, and the node says so.
{"id": "enhance", "group": "Preparation", "node": "FunUIEnhancePrompt",
"roles": [{"at": "generation.prompt", "input": "text", "label": "Prompt"},
{"at": "generation.prompt", "input": "enabled", "label": "Enhance prompt"}],
# The picture it is shown is the scene's first picture reference, linked per run by the app.
"inputs": {"clip": ["clip", 0], "text": "", "enabled": False}},
# Off by default (its own switches in Settings): writes cut times, views and camera moves into [Shot N] blocks.
{"id": "shot_camera", "group": "Preparation", "node": "FunUIShotCamera",
"roles": [{"at": "project.video", "input": "length", "label": "Length", "drives": "frames"}],
"inputs": {"text": ["enhance", 0], "settings": ["settings", 0], "length": 124, "frame_rate": 24.0}},
{"id": "markup", "group": "Preparation", "node": "FunUIPromptMarkup",
"inputs": {"text": ["shot_camera", 0], "settings": ["settings", 0]}},
{"id": "r2v", "group": "Preparation", "node": "MiniMaxH3ReferenceToVideo",
"roles": [{"at": "project.video", "input": "length", "label": "Length", "drives": "frames"}],
"inputs": {
"clip": ["clip", 0], "vae": ["vae", 0], "audio_vae": ["audio_vae", 0],
"prompt": ["markup", 0], "width": ["aspect", 0], "height": ["aspect", 1],
"length": 124, "ref_image_size": "match"}},
{"id": "negative", "group": "Preparation", "node": "CLIPTextEncode",
# H3 samples at CFG 1, where a negative prompt does nothing -- wired
# only because FunUISampler requires SOME conditioning here. The project's Negative
# prompt lands here so Shadow negative and Negative erase have words to work from.
"roles": [{"at": "project.negative", "input": "text", "label": "Negative prompt"}],
"inputs": {"clip": ["clip", 0], "text": ""}},
{"id": "settings", "group": "Preparation", "node": "FunUIModifierSettings", "inputs": {"settings": "{}"}},
{"id": "modifiers", "group": "Preparation", "node": "FunUILoadModifiers", "inputs": {
"model": ["shift", 0], "settings": ["settings", 0]}},
{"id": "markup_apply", "group": "Preparation", "node": "FunUIApplyPromptMarkup",
"inputs": {"model": ["modifiers", 0], "clip": ["clip", 0], "positive": ["r2v", 0],
"latent": ["r2v", 1], "markup": ["markup", 1]}},
# Strength 0 = untouched. Above it, the negative prompt (dead at CFG 1) is taken out of the words.
{"id": "erase", "group": "Preparation", "node": "FunUINegativeErase",
"inputs": {"positive": ["markup_apply", 1], "negative": ["negative", 0], "strength": 0.0,
"mode": "project", "keep_size": True}},
{"id": "sampler", "group": "Sampling", "node": "FunUISampler",
"roles": [{"at": "generation.sampling", "input": "steps", "label": "Steps"},
{"at": "generation.sampling", "input": "sampler_name", "label": "Sampler"},
{"at": "generation.sampling", "input": "scheduler", "label": "Scheduler"},
{"at": "generation.seed", "input": "seed", "label": "Seed"}],
"inputs": {
"model": ["markup_apply", 0], "positive": ["erase", 0],
"negative": ["negative", 0], "latent": ["r2v", 1], "settings": ["settings", 0],
"seed": 0, "steps": 20, "cfg": 1.0,
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0}},
{"id": "decode", "group": "Render", "node": "FunUIDecode", "inputs": {
"samples": ["sampler", 0], "vae": ["vae", 0], "model": ["shift", 0],
"audio_vae": ["audio_vae", 0]}},
{"id": "cut", "group": "Render", "node": "FunUICutOpening",
"roles": [{"at": "project.video", "input": "frames", "label": "Cut opening frames"}],
"inputs": {"images": ["decode", 0], "audio": ["decode", 1], "frames": 0, "fps": 24.0}},
{"id": "save", "group": "Render", "node": "FunUISaveVideo", "inputs": {
"images": ["cut", 0], "audio": ["cut", 1], "fps": 24.0, "filename_prefix": "FunUI", "settings": ["settings", 0]}},
]
for n in range(1, MAX_REFERENCES + 1):
slots.append({
"id": f"ref_source_{n}", "group": "Reference media", "node": "FunUILoadMedia",
# `wireTo` is not read by core or by graph.py -- it is this
# preset's own data, read by boot.js at queue time (see
# queueInputs()'s handling of "assets.reference_N" there) to know
# where a reference's IMAGE output should land WITHOUT boot.js
# having to know "r2v" or "ref_images" itself. Not wired here in
# the STATIC slot list -- see the module docstring above: an
# unwired optional autogrow slot is what "this reference is not
# in use" actually looks like to MiniMaxH3ReferenceToVideo,
# absent from its `ref_images` dict entirely, not an empty image
# degrading its output. Only a scene that actually has this many
# references gets this wire added, per-run.
"roles": [{"at": f"assets.reference_{n}", "input": "media_id",
"wireTo": {"slot": "r2v", "input": f"ref_images.ref_image_{n - 1}"}}],
"inputs": {"media_id": ""}})
return slots
#: Project settings the Fast & Simple preset goes with (the render steps after a clip finishes): Frame Generation on every
#: render, Balanced, 2 times; Super Resolution on demand (a button beside the rating), Denoise · Medium.
FAST_SIMPLE_PREFS = {
"interpolateMode": "always", "interpolateOpts": {"mode": "MEDIUM", "multiplier": 2},
"upscaleMode": "button", "upscaleEngine": "rtx", "upscaleRtx": {"quality": "DENOISE_MEDIUM"},
}
def _sol_vae() -> str:
"""The Sol-H3 VAE when it is installed; else "None" (the model's own VAE), so the preset is not refused for a missing file."""
try:
import folder_paths
return "sol_h3_vae.safetensors" if "sol_h3_vae.safetensors" in folder_paths.get_filename_list("vae") else "None"
except Exception: # noqa: BLE001 -- no ComfyUI folders to ask: the model's own VAE, never a file that may be missing
return "None"
def h3_fast_simple_r2v():
"""Reference to Video set up for speed with few settings to think about: MXFP8 weights, block-sparse attention
that ramps from 0.7 to 0.9, a DMAD LoRA slot, five steps on the Turbo sampler with the first three on a smaller
picture (Two stages) and a polish step, Attention explorer and Shot camera's camera moves on. Every other
module is left off. The module values travel in the settings slot, where Engine settings reads them.
"""
import json
settings = {
"attention_bandit": {"enabled": True},
"conditioning_shot_camera": {"camera_moves": True},
"two_stage": {"enabled": True, "size": 0.7, "steps": 3},
"turbo_sampler": {"enabled": True, "polish": 0.3},
}
inputs = {
"model": {"weight_dtype": "default", "compute_dtype": "default", "attention": "default", "mxfp8": True,
"sla": True, "sla_method": "sla", "sla_engine": "comfy_kitchen", "sla_sparsity": 0.9,
"sla_sparsity_start": 0.7, "sla_dense_steps": "0", "sla_dense_last_steps": 0},
"vae": {"decode_vae_name": _sol_vae()},
"lora": {"strength_model": 0.5, "strength_clip": 0.0},
"sampler": {"steps": 5, "sampler_name": "funui_turbo", "scheduler": "ddim_uniform"},
"settings": {"settings": json.dumps(settings)},
}
slots = h3_reference_to_video()
for slot in slots:
slot["inputs"] = {**slot["inputs"], **inputs.get(slot["id"], {})}
return slots
def h3_reference_to_video_second_pass():
"""The same pipeline, then a second pass: resample between passes (sharpen
by default) and a second sampler running a typed schedule as written.
The typed schedule's FIRST value is the strength (0.4 polishes, 0.8 reworks
the shot). The one here is a neutral starting point, not a validated one --
v4's good second passes came from a schedule the user typed in.
"""
slots = h3_reference_to_video()
for slot in slots:
if slot["id"] == "decode":
slot["inputs"]["samples"] = ["pass2", 0]
at = next(i for i, s in enumerate(slots) if s["id"] == "decode")
slots[at:at] = [
{"id": "upscaler", "group": "Second pass", "node": "FunUILatentUpscalerLoader",
"inputs": {}},
{"id": "resample", "group": "Second pass", "node": "FunUILatentResample",
"inputs": {"latent": ["sampler", 0], "upscaler": ["upscaler", 0],
"operation": "sharpen", "scale": 2.0,
"positive": ["erase", 0], "negative": ["negative", 0]}},
{"id": "pass2_sigmas", "group": "Second pass", "node": "ManualSigmas",
"inputs": {"sigmas": "0.4, 0.3, 0.2, 0.1, 0.0"}},
{"id": "pass2", "group": "Second pass", "node": "FunUISampler",
"roles": [{"at": "generation.seed", "input": "seed", "label": "Seed"}],
"inputs": {
"model": ["markup_apply", 0], "positive": ["resample", 1],
"negative": ["resample", 2], "latent": ["resample", 0],
"settings": ["settings", 0], "sigmas": ["pass2_sigmas", 0],
"seed": 0, "steps": 4, "cfg": 1.0,
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0}},
]
return slots
#: Nodes this pipeline can run without, and what to pass through when their module did not load:
#: {node class: {"pass": {output index: input name}}}. Asked for by core (graph.bypass_missing), so saved pipelines get it too.
OPTIONAL_NODES = {
"FunUIEnhancePrompt": {"pass": {"0": "text"}},
"FunUIShotCamera": {"pass": {"0": "text"}},
"FunUIPromptMarkup": {"pass": {"0": "text"}},
"FunUIApplyPromptMarkup": {"pass": {"0": "model", "1": "positive"}},
"FunUINegativeErase": {"pass": {"0": "positive"}},
"FunUICutOpening": {"pass": {"0": "images", "1": "audio"}},
}
def _free_enhancer_picture(slots):
"""A pipeline saved while the prompt enhancer read the aspect source's picture: that link is cut, so the app can hand the
enhancer the scene's first picture reference instead (a value cannot be put in an input another node feeds)."""
aspect = {x.get("id") for x in slots if any(r.get("at") == "assets.source_image" for r in x.get("roles") or [])}
cut = lambda x: x.get("node") == "FunUIEnhancePrompt" and isinstance((x.get("inputs") or {}).get("image"), (list, tuple)) and x["inputs"]["image"][0] in aspect
if not any(cut(x) for x in slots):
return slots
return [dict(x, inputs={k: v for k, v in x["inputs"].items() if k != "image"}) if cut(x) else x for x in slots]
def upgrade(slots, _outputs=None):
"""A pipeline saved while the size came from ImageTransformKJ (total_pixels, the pixel count of the project's width x height)
gets FunUIAspectSize in its place: the same size (`basis` is "area" in it), and the project's Short side / Long side choice
can reach it. The project's saved width and height keep working (the new roles say what they were called)."""
slots = _free_enhancer_picture(slots)
old = next((x for x in slots if x.get("id") == "image_transform" and x.get("node") == "ImageTransformKJ"), None)
if not old or (old.get("inputs") or {}).get("keep_proportion") != "total_pixels":
return slots
feeds = lambda v: isinstance(v, (list, tuple)) and len(v) == 2 and v[0] == "image_transform"
users = [(x, k) for x in slots if x is not old for k, v in (x.get("inputs") or {}).items() if feeds(v)]
if not users or any(not (v[1] in (4, 5)) for x, k in users for v in [x["inputs"][k]]):
return slots # something else reads the resized picture: not a pipeline made from the preset
fresh = next(x for x in h3_reference_to_video() if x["id"] == "aspect")
size = {k: (old["inputs"].get(f"target_{k}") if isinstance(old["inputs"].get(f"target_{k}"), int) else fresh["inputs"][k]) for k in ("width", "height")}
aspect = {**fresh, "inputs": {**fresh["inputs"], **size, "image": old["inputs"].get("image", fresh["inputs"]["image"]), "basis": "area"}}
out = []
for x in slots:
if x is old:
x = aspect
else:
inputs = {k: (["aspect", v[1] - 4] if feeds(v) else v) for k, v in (x.get("inputs") or {}).items()}
roles = [dict(r, blank_like=dict(r["blank_like"], slot="aspect", width="width", height="height")) if (r.get("blank_like") or {}).get("slot") == "image_transform" else r
for r in x.get("roles") or []]
x = {**x, "inputs": inputs, **({"roles": roles} if "roles" in x else {})}
out.append(x)
return out
def optional_nodes():
return OPTIONAL_NODES
def presets():
return [{"id": "minimax_h3_reference_to_video",
"title": "MiniMax H3 · Reference to Video",
"slots": h3_reference_to_video()},
{"id": "minimax_h3_fast_simple_r2v",
"title": "MiniMax H3 · Fast & Simple R2V",
"slots": h3_fast_simple_r2v(), "prefs": FAST_SIMPLE_PREFS}]
# Held back, not offered: h3_reference_to_video_second_pass() builds clean but was never verified on a GPU
# (it needs a compatible H3 latent upscaler file). Add it back here once it has been.
PROVIDES = {"pipeline_presets": presets}