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:
@@ -0,0 +1 @@
|
||||
manzakin
|
||||
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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 = ""
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user