#!/usr/bin/env python3 """CPU smoke test for H3 chain timing, segments, checkpoints and resume. Uses the adjacent ComfyUI checkout for its real node/runtime modules, but no model or GPU. It encodes two tiny H.264 segments, resumes clip 2 from clip 1's safetensors checkpoint, and assembles both source-track and generated-audio outputs with ffmpeg. """ import asyncio import hashlib import importlib.util import json import math import os import pathlib import subprocess import sys import tempfile import threading import time import wave from datetime import datetime ROOT = pathlib.Path(__file__).resolve().parents[1] COMFY_CANDIDATES = [ROOT.parent / "Comfyui", ROOT.parent / "ComfyUI"] if os.environ.get("COMFYUI_PATH"): COMFY_CANDIDATES.insert(0, pathlib.Path(os.environ["COMFYUI_PATH"])) COMFY = next((path for path in COMFY_CANDIDATES if (path / "comfy" / "options.py").is_file()), None) if COMFY is None: raise SystemExit("adjacent ComfyUI checkout not found") sys.path.insert(0, str(COMFY)) sys.argv = ["h3-chain-smoke", "--cpu"] import comfy.options # noqa: E402 comfy.options.enable_args_parsing() import folder_paths # noqa: E402 import torch # noqa: E402 import execution # noqa: E402 import nodes as comfy_nodes # noqa: E402 from PIL import Image as PILImage # noqa: E402 from safetensors import safe_open # noqa: E402 def load_package(): spec = importlib.util.spec_from_file_location( "h3_chain_smoke_package", ROOT / "__init__.py", submodule_search_locations=[str(ROOT)], ) package = importlib.util.module_from_spec(spec) sys.modules[spec.name] = package spec.loader.exec_module(package) return package, sys.modules[spec.name + ".chain_nodes"] def av_latent(video_t=1, audio_t=9): return { "samples": [ torch.zeros((1, 16, video_t, 2, 2), dtype=torch.float32), torch.zeros((1, 32, 2, audio_t), dtype=torch.float32), ] } def audio_for_frames(frames, sample_rate=8000): samples = round(frames / 24.0 * sample_rate) return { "waveform": torch.zeros((1, 2, samples), dtype=torch.float32), "sample_rate": sample_rate, } class FakeDynamicPrompt: def __init__(self, prompt): self.prompt = prompt def get_node(self, node_id): return self.prompt[str(node_id)] def get_display_node_id(self, node_id): return str(node_id) def get_original_prompt(self): return self.prompt def main(): package, chain = load_package() segment_prompt = { "26": {"class_type": "MiniMaxH3ChainSegmentSave", "inputs": {}}, "27": {"class_type": "MiniMaxH3ChainReview", "inputs": { "segment": ["26", 0], }}, } assert chain._has_downstream_review_gate( FakeDynamicPrompt(segment_prompt), "26") segment_prompt["27"]["inputs"]["segment"] = ["26", 1] assert not chain._has_downstream_review_gate( FakeDynamicPrompt(segment_prompt), "26") segment_prompt["27"]["class_type"] = "MiniMaxH3ChainLoopEnd" segment_prompt["27"]["inputs"]["segment"] = ["26", 0] assert not chain._has_downstream_review_gate( FakeDynamicPrompt(segment_prompt), "26") cleanup_input = chain.MiniMaxH3ChainLoopEnd.INPUT_TYPES()[ "optional"]["between_scene_cleanup"] assert cleanup_input[0] == ["off", "unload_models", "fresh_scene"] cleanup_calls = [] import comfy.memory_management as memory_management import comfy.model_management as model_management original_free_pins = model_management.free_pins original_unload = model_management.unload_all_models original_empty_cache = model_management.soft_empty_cache original_cleanup_models = model_management.cleanup_models_gc original_ram_release = memory_management.extra_ram_release original_collect = chain.gc.collect model_management.free_pins = lambda size, evict_active=False: ( cleanup_calls.append(("pins", size, evict_active)) or 12_345) model_management.unload_all_models = lambda: cleanup_calls.append( ("unload",)) model_management.soft_empty_cache = lambda force=False: cleanup_calls.append( ("empty", force)) model_management.cleanup_models_gc = lambda: cleanup_calls.append( ("models_gc",)) memory_management.extra_ram_release = ( lambda target, free_active=False: ( cleanup_calls.append(("ram", target, free_active)) or 54_321)) chain.gc.collect = lambda: cleanup_calls.append(("gc",)) or 7 try: untouched = chain._release_loop_boundary_resources("off", 1) assert cleanup_calls == [] and untouched["policy"] == "off" unloaded = chain._release_loop_boundary_resources( "unload_models", 1) assert cleanup_calls == [("unload",), ("empty", True)] assert unloaded["pinned_bytes"] == 0 assert unloaded["cache_bytes"] == 0 cleanup_calls.clear() fresh = chain._release_loop_boundary_resources("fresh_scene", 2) assert cleanup_calls == [ ("ram", sys.maxsize, True), ("gc",), ("models_gc",), ("pins", sys.maxsize, True), ("unload",), ("empty", True), ] assert fresh["pinned_bytes"] == 12_345 assert fresh["cache_bytes"] == 54_321 assert fresh["collected_objects"] == 7 try: chain._release_loop_boundary_resources("erase_everything", 2) except ValueError as exc: assert "between_scene_cleanup" in str(exc) else: raise AssertionError("unknown loop cleanup policy was accepted") finally: model_management.free_pins = original_free_pins model_management.unload_all_models = original_unload model_management.soft_empty_cache = original_empty_cache model_management.cleanup_models_gc = original_cleanup_models memory_management.extra_ram_release = original_ram_release chain.gc.collect = original_collect print("loop cleanup: cache-first eviction and model unload passed") fixed_now = datetime(2026, 8, 11, 14, 5, 9) assert chain._expand_filename_date( "render_%date:yyyy-MM-dd%_%hour%-%minute%-%second%", fixed_now ) == "render_2026-08-11_14-05-09" with tempfile.TemporaryDirectory() as version_dir: original = pathlib.Path(version_dir) / "render.mp4" original.touch() (pathlib.Path(version_dir) / "render_001.mp4").touch() assert chain._available_versioned_path(str(original)).endswith( "render_002.mp4" ) print("assemble filenames: date expansion and collision versioning passed") async def review_route_check(): token = "review-route-smoke" future = asyncio.get_running_loop().create_future() review_plan = package.NODE_CLASS_MAPPINGS[ "MiniMaxH3ChainPlan"]().build( json.dumps({ "prompt_prefix": "Shared only.", "shots": [{ "id": "review_route", "prompt": "Original scene prompt.", "length": 22, "steps": 8, "seed": "7", }], }), "review_route_smoke", "", 64, 64, 22, "video", "head", "disabled", "generated_audio", 22, 1.0, 8, 0, 18, 0, "guide")[0] chain._PENDING_REVIEWS[token] = { "future": future, "loop": asyncio.get_running_loop(), "public": { "token": token, "prompt_prefix": "Shared only.", "clip_index": 1, }, "plan": review_plan, "current_seed": 7, "current_length": 22, } class Request: async def json(self): return { "token": token, "action": "retry", "scene_prompt": "", "seed": "18446744073709551615", } try: response = await chain._submit_review_decision(Request()) assert response.status == 200 decision = await future assert decision["action"] == "retry" assert decision["scene_prompt"] == "" assert decision["seed"] == 18446744073709551615 finally: chain._PENDING_REVIEWS.pop(token, None) asyncio.run(review_route_check()) print("review: async decision route preserves exact uint64 seeds") required = { "MiniMaxH3ChainPlan", "MiniMaxH3ChainScenePromptEditor", "MiniMaxH3ChainRunManager", "MiniMaxH3ChainFirstSceneImage", "MiniMaxH3ReferenceVideoPrepare", "MiniMaxH3ScheduledPictureReference", "MiniMaxH3ScheduledVideoReference", "MiniMaxH3ScheduledAudioReference", "MiniMaxH3ScheduledReferenceToVideo", "MiniMaxH3TaggedSceneOptions", "MiniMaxH3CurrentTaggedScenePack", "MiniMaxH3CurrentTaggedReferenceScene", "MiniMaxH3SceneDataExtract", "MiniMaxH3ChainExternalVideo", "MiniMaxH3ChainLoopStart", "MiniMaxH3ChainCurrent", "MiniMaxH3ChainContext", "MiniMaxH3DriftControlModelPatch", "MiniMaxH3ChainSegmentSave", "MiniMaxH3ChainLoopEnd", "MiniMaxH3ChainManifestLoad", "MiniMaxH3ChainExportPNG", "MiniMaxH3ChainLatentVideoAdapter", "MiniMaxH3ChainAssemble", "MiniMaxH3LoopTrim", "MiniMaxH3ContexLoopSeamProbe", "MiniMaxH3ContexTrimSourceAV", "MiniMaxH3ContexMaskedTarget", "MiniMaxH3ContexMaskGridPreview", "MiniMaxH3ContexMasterAudioMaskedAV", } assert required <= set(package.NODE_CLASS_MAPPINGS) upstream_ids = { "MiniMaxH3MotionContext", "MiniMaxH3MotionContextTrim", "MiniMaxH3MotionContextSaveLatent", "MiniMaxH3MotionContextLoadLatent", "MiniMaxH3SongMaskedAVContext", "MiniMaxH3ContexSongMaskedAVContext", } assert not upstream_ids.intersection(package.NODE_CLASS_MAPPINGS) retired_mask_pack_ids = { "MiniMaxH3TrimSourceAV", "MiniMaxH3PerRowMaskPatch", "MiniMaxH3SetGenerationMask", "MiniMaxH3MaskGridPreview", } assert not retired_mask_pack_ids.intersection(package.NODE_CLASS_MAPPINGS) layout_patch = sys.modules[package.__name__ + ".patch_layout"] payload_patch = sys.modules[package.__name__ + ".patch_payload"] for module in (layout_patch, payload_patch): assert module.MC_KEY == "motion_context_index" assert module.MC_AUDIO_KEY == "motion_context_audio_end_frame" assert layout_patch.PATCH_MARKER == "_h3_motion_context_layout_patch" assert payload_patch.PATCH_MARKER == "_h3_motion_context_payload_patch" print("coexistence: node ids are disjoint and runtime patch ABI is shared") assert package.WEB_DIRECTORY == "./web" assert (ROOT / "web" / "h3_chain_plan_editor.js").is_file() assert (ROOT / "web" / "h3_chain_plan_core.mjs").is_file() assert (ROOT / "web" / "h3_prompt_completion_core.mjs").is_file() assert (ROOT / "web" / "h3_chain_cancel_reroll.js").is_file() assert (ROOT / "web" / "h3_chain_cancel_reroll_core.mjs").is_file() assert (ROOT / "web" / "h3_chain_scene_prompt_editor.js").is_file() assert (ROOT / "web" / "h3_reference_autoconnect.js").is_file() assert (ROOT / "web" / "h3_reference_autoconnect_core.mjs").is_file() assert (ROOT / "web" / "h3_scene_data_extract.js").is_file() assert (ROOT / "web" / "h3_scene_data_core.mjs").is_file() workflow_path = (ROOT / "example_workflows" / "Archive" / "Looping Seamless Chain Global Refs Example - MiniMax H3.json") workflow = json.loads(workflow_path.read_text(encoding="utf-8")) workflow_types = {node.get("type") for node in workflow["nodes"]} assert "MiniMaxH3LoopTrim" in workflow_types assert not upstream_ids.intersection(workflow_types) loop_nodes = [ node for node in workflow["nodes"] if node.get("type") == "MiniMaxH3LoopTrim" or str(node.get("type", "")).startswith("MiniMaxH3Chain") ] assert loop_nodes and all( node.get("properties", {}).get("aux_id") == "ethanfel/ComfyUI-MiniMaxH3-Contex-Loop" for node in loop_nodes) workflow_start = next( node for node in workflow["nodes"] if node.get("type") == "MiniMaxH3ChainLoopStart") assert workflow_start.get("widgets_values") == [1, ""] assert "SEGMENT" not in str(workflow_start.get("title", "")).upper() print("workflow: loop node ids and package metadata use the new namespace") def assert_workflow_links(payload): nodes = {int(node["id"]): node for node in payload["nodes"]} links = {int(link[0]): link for link in payload["links"]} assert len(nodes) == len(payload["nodes"]) assert len(links) == len(payload["links"]) for link_id, link in links.items(): _, origin_id, origin_slot, target_id, target_slot, _ = link origin = nodes[int(origin_id)] target = nodes[int(target_id)] assert link_id in (origin["outputs"][int(origin_slot)].get("links") or []) assert target["inputs"][int(target_slot)].get("link") == link_id for node in nodes.values(): for input_socket in node.get("inputs", []): link_id = input_socket.get("link") assert link_id is None or int(link_id) in links for output_socket in node.get("outputs", []): for link_id in output_socket.get("links") or []: assert int(link_id) in links fl2va_path = (ROOT / "example_workflows" / "Archive" / "Looping Core FL2VA V2 - MiniMax H3.json") fl2va = json.loads(fl2va_path.read_text(encoding="utf-8")) assert_workflow_links(fl2va) fl2va_types = {node.get("type") for node in fl2va["nodes"]} assert { "MiniMaxH3ImageToVideo", "MiniMaxH3ChainScenePromptEditor", "MiniMaxH3ChainReview", } <= fl2va_types assert not any(str(value).startswith("MiniMaxH3Scheduled") for value in fl2va_types) assert "LoadAudio" not in fl2va_types fl_plan_node = next(node for node in fl2va["nodes"] if node.get("type") == "MiniMaxH3ChainPlan") fl_plan = json.loads(fl_plan_node["widgets_values"][0]) assert len(fl_plan["shots"]) == 1 assert fl_plan["shots"][0]["length"] == 124 fl_prompt = "\n".join(fl_plan["shots"][0]["prompt"]) assert fl_prompt.startswith( "How the reference pictures align with the target video") fl_sections = [ "integrated_multimodal_description:", "overall_soundscape:", "non_diegetic_music:", ] assert [fl_prompt.index(value) for value in fl_sections] == sorted( fl_prompt.index(value) for value in fl_sections) assert fl_plan_node["widgets_values"][9] == "generated_audio" fl_conditioner = next(node for node in fl2va["nodes"] if node.get("type") == "MiniMaxH3ImageToVideo") assert {input_socket["name"] for input_socket in fl_conditioner["inputs"] if input_socket.get("link") is not None} >= { "first_frame", "last_frame", "prompt", "width", "height", "length", } fl_assemble = next(node for node in fl2va["nodes"] if node.get("type") == "MiniMaxH3ChainAssemble" and node.get("mode", 0) == 0) assert "%date:yyyy-MM-dd%" in fl_assemble["widgets_values"][1] print("workflow v2: scheduler-free core FL2VA editing/review graph passes") i2va_path = (ROOT / "example_workflows" / "Archive" / "Looping Single Image I2VA 20s V2 - MiniMax H3.json") i2va = json.loads(i2va_path.read_text(encoding="utf-8")) assert_workflow_links(i2va) i2va_types = {node.get("type") for node in i2va["nodes"]} assert { "MiniMaxH3ImageToVideo", "MiniMaxH3ChainFirstSceneImage", "MiniMaxH3ChainScenePromptEditor", "MiniMaxH3ChainReview", } <= i2va_types assert "LoadAudio" not in i2va_types i2va_plan_node = next(node for node in i2va["nodes"] if node.get("type") == "MiniMaxH3ChainPlan") i2va_plan = package.NODE_CLASS_MAPPINGS["MiniMaxH3ChainPlan"]().build( *i2va_plan_node["widgets_values"])[0] assert [shot["raw_frames"] for shot in i2va_plan["shots"]] == [243, 243] assert [shot["delivered_frames"] for shot in i2va_plan["shots"]] == [243, 238] assert i2va_plan["total_delivered_frames"] == 481 i2va_conditioner = next(node for node in i2va["nodes"] if node.get("type") == "MiniMaxH3ImageToVideo") i2va_inputs = {item["name"]: item.get("link") for item in i2va_conditioner["inputs"]} assert i2va_inputs["first_frame"] is not None assert i2va_inputs["last_frame"] is None print("workflow v2: gated two-scene I2VA 20-second graph passes") scheduled_path = (ROOT / "example_workflows" / "Archive" / "Looping Seamless Chain V2 - Scheduled Refs - MiniMax H3.json") scheduled = json.loads(scheduled_path.read_text(encoding="utf-8")) assert_workflow_links(scheduled) scheduled_types = {node.get("type") for node in scheduled["nodes"]} assert { "MiniMaxH3ScheduledPictureReference", "MiniMaxH3ScheduledVideoReference", "MiniMaxH3ScheduledAudioReference", "MiniMaxH3ScheduledReferenceToVideo", "MiniMaxH3ReferenceVideoPrepare", "MiniMaxH3ChainScenePromptEditor", "MiniMaxH3ChainReview", } <= scheduled_types assert "MiniMaxH3ReferenceToVideo" not in scheduled_types scheduled_plan_node = next( node for node in scheduled["nodes"] if node.get("type") == "MiniMaxH3ChainPlan") scheduled_plan = json.loads(scheduled_plan_node["widgets_values"][0]) assert len(scheduled_plan["shots"]) == 14 for index, shot in enumerate(scheduled_plan["shots"], start=1): text = "\n".join(shot["prompt"]) assert "@hero_look" in text and "@song" in text assert any(line.startswith("@song is ") for line in shot["prompt"]) assert ("@hero_face" in text) == (index <= 7) assert ("@performance" in text) == (4 <= index <= 6) assert (any(line.startswith("@performance provides ") for line in shot["prompt"]) == (4 <= index <= 6)) assert "" not in text and "" not in text assert "