Muting both image loaders is a legal way to run this — the optional inputs vanish from the prompt and core reads that as text-to-video — but with no image to match, the canvas came from ratio arithmetic and landed on 768x1312. Legal, and 41 rows short of the canvas the model itself ships with, for no reason anyone could explain. Each match tier now names its own portrait fallback: 768x1344, 480x832, 352x608. Tests assert all three are legal, portrait, and that full quality is the model's own default rather than whatever the arithmetic produces.
595 lines
26 KiB
Python
595 lines
26 KiB
Python
# -*- 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.
|
|
"""
|
|
import math
|
|
|
|
# ── 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)"
|
|
|
|
# Two ways to pick a canvas, and the first is better for anyone who supplies an
|
|
# image.
|
|
#
|
|
# MATCH takes the ASPECT RATIO from your frame and only the SIZE from the menu.
|
|
# It exists because core fits your image to the canvas rather than the other way
|
|
# round, and it fits the first frame by STRETCHING it — so a landscape photo in a
|
|
# portrait canvas is visibly squashed, and nothing in the UI says so. Matching
|
|
# makes that fit a no-op: nothing distorts, nothing is cropped, and the pixels
|
|
# are resampled once instead of twice. It is the same idea as the official H3
|
|
# template's GetImageSize -> ResolutionSelector -> ImageScaleToTotalPixels chain,
|
|
# done inside the node so the canvas stays clean.
|
|
#
|
|
# FIXED sizes stay for text-to-video (no image to match) and for deliberately
|
|
# choosing a shape.
|
|
#
|
|
# On the small sizes: they are not a compromise anyone invented here. ComfyUI's
|
|
# own H3 template renders at 0.4 MP by default and its notes tabulate 0.2 MP
|
|
# (608x352) and 0.3 MP (736x416) as ordinary choices. Every size in the first
|
|
# version of this menu was ~1.0 MP, which is the model's maximum canvas, not its
|
|
# working default — roughly 3x the pixels of the template, and attention costs
|
|
# more than linearly in pixels.
|
|
|
|
MATCH_TARGETS = {
|
|
"match my image - full quality (~1.0 MP)": 1_000_000,
|
|
"match my image - fast (~0.4 MP)": 400_000,
|
|
"match my image - draft (~0.2 MP)": 200_000,
|
|
}
|
|
|
|
# Text-to-video has no image to match, so each budget needs a canvas of its own.
|
|
# These are NOT computed: the full-quality one is the model's own default portrait
|
|
# (768x1344) rather than the 768x1312 the ratio arithmetic produces, because "the
|
|
# size the model ships with" is a better answer than an artefact of rounding.
|
|
MATCH_FALLBACKS = {
|
|
"match my image - full quality (~1.0 MP)": (768, 1344),
|
|
"match my image - fast (~0.4 MP)": (480, 832),
|
|
"match my image - draft (~0.2 MP)": (352, 608),
|
|
}
|
|
|
|
RESOLUTIONS = {
|
|
"portrait 768 x 1344": (768, 1344),
|
|
"landscape 1344 x 768": (1344, 768),
|
|
"square 960 x 960": (960, 960),
|
|
"portrait 448 x 768 - fast": (448, 768),
|
|
"landscape 768 x 448 - fast": (768, 448),
|
|
"portrait 352 x 608 - draft": (352, 608),
|
|
"landscape 608 x 352 - draft": (608, 352),
|
|
}
|
|
RESOLUTION_NAMES = list(MATCH_TARGETS) + list(RESOLUTIONS)
|
|
RESOLUTION_DEFAULT = "match my image - full quality (~1.0 MP)"
|
|
|
|
# ── 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 = 14 # the template says 20; this sampler converges early and the
|
|
# difference is hard to see, so the cheaper number ships.
|
|
# Settled by eye at draft resolution, not by argument.
|
|
|
|
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 canvas_from_image(width, height, target_pixels):
|
|
"""An H3-legal canvas with THIS image's aspect ratio, near a pixel budget.
|
|
|
|
Same arithmetic as core's ResolutionSelector: hold the ratio, aim at the
|
|
budget, round each axis to 32. Then the area cap is applied and the rounding
|
|
redone, because rounding up twice can cross the cap.
|
|
"""
|
|
ratio = width / float(height)
|
|
h = math.sqrt(target_pixels / ratio)
|
|
w = ratio * h
|
|
for _ in range(4):
|
|
cw = max(CANVAS_MULTIPLE, int(round(w / CANVAS_MULTIPLE)) * CANVAS_MULTIPLE)
|
|
ch = max(CANVAS_MULTIPLE, int(round(h / CANVAS_MULTIPLE)) * CANVAS_MULTIPLE)
|
|
if cw * ch <= MAX_PIXELS:
|
|
return cw, ch
|
|
shrink = math.sqrt(MAX_PIXELS / float(cw * ch))
|
|
w, h = w * shrink, h * shrink
|
|
return CANVAS_MULTIPLE, CANVAS_MULTIPLE
|
|
|
|
|
|
def _image_size(image):
|
|
"""(w, h) for a ComfyUI IMAGE batch, or None if it cannot be read."""
|
|
try:
|
|
return int(image.shape[2]), int(image.shape[1])
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def resolve_shape(duration, resolution, first_frame=None, last_frame=None):
|
|
"""(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 and resolution not in MATCH_TARGETS:
|
|
_fail("resolution=%r is not one of %s." % (resolution, RESOLUTION_NAMES))
|
|
|
|
asked_frames = DURATIONS[duration]
|
|
frames = align_frame_count(asked_frames)
|
|
notes = []
|
|
|
|
if resolution in MATCH_TARGETS:
|
|
target = MATCH_TARGETS[resolution]
|
|
source = _image_size(first_frame) or _image_size(last_frame)
|
|
if source is None:
|
|
# Text-to-video, or an unreadable image: there is no aspect to match,
|
|
# so fall back to a real portrait canvas and SAY a choice was made.
|
|
width, height = MATCH_FALLBACKS[resolution]
|
|
notes.append("no image to match, so the canvas is portrait %dx%d at "
|
|
"the size you asked for" % (width, height))
|
|
else:
|
|
sw, sh = source
|
|
width, height = canvas_from_image(sw, sh, target)
|
|
notes.append("canvas %dx%d, matched to your %dx%d frame (%.2f MP) — "
|
|
"nothing is stretched or cropped"
|
|
% (width, height, sw, sh, width * height / 1e6))
|
|
else:
|
|
width, height = RESOLUTIONS[resolution]
|
|
|
|
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))
|
|
|
|
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 _model_chain(workflow, node_id, input_name="model", limit=16):
|
|
"""Every node in the model path above us, nearest first.
|
|
|
|
Returns [(type, mode, widgets), ...]. Walks the `model` input of each node in
|
|
turn, so it sees LoRA loaders, patchers and reroutes alike, and stops at the
|
|
first node with nothing feeding its model input — the loader. Never raises.
|
|
"""
|
|
chain = []
|
|
try:
|
|
nodes = {int(n["id"]): n for n in workflow.get("nodes", [])}
|
|
links = {}
|
|
for link in workflow.get("links") or []:
|
|
if isinstance(link, (list, tuple)) and len(link) >= 5:
|
|
links[link[0]] = int(link[1])
|
|
current, want = int(node_id), input_name
|
|
for _ in range(limit):
|
|
node = nodes.get(current)
|
|
if node is None:
|
|
break
|
|
link_id = None
|
|
for index, slot in enumerate(node.get("inputs") or []):
|
|
if slot.get("name") == want or (want is None and index == 0):
|
|
link_id = slot.get("link")
|
|
break
|
|
if link_id is None or link_id not in links:
|
|
break
|
|
origin = nodes.get(links[link_id])
|
|
if origin is None:
|
|
break
|
|
chain.append((str(origin.get("type") or ""),
|
|
int(origin.get("mode") or 0),
|
|
list(origin.get("widgets_values") or [])))
|
|
names = [s.get("name") for s in (origin.get("inputs") or [])]
|
|
if "model" in names:
|
|
current, want = int(origin["id"]), "model"
|
|
elif names:
|
|
current, want = int(origin["id"]), names[0] or None
|
|
else:
|
|
break
|
|
except Exception:
|
|
return chain
|
|
return chain
|
|
|
|
|
|
def lora_note(extra_pnginfo, unique_id):
|
|
"""What the model picked up on its way here, or None if there is nothing to say.
|
|
|
|
Worth a line because a switched-off LoRA looks almost identical to a live one
|
|
on the canvas, and "why doesn't my character look right" is usually that. The
|
|
node cannot ask the MODEL object — a LoRA is already baked into its weights
|
|
by the time it arrives — so this reads the graph, like the checkpoint guard.
|
|
"""
|
|
if not extra_pnginfo or unique_id is None:
|
|
return None
|
|
workflow = (extra_pnginfo or {}).get("workflow") or {}
|
|
chain = _model_chain(workflow, unique_id)
|
|
slots = [(mode, widgets) for kind, mode, widgets in chain
|
|
if "lora" in kind.lower()]
|
|
if not slots:
|
|
return None
|
|
|
|
def describe(widgets):
|
|
name = widgets[0] if widgets and isinstance(widgets[0], str) else "?"
|
|
strength = widgets[1] if len(widgets) > 1 else 1.0
|
|
return "%s @ %s" % (name, strength)
|
|
|
|
live = [describe(w) for mode, w in slots if mode == 0]
|
|
off = len(slots) - len(live)
|
|
if live:
|
|
return ("LoRA: " + ", ".join(live)
|
|
+ ("" if not off else " (%d slot%s switched off)"
|
|
% (off, "" if off == 1 else "s")))
|
|
return ("no LoRA in effect — %d slot%s present, all switched off"
|
|
% (off, "" if off == 1 else "s"))
|
|
|
|
|
|
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:
|
|
# Walk the WHOLE model path, not just one hop. Anything with a `model`
|
|
# input can sit between the loader and us — a LoRA slot, a patcher — and
|
|
# taking the first node found reported the LORA's filename as the
|
|
# checkpoint, which then failed the family check on a graph that was
|
|
# perfectly correct. The loader is the far end of the chain.
|
|
try:
|
|
workflow = (extra_pnginfo or {}).get("workflow") or {}
|
|
chain = _model_chain(workflow, unique_id)
|
|
candidates = [w for kind, _mode, w in chain if kind in LOADER_TYPES]
|
|
if not candidates and chain:
|
|
candidates = [chain[-1][2]]
|
|
for widgets in candidates:
|
|
first = widgets[0] if widgets else None
|
|
if isinstance(first, str) and first:
|
|
name = first
|
|
break
|
|
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
|