simpleshot: the pack — one node, MiniMax-H3 first/last frame to video with sound

One node per capability and no mode dropdown, because a mode widget spanning two
sampling paths is a mistake this line has already paid for. Shared plumbing sits
at module scope so a second node cannot drift from this one.

Built on ComfyUI core alone — no second pack to install, which is the whole
premise. Core's own fl2va node supplies the conditioning and keyframes; the
sampling and the two decodes mirror core's SamplerCustomAdvanced, VAEDecode and
vae_decode_audio, and say so where they do, so they can be diffed against the
source rather than trusted.

What it refuses to offer: sampler, scheduler, cfg, sigma shift, negative prompt.
What it guards: the checkpoint family (fl2va vs ref2va, read out of the workflow
because nothing in the MODEL object carries the filename), the canvas — core does
NOT round the output canvas on this path, so the menu is the only guard — and
both frames' fitting, which core does asymmetrically and silently.

66 offline checks in tests/test_offline.py, no pytest and no GPU needed. They
caught the canvas claim above, which an earlier draft had backwards.
This commit is contained in:
manzakin
2026-08-27 12:05:35 +02:00
commit 7bc4298cb1
10 changed files with 944 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
manzakin
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 manzakin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+87
View File
@@ -0,0 +1,87 @@
# SimpleShot
ComfyUI nodes for workflows that run the first time.
One pack to install. No second custom pack, no dependency chain, no missing
node named in an error you have to go and look up. Each node here wraps a whole
generation path into a single block with only the settings that change your
result — so the workflow around it is a handful of loaders and a save node, and
there is almost nothing to wire wrongly.
## What's in it
| Node | What it does |
|---|---|
| **SimpleShot H3 - First/Last Frame to Video + Audio** | MiniMax-H3 video **with generated sound**, from a prompt and optionally a first frame, a last frame, or both. |
More nodes will follow, one per capability. They are added, never changed: a
workflow you downloaded today keeps working after an update.
## Install
**The easy way — ComfyUI Manager**
1. Open ComfyUI, click **Manager**, then **Custom Nodes Manager**.
2. Search for **SimpleShot**.
3. Click **Install**, then **Restart ComfyUI**.
That's it. The workflow's model files are downloaded by ComfyUI itself the first
time you open the workflow — it asks, and it puts each file in the right folder.
**Or by hand, from a ZIP**
1. On this page, click the green **Code** button, then **Download ZIP**.
2. Unzip it.
3. Move the unzipped folder into `ComfyUI/custom_nodes/`, and rename it to
`ComfyUI-SimpleShot` (a ZIP download adds `-main` to the name).
4. Restart ComfyUI.
The finished path should look like
`ComfyUI/custom_nodes/ComfyUI-SimpleShot/__init__.py`. If you unzipped one
folder too deep, ComfyUI will not see the nodes.
**Or with git**, if that is your habit:
```
cd ComfyUI/custom_nodes
git clone https://github.com/manzakin/ComfyUI-SimpleShot
```
Nothing to `pip install` — the pack has no dependencies of its own.
## Requirements
- A recent ComfyUI. MiniMax-H3 support has to be built in; if it is not, the
node says so in a sentence and tells you to update.
- The H3 model files, about 42 GB in total, which ComfyUI can fetch for you.
- Enough GPU for a 21 GB checkpoint. This is the honest limit: the software side
is frictionless, the hardware side cannot be. The node takes its model as a
link, so you can point it at a smaller community version instead.
## What the nodes deliberately do not offer
No sampler, no scheduler, no cfg, no sigma shift, no negative prompt. Those are
settings whose realistic effect here is to break a working render, so they are
fixed at values that are known to work. What is exposed is what changes the
picture: the prompt, your images, how long, how big, and how many steps.
Durations and canvas sizes are menus rather than numbers for the same reason.
Both have hard constraints in this model — a frame count off its grid and a
canvas off a multiple of 32 fail deep inside the sampler with a message that
names nothing you can act on. The menu only offers values that work.
## When something goes wrong
Every node returns an **info** output. Wire it into a **Preview Any** node and
it tells you what actually ran: frame count, canvas, which checkpoint was
loaded, and how each of your images was fitted. Most surprises are answered
there — for example, a first frame is *stretched* to the canvas while a last
frame is *centre-cropped*, which is invisible until you know it.
If a node refuses to run, the message is meant to be a complete instruction. If
you get one that is not, please open an issue with it — a message that leaves
you stuck is a bug in this pack.
## Licence
MIT. Runs inside ComfyUI, which is GPL-3.0.
+11
View File
@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
"""SimpleShot — ComfyUI workflows that run the first time.
One pack to install, no second dependency: every node here is built on ComfyUI
core alone. Install this, open the workflow, let ComfyUI fetch the models, press
Run.
"""
from .simpleshot import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
WEB_DIRECTORY = None
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+15
View File
@@ -0,0 +1,15 @@
[project]
name = "comfyui-simpleshot"
description = "MiniMax-H3 video with sound in one node: prompt, an optional first and last frame, and nothing that can be set wrongly. Core-only, no second pack to install."
version = "1.0.0"
license = { text = "MIT" }
requires-python = ">=3.10"
dependencies = []
[project.urls]
Repository = "https://github.com/manzakin/ComfyUI-SimpleShot"
[tool.comfy]
PublisherId = "manzakin"
DisplayName = "SimpleShot"
Icon = ""
+19
View File
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""SimpleShot nodes. One node per capability, never a mode dropdown.
Registration is collected here so adding a node means adding one import and
one merge, and so the pack's public surface is visible in twenty lines.
"""
from .h3_first_last import (
NODE_CLASS_MAPPINGS as _fl_classes,
NODE_DISPLAY_NAME_MAPPINGS as _fl_names,
)
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
for _classes, _names in ((_fl_classes, _fl_names),):
NODE_CLASS_MAPPINGS.update(_classes)
NODE_DISPLAY_NAME_MAPPINGS.update(_names)
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+418
View File
@@ -0,0 +1,418 @@
# -*- coding: utf-8 -*-
"""Shared plumbing for every SimpleShot node.
Written once and used by all of them, so a second node cannot quietly drift
from the first: the duration and canvas tables, the family guard, the sampling
call and the two decodes all live here. Nodes above this file choose WHAT to
offer; this file decides HOW it runs.
Everything here talks to ComfyUI core only — no second custom pack, by design.
The functions that mirror a core node's body say which node they mirror, so the
next person can diff them against the source rather than trust a comment.
"""
# ── the two tables the user actually chooses from ────────────────────────────
#
# THE TWO ARE NOT ALIKE, and an early version of this file had it wrong.
#
# Frame count: core DOES correct it. `temporal_shape` runs `align_frame_count`
# on the way into the latent, so any length is snapped up to the 17k+5 grid.
# The entries here are already on the grid, so nothing is corrected and nothing
# needs to be. The menu is convenience.
#
# Canvas: core does NOT correct it. `adapt_canvas` exists in the same module and
# looks like it would, but it is only ever called for reference VIDEOS in the
# ref2va node — the first/last-frame path passes width and height straight into
# `_empty_av_latent`, which divides them by 16. So an off-multiple canvas is a
# broken tensor, not a corrected one, and THIS MENU IS THE GUARD. Every entry
# is a multiple of 32 and stays inside the 768*1344 area the model works in.
# Read the source before adding one; tests/test_offline.py enforces both rules.
DURATIONS = {
"2 s — quick test (56 frames)": 56,
"5 s (124 frames)": 124,
"10 s (243 frames)": 243,
"15 s (362 frames)": 362,
}
DURATION_NAMES = list(DURATIONS)
DURATION_DEFAULT = "5 s (124 frames)"
RESOLUTIONS = {
"portrait 768 x 1344": (768, 1344),
"landscape 1344 x 768": (1344, 768),
"portrait 736 x 1280 - cheaper": (736, 1280),
"landscape 1280 x 736 - cheaper": (1280, 736),
"square 960 x 960": (960, 960),
}
RESOLUTION_NAMES = list(RESOLUTIONS)
RESOLUTION_DEFAULT = "portrait 768 x 1344" # core's own default, and the one
# every render behind this pack used
# ── the sampling pair, deliberately not exposed ──────────────────────────────
#
# This is the pairing ComfyUI's own MiniMax-H3 template ships (res_multistep +
# simple, cfg-free guidance, no sigma shift), which is the closest thing to an
# author-shipped config for a core-only fl2va graph. It is a constant and not a
# widget on purpose: exposing a sampler only lets a working thing be broken, and
# this line sells "it works the first time". One edit here changes every node.
SAMPLER_NAME = "res_multistep"
SCHEDULER = "simple"
STEPS_DEFAULT = 20
FPS = 24 # H3 renders at 24 fps. Not configurable — see the spec.
# ── core, imported late and with a legible failure ───────────────────────────
def _fail(msg):
"""One exception type, one voice. Errors are ours, not a stack trace."""
raise RuntimeError("[SimpleShot] " + msg)
def core_h3():
"""core's MiniMax-H3 module, or a sentence saying what to do about it."""
try:
import comfy_extras.nodes_minimax_h3 as h3
except Exception:
_fail("this ComfyUI has no MiniMax-H3 support built in, so the model "
"cannot run at all. Update ComfyUI to a version that ships "
"comfy_extras/nodes_minimax_h3.py (Manager > Update ComfyUI, "
"then restart). Nothing else needs installing.")
if not hasattr(h3, "MiniMaxH3ImageToVideo"):
_fail("this ComfyUI ships MiniMax-H3 support, but not the "
"first/last-frame node this workflow is built on. Update "
"ComfyUI and restart.")
return h3
def unwrap(out):
"""The values out of a core node's return, old API or new.
Core's newer nodes return a NodeOutput (indexable, with `.result`); the
older ones return a plain tuple. Both are handled so this pack does not
break on the version boundary.
"""
result = getattr(out, "result", None)
if result is not None:
return tuple(result)
if isinstance(out, (tuple, list)):
return tuple(out)
return (out,)
# ── what the model will actually do with the numbers ─────────────────────────
def align_frame_count(n):
"""Mirror of core's `align_frame_count`: snap UP to the 17k+5 grid."""
n = max(5, int(n))
while n % 17 != 5:
n += 1
return n
MAX_PIXELS = 768 * 1344 # the area core works in
CANVAS_MULTIPLE = 32 # the latent divides by 16; 32 keeps the video vae happy
def canvas_is_legal(width, height):
"""(ok, reason) for a canvas, since core will not fix a bad one.
Not a mirror of core's `adapt_canvas` — that function is for reference
videos and is never applied to the output canvas on this path. This is the
check core does not do.
"""
if width % CANVAS_MULTIPLE or height % CANVAS_MULTIPLE:
return (False, "%dx%d is not a multiple of %d, and core does not round "
"the output canvas — the latent would be the wrong shape"
% (width, height, CANVAS_MULTIPLE))
if width * height > MAX_PIXELS:
return (False, "%dx%d is %.2f MP, past the %.2f MP the model works in"
% (width, height, width * height / 1e6, MAX_PIXELS / 1e6))
return (True, "")
def resolve_shape(duration, resolution):
"""(frames, width, height, notes) for the two dropdown values.
Reads the tables, applies core's frame snapping so the node can SAY what the
model will use rather than what the menu said, and refuses a canvas core
would not fix. Silence about an adjustment is how a surprise becomes a bug
report.
"""
if duration not in DURATIONS:
_fail("duration=%r is not one of %s. (A duration arriving down a link "
"is never checked against the menu by ComfyUI, which is why this "
"is checked here.)" % (duration, DURATION_NAMES))
if resolution not in RESOLUTIONS:
_fail("resolution=%r is not one of %s." % (resolution, RESOLUTION_NAMES))
asked_frames = DURATIONS[duration]
width, height = RESOLUTIONS[resolution]
frames = align_frame_count(asked_frames)
ok, why = canvas_is_legal(width, height)
if not ok:
_fail("resolution=%r maps to an illegal canvas: %s. This is a bug in "
"the pack, not in your graph — please report it." % (resolution, why))
notes = []
if frames != asked_frames:
notes.append("frame count %d -> %d (core snaps up to its 17k+5 grid)"
% (asked_frames, frames))
return frames, width, height, notes
# ── which checkpoint is upstream, and is it the right family ─────────────────
LOADER_TYPES = (
"UNETLoader", "UnetLoaderGGUF", "UnetLoaderGGUFAdvanced",
"CheckpointLoaderSimple", "H3ModelLoaderAny",
)
def _walk_back(workflow, node_id, input_name, depth=0):
"""The node feeding `input_name` of `node_id`, through reroutes and bypasses.
Returns (type, first_widget_value) or (None, None). Never raises: a guard
that can crash is worse than a guard that admits it does not know.
Reroutes are followed by TAKING THE FIRST INPUT'S LINK rather than by name,
because a Reroute's input has no name at all — matching on one would quietly
end the walk at the reroute and turn a real answer into "cannot tell".
"""
if depth > 12:
return (None, None)
nodes = {int(n["id"]): n for n in workflow.get("nodes", [])}
node = nodes.get(int(node_id))
if node is None:
return (None, None)
link_id = None
for index, slot in enumerate(node.get("inputs") or []):
if slot.get("name") == input_name or (input_name is None and index == 0):
link_id = slot.get("link")
break
if link_id is None:
return (None, None)
for link in workflow.get("links") or []:
# [link_id, origin_id, origin_slot, target_id, target_slot, type]
if not isinstance(link, (list, tuple)) or len(link) < 5:
continue
if link[0] != link_id:
continue
origin = nodes.get(int(link[1]))
if origin is None:
return (None, None)
kind = str(origin.get("type") or "")
# A reroute, or a BYPASSED node (mode 4), is not the source: keep going
# through whatever feeds it.
if kind in ("Reroute", "RerouteNode", "PrimitiveNode") or origin.get("mode") == 4:
slots = origin.get("inputs") or []
passthrough = slots[0].get("name") if slots else None
return _walk_back(workflow, link[1], passthrough or None, depth + 1)
widgets = origin.get("widgets_values") or []
first = widgets[0] if widgets else None
return (kind, first if isinstance(first, str) else None)
return (None, None)
def checkpoint_note(model, extra_pnginfo, unique_id, want="fl2va"):
"""A line about the loaded checkpoint, and a hard stop on the wrong family.
fl2va and ref2va are different trainings of the same architecture and load
through the same loader, so nothing in the MODEL object identifies which one
arrived. The name does — and it is upstream in the graph, not in the object,
so it is read out of the workflow the frontend sends along.
Deliberately soft when it cannot tell: a run refused on a guess is worse
than a run that says "I could not check this". Deliberately hard when it
can: fl2va has no reference rows and ref2va cannot land on a last frame, so
the wrong file is a wasted render, not a worse one.
"""
name = None
try:
name = str(getattr(getattr(model, "model", None),
"h3_checkpoint_name", "") or "") or None
except Exception:
name = None
if name is None and extra_pnginfo and unique_id is not None:
try:
workflow = (extra_pnginfo or {}).get("workflow") or {}
_kind, value = _walk_back(workflow, unique_id, "model")
name = value
except Exception:
name = None
if not name:
return ("checkpoint: name unavailable, family UNCHECKED — if the render "
"ignores your frames, confirm you loaded an fl2va file")
low = name.lower()
family = "fl2va" if "fl2va" in low else ("ref2va" if "ref2va" in low else None)
if family is None:
return ("checkpoint: %s — family unproven (the filename says neither "
"fl2va nor ref2va), so this was not checked" % name)
if family != want:
_fail("wrong checkpoint for this node.\n"
" Loaded: %s (a %s model).\n"
" This node is the FIRST/LAST-FRAME task and needs an %s file.\n"
" %s renders from reference photographs and cannot be aimed at a "
"last frame; %s renders between the frames you give it and has no "
"reference inputs at all.\n"
" Fix: pick the %s checkpoint in the loader above."
% (name, family, want, family, want, want))
return "checkpoint: %s (%s, correct for this node)" % (name, family)
# ── images in ────────────────────────────────────────────────────────────────
def image_notes(first_frame, last_frame, width, height):
"""What the two optional images will have done to them, said in advance.
Core treats them differently and neither the UI nor the log mentions it:
first_frame is the geometry anchor and is STRETCHED to the canvas, while
last_frame is a follower and is CENTRE-CROPPED. A portrait photo dropped
into a landscape shot therefore distorts in one slot and loses its edges in
the other — the exact kind of surprise this pack exists to remove.
"""
notes = []
def describe(img, label, behaviour):
try:
h, w = int(img.shape[1]), int(img.shape[2])
except Exception:
return
if (w, h) == (width, height):
notes.append("%s: %dx%d, matches the canvas" % (label, w, h))
return
same_aspect = abs((w / h) - (width / height)) < 0.01
notes.append("%s: %dx%d -> %dx%d, %s%s"
% (label, w, h, width, height, behaviour,
"" if same_aspect else " — ASPECT DIFFERS, so this is visible"))
if first_frame is None and last_frame is None:
notes.append("no frames given: this is a text-to-video run, which is "
"legal here — an unrelated-looking result is not a bug")
if first_frame is not None:
describe(first_frame, "first_frame", "stretched to fit (core anchors geometry on it)")
if last_frame is not None:
describe(last_frame, "last_frame", "centre-cropped (core preserves its aspect)")
if first_frame is not None and last_frame is not None:
try:
a = (int(first_frame.shape[2]), int(first_frame.shape[1]))
b = (int(last_frame.shape[2]), int(last_frame.shape[1]))
except Exception:
a = b = None
if a and b and a != b:
notes.append("the two frames are different sizes (%dx%d and %dx%d) "
"— legal, but they will be fitted differently, so the "
"clip may drift in framing" % (a[0], a[1], b[0], b[1]))
return notes
# ── conditioning, sampling, decoding ─────────────────────────────────────────
def encode_fl2va(clip, vae, prompt, width, height, frames,
first_frame=None, last_frame=None):
"""Conditioning + empty AV latent, from core's own fl2va node.
Called rather than reimplemented: the keyframe encoding, the resize policy
and the latent geometry are the model author's code, and this pack has no
business having a second opinion about them.
"""
h3 = core_h3()
node = h3.MiniMaxH3ImageToVideo
fn = getattr(node, "execute", None) or getattr(node, "encode", None)
if fn is None:
_fail("core's MiniMax-H3 first/last-frame node changed shape in this "
"ComfyUI version. Update this pack (Manager > Update), or roll "
"ComfyUI back to the version the workflow page names.")
out = fn(clip=clip, vae=vae, prompt=prompt, width=width, height=height,
length=frames, first_frame=first_frame, last_frame=last_frame)
values = unwrap(out)
if len(values) < 2:
_fail("core's fl2va node returned %d value(s) where 2 were expected."
% len(values))
return values[0], values[1]
def sample_av(model, conditioning, latent, steps, seed):
"""Mirror of core's SamplerCustomAdvanced with a Basic guider.
Basic (not CFG) guidance is what the model's own template uses: this path
has no negative prompt, so there is no second branch to weigh against.
"""
import comfy.model_management
import comfy.sample
import comfy.samplers
import comfy.utils
import latent_preview
try:
from comfy_extras.nodes_custom_sampler import Guider_Basic, Noise_RandomNoise
except Exception:
_fail("this ComfyUI is missing the custom sampler internals this pack "
"drives. Update ComfyUI and restart.")
guider = Guider_Basic(model)
guider.set_conds(conditioning)
sampler = comfy.samplers.sampler_object(SAMPLER_NAME)
sigmas = comfy.samplers.calculate_sigmas(
model.get_model_object("model_sampling"), SCHEDULER, int(steps)).cpu()
noise = Noise_RandomNoise(int(seed))
latent = dict(latent)
samples_in = latent["samples"]
try:
samples_in = comfy.sample.fix_empty_latent_channels(
guider.model_patcher, samples_in,
latent.get("downscale_ratio_spacial", None),
latent.get("downscale_ratio_temporal", None))
except TypeError: # older signature took two arguments
samples_in = comfy.sample.fix_empty_latent_channels(
guider.model_patcher, samples_in)
latent["samples"] = samples_in
callback = latent_preview.prepare_callback(
guider.model_patcher, sigmas.shape[-1] - 1)
samples = guider.sample(
noise.generate_noise(latent), samples_in, sampler, sigmas,
denoise_mask=latent.get("noise_mask", None), callback=callback,
disable_pbar=not comfy.utils.PROGRESS_BAR_ENABLED, seed=noise.seed)
samples = samples.to(comfy.model_management.intermediate_device())
out = dict(latent)
out.pop("downscale_ratio_spacial", None)
out.pop("downscale_ratio_temporal", None)
out["samples"] = samples
return out
def decode_video(vae, latent):
"""Mirror of core's VAEDecode, including the nested-latent unbind.
An H3 latent is always a PAIR — video and audio in one NestedTensor — so
the video VAE gets element 0 and the audio VAE gets the last one.
"""
lat = latent["samples"]
if getattr(lat, "is_nested", False):
lat = lat.unbind()[0]
images = vae.decode(lat)
if len(images.shape) == 5:
images = images.reshape(-1, images.shape[-3], images.shape[-2], images.shape[-1])
return images
def decode_audio(audio_vae, latent):
"""core's own `vae_decode_audio` — it normalises level, we should not."""
try:
from comfy_extras.nodes_audio import vae_decode_audio
except Exception:
_fail("this ComfyUI cannot decode audio latents (comfy_extras/"
"nodes_audio.py is missing or older than this pack expects). "
"Update ComfyUI and restart.")
return vae_decode_audio(audio_vae, latent)
def report(title, lines):
"""One block, printed and returned, so the run explains itself either way."""
body = "\n".join(" " + str(line) for line in lines if line)
text = "[SimpleShot] %s\n%s" % (title, body)
print(text, flush=True)
return text
+137
View File
@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
"""SimpleShot's first node: one image (or two, or none) to video with sound.
The whole MiniMax-H3 fl2va pipeline in a single node — conditioning, keyframes,
sampling and both decodes. Not because a monolith is elegant, but because the
graph it leaves behind is four loaders, this node, and a video saver: there is
almost nothing to wire, so there is almost nothing to wire wrongly.
What it does NOT offer is the point of the design: no sampler, no scheduler, no
cfg, no sigma shift, no negative prompt. Those are settings whose only realistic
use here is to break a working render. What it does offer is what changes the
picture: the prompt, up to two frames, how long, how big, how many steps.
The model behind it is a first/last-frame model, so identity comes from the
image you give it, not from reference photographs — a reference node is a
separate node, later, because a mode dropdown spanning two sampling paths is a
mistake this line has already paid for once.
"""
from . import _core
class SimpleShotH3FirstLast:
"""fl2va in one node: prompt (+ optional first/last frames) -> video + audio."""
CATEGORY = "SimpleShot"
FUNCTION = "render"
RETURN_TYPES = ("IMAGE", "AUDIO", "STRING")
RETURN_NAMES = ("images", "audio", "info")
OUTPUT_TOOLTIPS = (
"The frames. Wire into Create Video, then Save Video.",
"The generated soundtrack, already in step with the picture.",
"What actually ran: frame count, canvas, checkpoint, how the frames "
"were fitted. Wire it into a Preview Any node if you want to read it "
"on the canvas.",
)
DESCRIPTION = (
"MiniMax-H3 first/last-frame video WITH sound, in one node. Give it a "
"prompt and optionally a first frame, a last frame, or both. Nothing "
"here can be set to an illegal value: the menus only offer lengths and "
"canvases this model accepts, and the info output says what it used."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL", {"tooltip":
"The H3 fl2va checkpoint, from a Load Diffusion Model "
"node. Any loader works — swap in a smaller community "
"quant if 21 GB of weights is too much for your card."}),
"clip": ("CLIP", {"tooltip":
"The H3 text encoder, from a Load CLIP node."}),
"vae": ("VAE", {"tooltip":
"The VIDEO vae (minimax_h3_video_vae). Easy to swap with "
"the audio one by accident — this is the larger file."}),
"audio_vae": ("VAE", {"tooltip":
"The AUDIO vae (minimax_h3_audio_vae), the small one."}),
"prompt": ("STRING", {
"multiline": True, "dynamicPrompts": True,
"default": "",
"tooltip":
"Describe the shot: subject, what it does, the camera, "
"the light, and the sound you want. This model listens "
"to sound cues in the prompt — voices, footsteps, room "
"tone — because it renders picture and audio together."}),
"duration": (_core.DURATION_NAMES, {
"default": _core.DURATION_DEFAULT,
"tooltip":
"Clip length at 24 fps. Start on the 2 s quick test to "
"prove your install before spending a long render."}),
"resolution": (_core.RESOLUTION_NAMES, {
"default": _core.RESOLUTION_DEFAULT,
"tooltip":
"Output canvas. These are the shapes the model works "
"in — the menu is the guard, because ComfyUI will not "
"round an odd size for you on this path. Portrait 768 "
"x 1344 is what the model itself defaults to."}),
"steps": ("INT", {
"default": _core.STEPS_DEFAULT, "min": 1, "max": 100,
"tooltip":
"The one real cost/quality dial. 20 is the model's own "
"template default; fewer is faster and softer."}),
"seed": ("INT", {
"default": 0, "min": 0, "max": 0xffffffffffffffff,
"control_after_generate": True,
"tooltip":
"Changes the take. Left on randomize you get a fresh "
"one each time you press Run; set it to fixed to keep "
"a take you liked while you edit the prompt."}),
},
"optional": {
"first_frame": ("IMAGE", {"tooltip":
"Optional. The clip starts here. This is the geometry "
"anchor: it is STRETCHED to the canvas, so give it the "
"same shape as the resolution you picked or it distorts."}),
"last_frame": ("IMAGE", {"tooltip":
"Optional. The clip lands here. This one is CENTRE-CROPPED "
"instead of stretched, so a different shape loses its "
"edges rather than squashing."}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
def render(self, model, clip, vae, audio_vae, prompt, duration, resolution,
steps, seed, first_frame=None, last_frame=None,
unique_id=None, extra_pnginfo=None):
frames, width, height, snap_notes = _core.resolve_shape(duration, resolution)
checkpoint = _core.checkpoint_note(model, extra_pnginfo, unique_id,
want="fl2va")
lines = ["%d frames at %d fps (%.2f s), canvas %dx%d, %d steps, seed %d"
% (frames, _core.FPS, frames / _core.FPS, width, height,
int(steps), int(seed)),
checkpoint]
lines += snap_notes
lines += _core.image_notes(first_frame, last_frame, width, height)
if not str(prompt).strip():
lines.append("the prompt is EMPTY — the model will invent "
"everything, including the sound")
info = _core.report("H3 first/last -> video + audio", lines)
conditioning, latent = _core.encode_fl2va(
clip, vae, prompt, width, height, frames,
first_frame=first_frame, last_frame=last_frame)
sampled = _core.sample_av(model, conditioning, latent, steps, seed)
images = _core.decode_video(vae, sampled)
audio = _core.decode_audio(audio_vae, sampled)
return (images, audio, info)
NODE_CLASS_MAPPINGS = {"SimpleShotH3FirstLast": SimpleShotH3FirstLast}
NODE_DISPLAY_NAME_MAPPINGS = {
"SimpleShotH3FirstLast": "SimpleShot H3 - First/Last Frame to Video + Audio",
}
+233
View File
@@ -0,0 +1,233 @@
# -*- coding: utf-8 -*-
"""Everything about this pack that can be checked without a GPU.
python tests/test_offline.py
No pytest, no torch, no ComfyUI: it exercises the tables, the snapping, the
graph walk and the guards, which is where the bugs that reach a user actually
live. The sampling call is not covered here — that needs the model, and a real
render is the only honest test of it.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from simpleshot import _core # noqa: E402
from simpleshot.h3_first_last import ( # noqa: E402
NODE_CLASS_MAPPINGS, SimpleShotH3FirstLast)
PASS = []
FAIL = []
def check(name, condition, detail=""):
(PASS if condition else FAIL).append(name)
print("%-4s %s%s" % ("ok" if condition else "FAIL", name,
"" if condition or not detail else " <- " + detail))
def raises(fn, needle):
try:
fn()
except Exception as exc:
return needle in str(exc)
return False
class FakeImage:
"""Only `.shape` is read, so only `.shape` is provided."""
def __init__(self, w, h):
self.shape = (1, h, w, 3)
# ── the tables must already be legal, so core never has to correct them ──────
for label, frames in _core.DURATIONS.items():
check("duration on the 17k+5 grid: %s" % label, frames % 17 == 5,
"%d %% 17 == %d" % (frames, frames % 17))
check("duration snaps to itself: %s" % label,
_core.align_frame_count(frames) == frames)
# The canvas rules are the ones core does NOT enforce on this path, so every
# entry has to be legal on its own. An off-multiple entry here would reach the
# user as a tensor-shape crash from deep inside the sampler.
for label, (w, h) in _core.RESOLUTIONS.items():
ok, why = _core.canvas_is_legal(w, h)
check("canvas is legal without core's help: %s" % label, ok, why)
check("canvas is a multiple of 32: %s" % label, w % 32 == 0 and h % 32 == 0)
check("canvas within the area the model works in: %s" % label,
w * h <= _core.MAX_PIXELS, "%.2f MP" % (w * h / 1e6))
check("defaults are in their menus",
_core.DURATION_DEFAULT in _core.DURATIONS
and _core.RESOLUTION_DEFAULT in _core.RESOLUTIONS)
# ── snapping reports itself ──────────────────────────────────────────────────
frames, w, h, notes = _core.resolve_shape("5 s (124 frames)", "portrait 768 x 1344")
check("resolve_shape returns the asked values", (frames, w, h) == (124, 768, 1344),
str((frames, w, h)))
check("resolve_shape reports no adjustment for a legal pair", notes == [], str(notes))
check("align_frame_count snaps up", _core.align_frame_count(100) == 107,
str(_core.align_frame_count(100)))
check("align_frame_count floors at 5", _core.align_frame_count(1) == 5)
check("an odd canvas is called illegal, not corrected",
_core.canvas_is_legal(700, 1280)[0] is False)
check("an oversized canvas is called illegal",
_core.canvas_is_legal(1344, 1344)[0] is False)
check("the illegal-canvas reason says what is wrong",
"multiple of 32" in _core.canvas_is_legal(700, 1280)[1])
# ── a linked value bypasses the menu, so it is checked in code ───────────────
check("unknown duration is refused by name",
raises(lambda: _core.resolve_shape("7 s", "portrait 768 x 1344"),
"duration='7 s'"))
check("unknown resolution is refused by name",
raises(lambda: _core.resolve_shape("5 s (124 frames)", "4k"), "resolution='4k'"))
check("refusals carry the pack name",
raises(lambda: _core.resolve_shape("7 s", "portrait 768 x 1344"), "[SimpleShot]"))
# ── walking back to the loader through the shapes a real graph takes ─────────
DIRECT = {
"nodes": [
{"id": 1, "type": "UNETLoader",
"widgets_values": ["minimax_h3_fl2va_pruned_fp8_scaled.safetensors", "default"]},
{"id": 2, "type": "SimpleShotH3FirstLast",
"inputs": [{"name": "model", "link": 10}]},
],
"links": [[10, 1, 0, 2, 0, "MODEL"]],
}
check("walk back: direct link",
_core._walk_back(DIRECT, 2, "model")
== ("UNETLoader", "minimax_h3_fl2va_pruned_fp8_scaled.safetensors"))
REROUTED = {
"nodes": [
{"id": 1, "type": "UNETLoader", "widgets_values": ["h3_fl2va.safetensors"]},
{"id": 5, "type": "Reroute", "inputs": [{"name": "", "link": 10}]},
{"id": 2, "type": "SimpleShotH3FirstLast", "inputs": [{"name": "model", "link": 11}]},
],
"links": [[10, 1, 0, 5, 0, "MODEL"], [11, 5, 0, 2, 0, "MODEL"]],
}
check("walk back: through a reroute, whose input has no name",
_core._walk_back(REROUTED, 2, "model") == ("UNETLoader", "h3_fl2va.safetensors"),
str(_core._walk_back(REROUTED, 2, "model")))
check("walk back: two reroutes in a row",
_core._walk_back({
"nodes": [{"id": 1, "type": "UNETLoader", "widgets_values": ["h3_fl2va.safetensors"]},
{"id": 5, "type": "Reroute", "inputs": [{"name": "", "link": 10}]},
{"id": 6, "type": "Reroute", "inputs": [{"name": "", "link": 11}]},
{"id": 2, "type": "X", "inputs": [{"name": "model", "link": 12}]}],
"links": [[10, 1, 0, 5, 0, "MODEL"], [11, 5, 0, 6, 0, "MODEL"],
[12, 6, 0, 2, 0, "MODEL"]]}, 2, "model")
== ("UNETLoader", "h3_fl2va.safetensors"))
check("walk back: a cycle of reroutes cannot hang the guard",
_core._walk_back({
"nodes": [{"id": 5, "type": "Reroute", "inputs": [{"name": "", "link": 11}]},
{"id": 6, "type": "Reroute", "inputs": [{"name": "", "link": 10}]},
{"id": 2, "type": "X", "inputs": [{"name": "model", "link": 12}]}],
"links": [[10, 5, 0, 6, 0, "MODEL"], [11, 6, 0, 5, 0, "MODEL"],
[12, 6, 0, 2, 0, "MODEL"]]}, 2, "model") == (None, None))
BYPASSED = {
"nodes": [
{"id": 1, "type": "UNETLoader", "widgets_values": ["h3_ref2va.safetensors"]},
{"id": 5, "type": "LoraLoaderModelOnly", "mode": 4,
"inputs": [{"name": "model", "link": 10}], "widgets_values": ["x.safetensors", 1.0]},
{"id": 2, "type": "SimpleShotH3FirstLast", "inputs": [{"name": "model", "link": 11}]},
],
"links": [[10, 1, 0, 5, 0, "MODEL"], [11, 5, 0, 2, 0, "MODEL"]],
}
check("walk back: past a bypassed node to the real loader",
_core._walk_back(BYPASSED, 2, "model") == ("UNETLoader", "h3_ref2va.safetensors"),
str(_core._walk_back(BYPASSED, 2, "model")))
check("walk back: nothing connected", _core._walk_back(DIRECT, 2, "clip") == (None, None))
check("walk back: unknown node id", _core._walk_back(DIRECT, 99, "model") == (None, None))
check("walk back: empty workflow", _core._walk_back({}, 2, "model") == (None, None))
check("walk back: malformed links survive",
_core._walk_back({"nodes": [{"id": 2, "inputs": [{"name": "model", "link": 1}]}],
"links": [None, [1], "junk"]}, 2, "model") == (None, None))
# ── the family guard: hard when it knows, soft when it does not ──────────────
class FakeModel:
def __init__(self, name=None):
if name is not None:
self.model = type("M", (), {"h3_checkpoint_name": name})()
check("guard: fl2va checkpoint passes and is named",
"fl2va" in _core.checkpoint_note(FakeModel("MiniMax-H3-fl2va-Q8_0.gguf"), None, None))
check("guard: ref2va checkpoint is refused with an instruction",
raises(lambda: _core.checkpoint_note(FakeModel("MiniMax-H3-ref2va-curve.gguf"), None, None),
"wrong checkpoint"))
check("guard: the refusal names the file",
raises(lambda: _core.checkpoint_note(FakeModel("MiniMax-H3-ref2va-curve.gguf"), None, None),
"MiniMax-H3-ref2va-curve.gguf"))
check("guard: an unprovable name warns instead of refusing",
"unproven" in _core.checkpoint_note(FakeModel("my_model.safetensors"), None, None))
check("guard: no name at all admits it did not check",
"UNCHECKED" in _core.checkpoint_note(FakeModel(), None, None))
check("guard: reads the name out of the workflow when the object has none",
"fl2va" in _core.checkpoint_note(FakeModel(), {"workflow": DIRECT}, 2))
check("guard: refuses a ref2va file found in the workflow",
raises(lambda: _core.checkpoint_note(FakeModel(), {"workflow": BYPASSED}, 2),
"wrong checkpoint"))
check("guard: a broken extra_pnginfo never crashes the run",
"UNCHECKED" in _core.checkpoint_note(FakeModel(), {"workflow": "not a graph"}, 2))
# ── what happens to the images, said before the render ──────────────────────
matched = _core.image_notes(FakeImage(736, 1280), None, 736, 1280)
check("images: a matching first frame is confirmed",
any("matches the canvas" in n for n in matched), str(matched))
stretched = _core.image_notes(FakeImage(1280, 736), None, 736, 1280)
check("images: a mismatched first frame says stretched, and says it shows",
any("stretched" in n and "ASPECT DIFFERS" in n for n in stretched), str(stretched))
cropped = _core.image_notes(None, FakeImage(1280, 736), 736, 1280)
check("images: a mismatched last frame says cropped",
any("centre-cropped" in n for n in cropped), str(cropped))
both = _core.image_notes(FakeImage(736, 1280), FakeImage(512, 512), 736, 1280)
check("images: two different sizes are called out",
any("different sizes" in n for n in both), str(both))
none = _core.image_notes(None, None, 736, 1280)
check("images: nothing supplied is legal and says so",
any("text-to-video" in n for n in none), str(none))
check("images: a same-aspect resize does not shout",
not any("ASPECT DIFFERS" in n
for n in _core.image_notes(FakeImage(368, 640), None, 736, 1280)))
# ── the positional contract: widget order may only ever grow ────────────────
spec = SimpleShotH3FirstLast.INPUT_TYPES()
WIDGETS_AS_PUBLISHED = ["prompt", "duration", "resolution", "steps", "seed"]
LINKS_AS_PUBLISHED = ["model", "clip", "vae", "audio_vae"]
required = list(spec["required"])
widgets = [k for k in required if k not in LINKS_AS_PUBLISHED]
check("widget order is unchanged (widgets_values is POSITIONAL)",
widgets[:len(WIDGETS_AS_PUBLISHED)] == WIDGETS_AS_PUBLISHED, str(widgets))
check("link inputs are unchanged and first",
required[:len(LINKS_AS_PUBLISHED)] == LINKS_AS_PUBLISHED, str(required))
check("both frames stayed optional", set(spec["optional"]) == {"first_frame", "last_frame"})
check("the graph's hidden inputs are declared",
spec["hidden"] == {"unique_id": "UNIQUE_ID", "extra_pnginfo": "EXTRA_PNGINFO"})
check("seed re-rolls per run", spec["required"]["seed"][1]["control_after_generate"] is True)
check("every input carries a tooltip",
all("tooltip" in v[1] for group in ("required", "optional")
for v in spec[group].values()), "a tooltip is the only manual most users read")
# ── registration ────────────────────────────────────────────────────────────
check("one node registered, under its published id",
list(NODE_CLASS_MAPPINGS) == ["SimpleShotH3FirstLast"])
check("outputs are labelled and documented",
len(SimpleShotH3FirstLast.RETURN_TYPES)
== len(SimpleShotH3FirstLast.RETURN_NAMES)
== len(SimpleShotH3FirstLast.OUTPUT_TOOLTIPS) == 3)
check("no second custom pack is imported",
not any("custom_nodes" in line
for line in open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "simpleshot", "_core.py"))))
print("\n%d passed, %d failed" % (len(PASS), len(FAIL)))
if FAIL:
for name in FAIL:
print(" FAILED: " + name)
sys.exit(1 if FAIL else 0)