From 7bc4298cb1f985c18fc31a1e25ed40831e8fbb42 Mon Sep 17 00:00:00 2001 From: manzakin <316723319+manzakin@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:05:35 +0200 Subject: [PATCH] =?UTF-8?q?simpleshot:=20the=20pack=20=E2=80=94=20one=20no?= =?UTF-8?q?de,=20MiniMax-H3=20first/last=20frame=20to=20video=20with=20sou?= =?UTF-8?q?nd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github-account | 1 + .gitignore | 2 + LICENSE | 21 ++ README.md | 87 ++++++++ __init__.py | 11 + pyproject.toml | 15 ++ simpleshot/__init__.py | 19 ++ simpleshot/_core.py | 418 ++++++++++++++++++++++++++++++++++++ simpleshot/h3_first_last.py | 137 ++++++++++++ tests/test_offline.py | 233 ++++++++++++++++++++ 10 files changed, 944 insertions(+) create mode 100644 .github-account create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 pyproject.toml create mode 100644 simpleshot/__init__.py create mode 100644 simpleshot/_core.py create mode 100644 simpleshot/h3_first_last.py create mode 100644 tests/test_offline.py diff --git a/.github-account b/.github-account new file mode 100644 index 0000000..1720357 --- /dev/null +++ b/.github-account @@ -0,0 +1 @@ +manzakin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bbda674 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0937090 --- /dev/null +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b319d89 --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..66060bd --- /dev/null +++ b/pyproject.toml @@ -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 = "" diff --git a/simpleshot/__init__.py b/simpleshot/__init__.py new file mode 100644 index 0000000..b41133f --- /dev/null +++ b/simpleshot/__init__.py @@ -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"] diff --git a/simpleshot/_core.py b/simpleshot/_core.py new file mode 100644 index 0000000..5cdccde --- /dev/null +++ b/simpleshot/_core.py @@ -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 diff --git a/simpleshot/h3_first_last.py b/simpleshot/h3_first_last.py new file mode 100644 index 0000000..7b7fac9 --- /dev/null +++ b/simpleshot/h3_first_last.py @@ -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", +} diff --git a/tests/test_offline.py b/tests/test_offline.py new file mode 100644 index 0000000..8bd209d --- /dev/null +++ b/tests/test_offline.py @@ -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)