Moxie-Multimedia / tests /test_minimax_node.py
turtle89431's picture
Upload folder using huggingface_hub (part 2)
3c2af96 verified
Raw History Blame Contribute Delete
214 kB
import importlib.util
from io import BytesIO
import json
import sys
import types
from fractions import Fraction
from pathlib import Path
import pytest
import torch
import torch.nn.functional as F
class _Port:
def __init__(self, name=None, **kwargs):
self.name = name
self.kwargs = kwargs
class _PortType:
@staticmethod
def Input(name, **kwargs):
return _Port(name, **kwargs)
@staticmethod
def Output(name=None, **kwargs):
return _Port(name, **kwargs)
class _DynamicCombo(_PortType):
@staticmethod
def Option(name, inputs):
return name, inputs
class _Autogrow:
Type = dict
class TemplatePrefix:
def __init__(self, input, prefix, min=1, max=10):
self.input = input
self.prefix = prefix
self.min = min
self.max = max
@staticmethod
def Input(name, **kwargs):
return _Port(name, **kwargs)
class _NodeOutput:
def __init__(self, *values, expand=None, **kwargs):
self.values = values
self.expand = expand
self.kwargs = kwargs
class _Schema:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class _NestedTensor:
is_nested = True
def __init__(self, values):
self.values = tuple(values)
self.tensors = self.values
def unbind(self):
return self.values
def _h3_context_latent(value: int | float = 0, video_steps: int = 7):
video = torch.full((1, 24, video_steps, 2, 2), float(value))
pixel_frames = sum((1, 4, 4, 4, 4)[index % 5] for index in range(video_steps))
audio_steps = round(pixel_frames * 5 / 3)
audio = torch.full((1, 32, 2, audio_steps), float(value) + 100)
return {"samples": _NestedTensor((video, audio))}
def _empty_h3_context_latent(video_steps: int = 7):
video = torch.zeros(1, 24, video_steps, 2, 2)
pixel_frames = sum((1, 4, 4, 4, 4)[index % 5] for index in range(video_steps))
audio_steps = round(pixel_frames * 5 / 3)
audio = torch.zeros(1, 32, 2, audio_steps)
return {"samples": _NestedTensor((video, audio))}
def _h3_conditioning_cache_dir(tmp_path):
return tmp_path / "easy_media" / "h3_conditioning_cache"
def _h3_video_anchor_latent(value: int | float = 0):
return {"samples": torch.full((1, 24, 2, 2, 2), float(value))}
class _ProgressBar:
instances = []
def __init__(self, total):
self.total = total
self.updates = []
self.instances.append(self)
def update_absolute(self, value, total=None, preview=None):
self.updates.append((value, total))
class _Clip:
def __init__(self):
self.tokenize_calls = []
def tokenize(self, prompt, **kwargs):
tokens = {"prompt": prompt, "kwargs": kwargs}
self.tokenize_calls.append(tokens)
return tokens
def encode_from_tokens_scheduled(self, tokens):
return [(torch.tensor([1.0]), {"tokens": tokens})]
class _Vae:
def __init__(self):
self.encoded = []
def encode(self, value):
self.encoded.append(value)
temporal = 2 if value.shape[0] > 1 else 1
return torch.zeros(1, 24, temporal, 2, 2)
class _AudioVae:
audio_sample_rate = 32000
def __init__(self):
self.encoded = []
def encode(self, value):
self.encoded.append(value)
return torch.zeros(1, 32, 2, 4)
class _ImageResizeKJWithNvidia:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"width": ("INT", {"default": 512}),
"height": ("INT", {"default": 512}),
"upscale_method": (["nvidia_rtx_vsr", "lanczos"],),
"keep_proportion": (["stretch", "resize"], {"default": "stretch"}),
"pad_color": ("STRING", {"default": "0, 0, 0"}),
"crop_position": (["center"], {"default": "center"}),
"divisible_by": ("INT", {"default": 2}),
}
}
class _MiniMaxLatentUpscaler:
pass
class _MiniMaxMotionContextTrim:
pass
class MiniMaxH3:
unet_config = {"image_model": "minimax_h3"}
class _MiniMaxH3Model:
def __init__(self):
self.model = types.SimpleNamespace(model_config=MiniMaxH3())
def _load_minimax_node(monkeypatch):
io = types.SimpleNamespace(
Audio=_PortType,
AnyType=_PortType,
Autogrow=_Autogrow,
Boolean=_PortType,
Clip=_PortType,
Combo=_PortType,
ComfyNode=object,
Conditioning=_PortType,
ControlAfterGenerate=types.SimpleNamespace(fixed="fixed"),
DynamicCombo=_DynamicCombo,
Hidden=types.SimpleNamespace(prompt="PROMPT", unique_id="UNIQUE_ID"),
Custom=lambda **kwargs: _PortType,
Float=_PortType,
Guider=_PortType,
Image=_PortType,
Int=_PortType,
Latent=_PortType,
Model=_PortType,
NodeOutput=_NodeOutput,
Noise=_PortType,
Sampler=_PortType,
Schema=_Schema,
Sigmas=_PortType,
String=_PortType,
Vae=_PortType,
Video=_PortType,
)
comfy_api = types.ModuleType("comfy_api")
comfy_api_latest = types.ModuleType("comfy_api.latest")
comfy_api_latest.io = io
comfy_api_latest.InputImpl = types.SimpleNamespace(
VideoFromFile=lambda path: types.SimpleNamespace(path=path)
)
comfy_api_latest.Types = types.SimpleNamespace()
comfy_api.latest = comfy_api_latest
core_nodes = types.ModuleType("nodes")
core_nodes.MAX_RESOLUTION = 16384
core_nodes.NODE_CLASS_MAPPINGS = {}
comfy = types.ModuleType("comfy")
comfy.__path__ = []
model_management = types.ModuleType("comfy.model_management")
model_management.intermediate_device = lambda: torch.device("cpu")
model_management.get_torch_device = lambda: torch.device("cpu")
nested_tensor = types.ModuleType("comfy.nested_tensor")
nested_tensor.NestedTensor = _NestedTensor
comfy_utils = types.ModuleType("comfy.utils")
comfy_utils.common_upscale = (
lambda samples, width, height, method, crop: F.interpolate(
samples, size=(height, width), mode="nearest"
)
)
_ProgressBar.instances.clear()
comfy_utils.ProgressBar = _ProgressBar
def save_torch_file(state, path, metadata=None):
torch.save({"state": state, "metadata": metadata}, path)
def load_torch_file(
path,
safe_load=False,
device=None,
return_metadata=False,
):
del safe_load
stored = torch.load(
BytesIO(Path(path).read_bytes()),
map_location=device or torch.device("cpu"),
weights_only=True,
)
if isinstance(stored, dict) and set(stored) == {"state", "metadata"}:
state = stored["state"]
metadata = stored["metadata"]
else:
state = stored
metadata = None
return (state, metadata) if return_metadata else state
comfy_utils.save_torch_file = save_torch_file
# The fixture writes torch archives; avoid suffix-based safetensors detection.
comfy_utils.load_torch_file = load_torch_file
comfy.model_management = model_management
comfy.nested_tensor = nested_tensor
comfy.utils = comfy_utils
node_helpers = types.ModuleType("node_helpers")
def conditioning_set_values(conditioning, values):
return [(tensor, {**metadata, **values}) for tensor, metadata in conditioning]
node_helpers.conditioning_set_values = conditioning_set_values
torchaudio = types.ModuleType("torchaudio")
torchaudio.functional = types.SimpleNamespace(
resample=lambda waveform, source, target: waveform
)
folder_paths = types.ModuleType("folder_paths")
folder_paths.get_filename_list = lambda category: []
folder_paths.get_output_directory = lambda: "/tmp"
folder_paths.get_temp_directory = lambda: "/tmp"
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(
instance=types.SimpleNamespace(send_sync=lambda _event, _payload: None)
)
package = types.ModuleType("easy_media")
package.__path__ = []
nodes_package = types.ModuleType("easy_media.nodes")
nodes_package.__path__ = []
basic_module = types.ModuleType("easy_media.nodes.basic")
def prepare_multitrack_project_media(info):
has_locked_audio = any(
isinstance(track, dict) and track.get("audio_locked") is True
for track in info.get("tracks", [])
)
locked_audio = {"prepared_locked_audio": True} if has_locked_audio else None
return info, [], [], [], locked_audio
basic_module.prepare_multitrack_project_media = prepare_multitrack_project_media
basic_module.crop_multitrack_project_media = (
lambda shared_audio, shared_video, locked_audio, *_args: (
shared_audio,
shared_video,
locked_audio,
)
)
project_modules_package = types.ModuleType("easy_media.modules")
project_modules_package.__path__ = []
motion_context_package = types.ModuleType("easy_media.modules.motion_context")
motion_context_package.__path__ = []
utils_package = types.ModuleType("easy_media.utils")
utils_package.__path__ = [str(Path(__file__).parents[1] / "utils")]
utils_package.log_node_info = lambda *_args, **_kwargs: None
log_spec = importlib.util.spec_from_file_location(
"stage_log_under_test", Path(__file__).parents[1] / "utils" / "log.py"
)
log_module = importlib.util.module_from_spec(log_spec)
log_spec.loader.exec_module(log_module)
utils_package.log_stage_time = log_module.log_stage_time
utils_package.instrument_node_timing = log_module.instrument_node_timing
utils_package.synchronize_execution_device = log_module.synchronize_execution_device
utils_package.save_audio_to_temp_wav = lambda _audio: None
utils_package.FFMPEG_RESIZE_METHODS = frozenset()
for name in (
"audio_db_to_gain",
"audio_volume_db",
"equirectangular_to_perspective",
"load_audio_waveform",
"load_image_tensor",
"multitrack_is_shared_reference",
"multitrack_is_muted_image",
"multitrack_media_identity",
"multitrack_segments_in_window",
"multitrack_slot_name",
"resize_image",
"resize_video_with_ffmpeg",
"resolve_video_path",
):
setattr(utils_package, name, lambda *_args, **_kwargs: None)
utils_package.audio_is_muted = (
lambda settings: isinstance(settings, dict) and settings.get("muted") is True
)
models_module = types.ModuleType("easy_media.utils.models")
models_module.detect_turbo_model = lambda model: types.SimpleNamespace(
is_turbo=False,
as_dict=lambda: {
"status": "unknown",
"is_turbo": False,
"source": "fallback",
"evidence": "test detector",
"patch_count": 0,
}
)
models_module.detect_turbo_lora_from_prompt = lambda prompt, node_id: None
root = Path(__file__).parents[1]
h3_presets_spec = importlib.util.spec_from_file_location(
"easy_media.utils.h3_presets", root / "utils" / "h3_presets.py"
)
assert h3_presets_spec is not None and h3_presets_spec.loader is not None
h3_presets_module = importlib.util.module_from_spec(h3_presets_spec)
h3_presets_spec.loader.exec_module(h3_presets_module)
monkeypatch.setitem(sys.modules, "comfy_api", comfy_api)
monkeypatch.setitem(sys.modules, "comfy_api.latest", comfy_api_latest)
monkeypatch.setitem(sys.modules, "folder_paths", folder_paths)
monkeypatch.setitem(sys.modules, "easy_media.utils", utils_package)
audio_gain_module = types.ModuleType("easy_media.utils.audio_gain")
audio_gain_module.audio_db_to_gain = utils_package.audio_db_to_gain
audio_gain_module.audio_is_muted = utils_package.audio_is_muted
audio_gain_module.audio_volume_db = utils_package.audio_volume_db
panorama_module = types.ModuleType("easy_media.utils.panorama")
panorama_module.equirectangular_to_perspective = (
utils_package.equirectangular_to_perspective
)
monkeypatch.setitem(
sys.modules,
"easy_media.utils.audio_gain",
audio_gain_module,
)
monkeypatch.setitem(
sys.modules,
"easy_media.utils.panorama",
panorama_module,
)
multitrack_spec = importlib.util.spec_from_file_location(
"easy_media.utils.multitrack", root / "utils" / "multitrack.py"
)
assert multitrack_spec is not None and multitrack_spec.loader is not None
multitrack_module = importlib.util.module_from_spec(multitrack_spec)
monkeypatch.setitem(
sys.modules,
"easy_media.utils.multitrack",
multitrack_module,
)
multitrack_spec.loader.exec_module(multitrack_module)
h3_project_spec = importlib.util.spec_from_file_location(
"easy_media.utils.h3_project", root / "utils" / "h3_project.py"
)
assert h3_project_spec is not None and h3_project_spec.loader is not None
h3_project_module = importlib.util.module_from_spec(h3_project_spec)
monkeypatch.setitem(
sys.modules,
"easy_media.utils.h3_project",
h3_project_module,
)
h3_project_spec.loader.exec_module(h3_project_module)
h3_project_module.prepare_multitrack_project_media = (
lambda info: basic_module.prepare_multitrack_project_media(info)
)
h3_project_module.crop_multitrack_project_media = (
lambda shared_audio, shared_video, locked_audio, *args: (
basic_module.crop_multitrack_project_media(
shared_audio,
shared_video,
locked_audio,
*args,
)
)
)
utils_spec = importlib.util.spec_from_file_location(
"easy_media.utils.minimax", root / "utils" / "minimax.py"
)
assert utils_spec is not None and utils_spec.loader is not None
utils_module = importlib.util.module_from_spec(utils_spec)
utils_spec.loader.exec_module(utils_module)
motion_context_spec = importlib.util.spec_from_file_location(
"easy_media.modules.motion_context.core",
root / "modules" / "motion_context" / "core.py",
)
assert motion_context_spec is not None and motion_context_spec.loader is not None
motion_context_module = importlib.util.module_from_spec(motion_context_spec)
motion_context_spec.loader.exec_module(motion_context_module)
drift_control_module = types.ModuleType(
"easy_media.modules.motion_context.drift_control_av"
)
drift_control_module.apply_context_swap_drift_control = (
lambda model, target_latent, context_latent, **kwargs: (
model,
target_latent,
int(kwargs.get("context_length", 22)),
)
)
modules = {
"comfy_api": comfy_api,
"comfy_api.latest": comfy_api_latest,
"nodes": core_nodes,
"comfy": comfy,
"comfy.model_management": model_management,
"comfy.nested_tensor": nested_tensor,
"comfy.utils": comfy_utils,
"node_helpers": node_helpers,
"torchaudio": torchaudio,
"folder_paths": folder_paths,
"server": server,
"easy_media": package,
"easy_media.nodes": nodes_package,
"easy_media.nodes.basic": basic_module,
"easy_media.modules": project_modules_package,
"easy_media.modules.motion_context": motion_context_package,
"easy_media.modules.motion_context.core": motion_context_module,
"easy_media.modules.motion_context.drift_control_av": drift_control_module,
"easy_media.utils": utils_package,
"easy_media.utils.multitrack": multitrack_module,
"easy_media.utils.h3_presets": h3_presets_module,
"easy_media.utils.h3_project": h3_project_module,
"easy_media.utils.models": models_module,
"easy_media.utils.minimax": utils_module,
}
for name, module in modules.items():
monkeypatch.setitem(sys.modules, name, module)
monkeypatch.delitem(sys.modules, "comfy_extras.nodes_minimax_h3", raising=False)
# Other test modules install a reduced graph_utils stub globally. Reload the
# real execution package so this module always sees GraphBuilder and
# ExecutionBlocker regardless of test order.
monkeypatch.delitem(sys.modules, "comfy_execution.graph_utils", raising=False)
monkeypatch.delitem(sys.modules, "comfy_execution", raising=False)
path = root / "nodes" / "minimax.py"
try:
spec = importlib.util.spec_from_file_location("easy_media.nodes.minimax", path)
if spec is None or spec.loader is None:
return None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
project_spec = importlib.util.spec_from_file_location(
"easy_media.nodes.project",
root / "nodes" / "project.py",
)
if project_spec is None or project_spec.loader is None:
return None
project_module = importlib.util.module_from_spec(project_spec)
sys.modules[project_spec.name] = project_module
project_spec.loader.exec_module(project_module)
monkeypatch.setattr(project_module, "install_project_memory_cleanup", lambda: None)
module.EasyMultiTrackProject = project_module.EasyMultiTrackProject
module.EasyMultiTrackProjectVideoCombine = (
project_module.EasyMultiTrackProjectVideoCombine
)
module._project_module = project_module
return module
except (FileNotFoundError, ImportError):
raise
def _load_moxie_combine(monkeypatch):
"""Load moxie_combine.py into the stub environment set up above."""
root = Path(__file__).parents[1]
spec = importlib.util.spec_from_file_location(
"easy_media.moxie_combine", root / "moxie_combine.py"
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _image_values(*values):
return torch.tensor(values, dtype=torch.float32).reshape(len(values), 1, 1, 1)
def _base_inputs(**overrides):
inputs = {
"clip": [_Clip()],
"vae": [_Vae()],
"audio_vae": [],
"images": [],
"videos": [],
"audios": [],
"prompt": ["prompt"],
"mode": ["multi_frames"],
"width": [32],
"height": [32],
"length": [5],
"ref_image_size": ["match"],
}
inputs.update(overrides)
return inputs
def _h3_project_inputs(**overrides):
inputs = {
"model_loader": [{
"model": _MiniMaxH3Model(),
"clip": object(),
"vae": object(),
"audio_vae": object(),
}],
"tracks_info": [{
"width": 1344,
"height": 768,
"frame_rate": 24,
"format": "MiniMax",
"tracks": [{
"type": "task",
"segments": [{
"start_frame": 0,
"end_frame": 120,
"content": {
"task_mode": "default",
"continuity_mode": "shot",
"images": [],
"user_prompt": "a cinematic scene",
},
}],
}],
}],
}
inputs.update(overrides)
return inputs
def _h3_sampling_mode(mode, **children):
return [{"sampling_mode": [mode], **children}]
def _graph_node(output, class_type):
return next(
node for node in output.expand.values() if node["class_type"] == class_type
)
def test_module_loads_without_native_minimax_nodes(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
def test_h3_conditioning_cache_round_trips_and_skips_lazy_inputs(
monkeypatch,
tmp_path,
):
module = _load_minimax_node(monkeypatch)
schema_inputs = {
item.name: item
for item in module.EasyH3ConditioningCache.define_schema().inputs
}
assert schema_inputs["conditioning"].kwargs["lazy"] is True
assert schema_inputs["latent"].kwargs["lazy"] is True
monkeypatch.setattr(
module.folder_paths,
"get_temp_directory",
lambda: str(tmp_path),
)
cache_logs = []
monkeypatch.setattr(
module,
"log_node_info",
lambda name, message: cache_logs.append((name, message)),
)
clip = _Clip()
vae = _Vae()
audio_vae = _AudioVae()
model = object()
conditioning = [(
torch.arange(6, dtype=torch.float32).reshape(1, 2, 3),
{
"pooled_output": torch.ones(1, 3),
"minimax_token_tags": torch.tensor([0, 1], dtype=torch.int64),
"minimax_refs": [{
"kind": "video_audio",
"latent_t": 2,
"latent": torch.full((1, 24, 2, 2, 2), 3.0),
"audio_latent": torch.full((1, 32, 2, 4), 4.0),
}],
},
)]
latent = _empty_h3_context_latent(video_steps=2)
first_status = {
"_easy_media_cache_status": {
"project_media": "first-load",
"segment_media": "first-load",
"task_output": "first-load",
}
}
assert module.EasyH3ConditioningCache.check_lazy_status(
project_name="demo",
segment_index=0,
tracks_info=first_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
) == ["conditioning", "latent"]
first = module.EasyH3ConditioningCache.execute(
project_name="demo",
segment_index=0,
tracks_info=first_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
conditioning=conditioning,
latent=latent,
)
cache_path = _h3_conditioning_cache_dir(tmp_path) / "conditioning_0.safetensors"
assert cache_path.is_file()
assert first.values == (conditioning, latent)
stored = torch.load(
BytesIO(cache_path.read_bytes()),
map_location=torch.device("cpu"),
weights_only=True,
)
assert not any(name.startswith("latent.") for name in stored["state"])
cache_metadata = json.loads(
stored["metadata"]["easy_media_h3_conditioning_cache"]
)
assert cache_metadata["schema_version"] == "3"
assert cache_metadata["scope_token"]
assert "file(" in cache_logs[-1][1]
assert "=conditioning(" in cache_logs[-1][1]
assert "+reference-video(" in cache_logs[-1][1]
assert "+reference-audio(" in cache_logs[-1][1]
assert "initial latent" not in cache_logs[-1][1]
hit_status = {
"_easy_media_cache_status": {
"project_media": "cache-hit",
"segment_media": "cache-hit",
"task_output": "cache-hit",
}
}
original_load = module.load_h3_conditioning_cache
load_calls = []
def tracked_load(*args, **kwargs):
load_calls.append(args)
return original_load(*args, **kwargs)
monkeypatch.setattr(module, "load_h3_conditioning_cache", tracked_load)
required = module.EasyH3ConditioningCache.check_lazy_status(
project_name="demo",
segment_index=0,
tracks_info=hit_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
)
assert required == []
restored = module.EasyH3ConditioningCache.execute(
project_name="demo",
segment_index=0,
tracks_info=hit_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
)
assert len(load_calls) == 1
restored_conditioning, restored_latent = restored.values
assert torch.equal(restored_conditioning[0][0], conditioning[0][0])
assert torch.equal(
restored_conditioning[0][1]["minimax_refs"][0]["audio_latent"],
conditioning[0][1]["minimax_refs"][0]["audio_latent"],
)
assert restored_latent["samples"].is_nested is True
assert all(
torch.equal(actual, expected)
for actual, expected in zip(
restored_latent["samples"].unbind(),
latent["samples"].unbind(),
)
)
def test_h3_conditioning_cache_reencodes_when_model_changes(
monkeypatch,
tmp_path,
):
module = _load_minimax_node(monkeypatch)
monkeypatch.setattr(
module.folder_paths,
"get_temp_directory",
lambda: str(tmp_path),
)
clip = _Clip()
vae = _Vae()
audio_vae = _AudioVae()
model = object()
hit_status = {
"_easy_media_cache_status": {
"project_media": "cache-hit",
"segment_media": "cache-hit",
"task_output": "cache-hit",
}
}
module.EasyH3ConditioningCache.execute(
project_name="demo",
segment_index=1,
tracks_info=hit_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
conditioning=[(torch.ones(1), {})],
latent=_empty_h3_context_latent(),
)
replacement_model = object()
required = module.EasyH3ConditioningCache.check_lazy_status(
project_name="demo",
segment_index=1,
tracks_info=hit_status,
task_output_ready="prompt",
model=replacement_model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
)
assert required == ["conditioning", "latent"]
module.EasyH3ConditioningCache.execute(
project_name="demo",
segment_index=1,
tracks_info=hit_status,
task_output_ready="prompt",
model=replacement_model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
conditioning=[(torch.full((1,), 2.0), {})],
latent=_empty_h3_context_latent(),
)
cache_dir = _h3_conditioning_cache_dir(tmp_path)
assert [path.name for path in cache_dir.glob("conditioning_1*.safetensors")] == [
"conditioning_1.safetensors"
]
assert module.EasyH3ConditioningCache.check_lazy_status(
project_name="demo",
segment_index=1,
tracks_info=hit_status,
task_output_ready="prompt",
model=replacement_model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
) == []
def test_h3_conditioning_cache_refuses_nonzero_initial_latent(
monkeypatch,
tmp_path,
):
module = _load_minimax_node(monkeypatch)
cache_path = tmp_path / "conditioning_0.safetensors"
with pytest.raises(ValueError, match="must be zero-filled"):
module.save_h3_conditioning_cache(
[(torch.ones(1), {})],
_h3_context_latent(1),
cache_path,
"encoder",
"scope",
)
assert not cache_path.exists()
def test_h3_conditioning_cache_invalidates_temp_pool_once_per_queue(
monkeypatch,
tmp_path,
):
module = _load_minimax_node(monkeypatch)
monkeypatch.setattr(
module.folder_paths,
"get_temp_directory",
lambda: str(tmp_path),
)
clip = _Clip()
vae = _Vae()
audio_vae = _AudioVae()
model = object()
miss_status = {
"_easy_media_cache_status": {
"project_media": "first-load",
"segment_media": "first-load",
"task_output": "first-load",
}
}
for segment_index in (0, 1):
module.EasyH3ConditioningCache.execute(
project_name="demo",
segment_index=segment_index,
tracks_info=miss_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
conditioning=[(torch.tensor([segment_index]), {})],
latent=_empty_h3_context_latent(),
)
assert sorted(
path.name for path in _h3_conditioning_cache_dir(tmp_path).glob("*.safetensors")
) == ["conditioning_0.safetensors", "conditioning_1.safetensors"]
def test_h3_conditioning_cache_switching_project_resets_temp_pool(
monkeypatch,
tmp_path,
):
module = _load_minimax_node(monkeypatch)
monkeypatch.setattr(
module.folder_paths,
"get_temp_directory",
lambda: str(tmp_path),
)
clip = _Clip()
vae = _Vae()
audio_vae = _AudioVae()
model = object()
hit_status = {
"_easy_media_cache_status": {
"project_media": "cache-hit",
"segment_media": "cache-hit",
"task_output": "cache-hit",
}
}
for project_name, segment_index in (("first", 0), ("second", 1)):
module.EasyH3ConditioningCache.execute(
project_name=project_name,
segment_index=segment_index,
tracks_info=hit_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
conditioning=[(torch.tensor([segment_index]), {})],
latent=_empty_h3_context_latent(),
)
assert [
path.name for path in _h3_conditioning_cache_dir(tmp_path).glob("*.safetensors")
] == ["conditioning_1.safetensors"]
def test_h3_conditioning_cache_keeps_only_five_recent_segments(
monkeypatch,
tmp_path,
):
module = _load_minimax_node(monkeypatch)
monkeypatch.setattr(
module.folder_paths,
"get_temp_directory",
lambda: str(tmp_path),
)
clip = _Clip()
vae = _Vae()
audio_vae = _AudioVae()
model = object()
hit_status = {
"_easy_media_cache_status": {
"project_media": "cache-hit",
"segment_media": "cache-hit",
"task_output": "cache-hit",
}
}
for segment_index in range(6):
module.EasyH3ConditioningCache.execute(
project_name="demo",
segment_index=segment_index,
tracks_info=hit_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
conditioning=[(torch.tensor([segment_index]), {})],
latent=_empty_h3_context_latent(),
)
cache_files = sorted(
path.name for path in _h3_conditioning_cache_dir(tmp_path).glob("*.safetensors")
)
assert len(cache_files) == 5
assert "conditioning_0.safetensors" not in cache_files
def test_h3_conditioning_cache_unavailable_pool_falls_back_to_encoding(
monkeypatch,
tmp_path,
):
module = _load_minimax_node(monkeypatch)
monkeypatch.setattr(
module.folder_paths,
"get_temp_directory",
lambda: str(tmp_path),
)
monkeypatch.setattr(
module,
"prepare_h3_conditioning_cache_pool",
lambda *_args, **_kwargs: None,
)
clip = _Clip()
vae = _Vae()
audio_vae = _AudioVae()
model = object()
hit_status = {
"_easy_media_cache_status": {
"project_media": "cache-hit",
"segment_media": "cache-hit",
"task_output": "cache-hit",
}
}
required = module.EasyH3ConditioningCache.check_lazy_status(
project_name="demo",
segment_index=0,
tracks_info=hit_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
)
assert required == ["conditioning", "latent"]
conditioning = [(torch.ones(1), {})]
latent = _empty_h3_context_latent()
result = module.EasyH3ConditioningCache.execute(
project_name="demo",
segment_index=0,
tracks_info=hit_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
conditioning=conditioning,
latent=latent,
)
assert result.values == (conditioning, latent)
assert not _h3_conditioning_cache_dir(tmp_path).exists()
def test_h3_conditioning_cache_scope_rejects_stale_file_when_clear_fails(
monkeypatch,
tmp_path,
):
module = _load_minimax_node(monkeypatch)
monkeypatch.setattr(
module.folder_paths,
"get_temp_directory",
lambda: str(tmp_path),
)
clip = _Clip()
vae = _Vae()
audio_vae = _AudioVae()
model = object()
hit_status = {
"_easy_media_cache_status": {
"project_media": "cache-hit",
"segment_media": "cache-hit",
"task_output": "cache-hit",
}
}
module.EasyH3ConditioningCache.execute(
project_name="first",
segment_index=0,
tracks_info=hit_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
conditioning=[(torch.ones(1), {})],
latent=_empty_h3_context_latent(),
)
cache_path = _h3_conditioning_cache_dir(tmp_path) / "conditioning_0.safetensors"
assert cache_path.is_file()
cache_module = sys.modules["easy_media.utils.h3_conditioning_cache"]
monkeypatch.setattr(
cache_module,
"_clear_h3_conditioning_cache_files",
lambda _cache_dir: None,
)
required = module.EasyH3ConditioningCache.check_lazy_status(
project_name="second",
segment_index=0,
tracks_info=hit_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
)
assert cache_path.is_file()
assert required == ["conditioning", "latent"]
def test_h3_conditioning_cache_corrupt_body_falls_back_in_same_queue(
monkeypatch,
tmp_path,
):
module = _load_minimax_node(monkeypatch)
monkeypatch.setattr(
module.folder_paths,
"get_temp_directory",
lambda: str(tmp_path),
)
clip = _Clip()
vae = _Vae()
audio_vae = _AudioVae()
model = object()
hit_status = {
"_easy_media_cache_status": {
"project_media": "cache-hit",
"segment_media": "cache-hit",
"task_output": "cache-hit",
}
}
module.EasyH3ConditioningCache.execute(
project_name="demo",
segment_index=0,
tracks_info=hit_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
conditioning=[(torch.ones(1), {})],
latent=_empty_h3_context_latent(),
)
cache_path = _h3_conditioning_cache_dir(tmp_path) / "conditioning_0.safetensors"
monkeypatch.setattr(
module,
"load_h3_conditioning_cache",
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("corrupt body")),
)
required = module.EasyH3ConditioningCache.check_lazy_status(
project_name="demo",
segment_index=0,
tracks_info=hit_status,
task_output_ready="prompt",
model=model,
clip=clip,
vae=vae,
audio_vae=audio_vae,
)
assert required == ["conditioning", "latent"]
assert not cache_path.exists()
def test_fallback_conditioning_nodes_use_native_node_ids(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
assert (
module.MiniMaxH3ImageToVideoFallback.define_schema().node_id
== "MiniMaxH3ImageToVideo"
)
assert (
module.MiniMaxH3ReferenceToVideoFallback.define_schema().node_id
== "MiniMaxH3ReferenceToVideo"
)
def test_missing_native_conditioning_nodes_are_selected_for_registration(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
assert module.get_minimax_h3_fallback_nodes() == [
module.MiniMaxH3ImageToVideoFallback,
module.MiniMaxH3ReferenceToVideoFallback,
]
def test_reference_mode_routes_to_native_node_when_available(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
module.comfy_nodes.NODE_CLASS_MAPPINGS.update(
{
"MiniMaxH3ImageToVideo": object,
"MiniMaxH3ReferenceToVideo": object,
}
)
output = module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(mode=["reference"], images=[_image_values(1)])
)
assert module.get_minimax_h3_fallback_nodes() == []
assert _graph_node(output, module.REFERENCE_BRIDGE_NODE_ID)
def test_missing_native_reference_registers_fallback_and_routes_through_bridge(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
assert module is not None
module.comfy_nodes.NODE_CLASS_MAPPINGS["MiniMaxH3ImageToVideo"] = object
output = module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(mode=["reference"], images=[_image_values(1)])
)
assert module.get_minimax_h3_fallback_nodes() == [
module.MiniMaxH3ReferenceToVideoFallback,
]
assert _graph_node(output, module.REFERENCE_BRIDGE_NODE_ID)
def test_schema_exposes_list_media_inputs_without_image_position(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
schema = module.EasyMiniMaxH3ToVideo.define_schema()
inputs = {port.name: port for port in schema.inputs}
assert schema.node_id == "easy minimaxH3ToVideo"
assert schema.is_input_list is True
assert schema.enable_expand is True
assert list(inputs) == [
"clip",
"vae",
"audio_vae",
"images",
"audios",
"videos",
"prompt",
"mode",
"width",
"height",
"length",
"locked_video_timing_frames",
"ref_image_size",
]
assert inputs["audio_vae"].kwargs["optional"] is True
assert inputs["mode"].kwargs["options"] == [
"reference",
"multi_frames",
"last_frame",
]
assert [output.name for output in schema.outputs] == ["positive", "latent"]
def test_multitrack_h3_project_schema_exposes_pipeline_configuration(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
schema = module.EasyMultiTrackProject.define_schema()
inputs = {port.name: port for port in schema.inputs}
assert schema.node_id == "easy multitrackProject"
assert schema.is_input_list is True
assert schema.enable_expand is True
assert not getattr(schema, "is_output_node", False)
assert schema.not_idempotent is True
assert schema.hidden == ["PROMPT", "UNIQUE_ID"]
assert list(inputs) == [
"tracks_info",
"model_loader",
"model_loader_2nd",
"sampler",
"sampler_2nd",
"sigmas",
"sigmas_2nd",
"project_name",
"project_save",
"segment_start_number",
"segment_count",
"seed",
"sampling_plan",
"sampling_mode",
"1st_pass_only",
"disable_2nd_noise",
"upscale_by",
"upscale_model",
"enabled_tiling",
]
for name in (
"sampler",
"sigmas",
):
assert inputs[name].kwargs["optional"] is True
assert inputs[name].kwargs["raw_link"] is True
assert inputs[name].kwargs["lazy"] is True
assert inputs["model_loader"].kwargs["raw_link"] is True
assert inputs["model_loader"].kwargs["lazy"] is True
assert inputs["project_name"].kwargs["default"] == ""
assert inputs["project_save"].kwargs["options"] == ["new", "override"]
assert inputs["project_save"].kwargs["default"] == "override"
sampling_plan_options = inputs["sampling_plan"].kwargs["options"]
assert sampling_plan_options == [
"custom",
"ultra_light",
"light",
"medium",
"high",
]
assert json.loads(json.dumps(sampling_plan_options)) == sampling_plan_options
assert inputs["sampling_plan"].kwargs["default"] == "light"
sampling_mode_options = inputs["sampling_mode"].kwargs["options"]
assert [name for name, _ in sampling_mode_options] == [
"single",
"dual",
"selflift",
"passthrough",
]
assert sampling_mode_options[0][1] == []
assert sampling_mode_options[1][1] == []
assert sampling_mode_options[3][1] == []
selflift_inputs = {port.name: port for port in sampling_mode_options[2][1]}
assert list(selflift_inputs) == [
"transition_ratio",
"lowres_scale",
]
assert selflift_inputs["transition_ratio"].kwargs["default"] == 0.6
assert selflift_inputs["lowres_scale"].kwargs["default"] == 0.6
for name in ("sampler_2nd", "sigmas_2nd", "model_loader_2nd"):
assert inputs[name].kwargs["optional"] is True
assert inputs[name].kwargs["raw_link"] is True
assert inputs[name].kwargs["lazy"] is True
assert inputs["1st_pass_only"].kwargs["default"] is False
assert inputs["disable_2nd_noise"].kwargs["default"] is False
assert inputs["upscale_by"].kwargs["default"] == 1.250
assert inputs["upscale_by"].kwargs["step"] == 0.001
assert inputs["upscale_by"].kwargs["round"] == 0.001
assert inputs["upscale_by"].kwargs["extra_dict"] == {"precision": 3}
assert inputs["segment_start_number"].kwargs["default"] == 1
assert inputs["segment_start_number"].kwargs["min"] == 1
assert inputs["segment_count"].kwargs["default"] == -1
assert [output.name for output in schema.outputs] == [
"PROJECT_NAME",
"LOCKED_AUDIO",
]
def test_h3_project_passthrough_skips_conditioning_and_keeps_context(monkeypatch):
module = _load_minimax_node(monkeypatch)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(sampling_mode=_h3_sampling_mode("passthrough"))
)
nodes = list(result.expand.values())
types = {node["class_type"] for node in nodes}
assert "easy h3PassthroughVideo" in types
assert "easy minimaxH3ToVideo" not in types
assert "easy h3ConditioningCache" not in types
assert "SamplerCustomAdvanced" not in types
assert "easy h3SegmentSamplingStart" not in types
passthrough = _graph_node(result, "easy h3PassthroughVideo")
assert passthrough["inputs"]["frame_count"] == 120
assert passthrough["inputs"]["fps"] == 24.0
artifact = _graph_node(result, "easy h3ProjectArtifact")
context = result.expand[artifact["inputs"]["context_latent"][0]]
assert context["class_type"] == "easy h3MotionContextLatentTrim"
assert context["inputs"]["context_length"] == "22"
def test_h3_project_static_prepare_exposes_media_cache_boundary(monkeypatch):
module = _load_minimax_node(monkeypatch)
static_schema = module._project_module.EasyH3ProjectStaticPrepare.define_schema()
assert static_schema.node_id == "easy h3ProjectStaticPrepare"
assert static_schema.enable_expand is True
def test_multitrack_h3_project_keeps_model_and_media_as_prepare_links(monkeypatch):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs(model_loader=[["loader", 0]])
module.EasyMultiTrackProject.hidden = types.SimpleNamespace(
prompt={
"project-node": {
"inputs": {
"tracks_info": ["multitrack-info", 0],
"model_loader": ["loader", 0],
},
},
},
unique_id="project-node",
)
result = module.EasyMultiTrackProject.execute(**inputs)
media_id, media_node = next(
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3ProjectStaticPrepare"
and node_id.endswith("project_media_prepare")
)
model_id, model_node = next(
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3ProjectStaticPrepare"
and node_id.endswith("project_model_prepare")
)
segment_id, segment_node = next(
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3ProjectStaticPrepare"
and node_id.endswith("segment_static_prepare_0")
)
task_id, task = next(
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy multiTrackTaskOutput"
)
conditioning = _graph_node(result, "easy minimaxH3ToVideo")
assert model_node["inputs"]["model_loader"] == ["loader", 0]
assert "tracks_info" not in model_node["inputs"]
assert media_node["inputs"]["tracks_info"] == ["multitrack-info", 0]
assert "model_loader" not in media_node["inputs"]
assert segment_node["inputs"]["project_static"] == [media_id, 0]
assert task["inputs"]["tracks_info"] == [segment_id, 1]
assert conditioning["inputs"]["clip"] == [model_id, 4]
assert conditioning["inputs"]["images"] == [task_id, 4]
assert "seed" not in model_node["inputs"]
assert "seed" not in media_node["inputs"]
assert "seed" not in segment_node["inputs"]
assert "seed" not in task["inputs"]
assert "seed" not in conditioning["inputs"]
def test_linked_selflift_keeps_segment_order_after_cached_media(monkeypatch):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs(
model_loader=[["loader", 0]],
sampling_mode=_h3_sampling_mode("selflift"),
)
first = inputs["tracks_info"][0]["tracks"][0]["segments"][0]
inputs["tracks_info"][0]["tracks"][0]["segments"].append({
**first,
"start_frame": 120,
"end_frame": 240,
"content": {**first["content"]},
})
result = module.EasyMultiTrackProject.execute(**inputs)
artifact_id = next(
node_id
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3ProjectArtifact"
and node["inputs"]["segment_index"] == 0
)
second_selflift = next(
node
for node_id, node in result.expand.items()
if node["class_type"] == "easy minimaxH3SelfLiftSampler"
and node_id.endswith("selflift_sample_1")
)
second_segment_prepare = next(
node
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3ProjectStaticPrepare"
and node_id.endswith("segment_static_prepare_1")
)
assert second_selflift["inputs"]["previous"] == [artifact_id, 0]
assert second_segment_prepare["inputs"]["previous"] == [artifact_id, 0]
def test_h3_project_static_prepare_materializes_once_then_crops(monkeypatch):
module = _load_minimax_node(monkeypatch)
project_module = module._project_module
loader = {
"model": _MiniMaxH3Model(),
"clip": "clip",
"vae": "vae",
"audio_vae": "audio-vae",
}
prepared = []
shared_image = _image_values(1)
shared_audio = {"waveform": torch.ones(1, 1, 1), "sample_rate": 1}
shared_video = object()
locked_audio = {"waveform": torch.ones(1, 1, 1), "sample_rate": 1}
def prepare(info):
prepared.append(info)
return info, [shared_image], [shared_audio], [shared_video], locked_audio
monkeypatch.setattr(project_module, "prepare_multitrack_project_media", prepare)
monkeypatch.setattr(
project_module,
"crop_multitrack_project_media",
lambda audio, video, locked, *_args: (audio, video, locked),
)
tracks_info = _h3_project_inputs()["tracks_info"]
global_result = project_module.EasyH3ProjectStaticPrepare.execute(
model_loader=loader,
tracks_info=tracks_info,
sampling_plan="custom",
sampler="sampler",
sigmas="sigmas",
)
segment_result = project_module.EasyH3ProjectStaticPrepare.execute(
project_static=global_result.values[0],
task_start_frame=0,
task_duration_frames=120,
generation_mode="reference",
)
restored_global = project_module.EasyH3ProjectStaticPrepare.execute(
tracks_info=tracks_info,
)
restored_segment = project_module.EasyH3ProjectStaticPrepare.execute(
project_static=restored_global.values[0],
task_start_frame=0,
task_duration_frames=120,
generation_mode="reference",
)
def assert_no_container_cycle(value, ancestors=None):
if not isinstance(value, (dict, list, tuple)):
return
ancestors = set() if ancestors is None else ancestors
assert id(value) not in ancestors
nested_ancestors = ancestors | {id(value)}
children = value.values() if isinstance(value, dict) else value
for child in children:
assert_no_container_cycle(child, nested_ancestors)
assert len(prepared) == 1
assert global_result.values[2:7] == (
loader["model"],
loader["model"],
"clip",
"vae",
"audio-vae",
)
assert segment_result.values[1]["_preloaded_media"] == {
"images": [shared_image],
"audio": [shared_audio],
"video": [shared_video],
}
assert segment_result.values[8] is locked_audio
assert restored_global.values[0]["shared_images"] is global_result.values[0]["shared_images"]
assert restored_global.values[0]["shared_audio"] is global_result.values[0]["shared_audio"]
assert restored_global.values[0]["shared_video"] is global_result.values[0]["shared_video"]
assert restored_segment.values[1] is segment_result.values[1]
assert restored_segment.values[8] is locked_audio
assert_no_container_cycle(tracks_info)
def test_multitrack_h3_project_loads_segment_media_from_tracks_info(monkeypatch):
module = _load_minimax_node(monkeypatch)
result = module.EasyMultiTrackProject.execute(**_h3_project_inputs())
task_node = next(
node
for node in result.expand.values()
if node["class_type"] == "easy multiTrackTaskOutput"
)
assert set(task_node["inputs"]) == {
"tracks_info",
"task_index",
"prompt_format",
}
@pytest.mark.parametrize(
"sampling_mode,first_only",
[
("single", False),
("dual", False),
("dual", True),
("selflift", False),
],
)
def test_project_memory_boundaries_follow_artifact_saves(monkeypatch, sampling_mode, first_only):
module = _load_minimax_node(monkeypatch)
installed = []
monkeypatch.setattr(module._project_module, "install_project_memory_cleanup", lambda: installed.append(True))
result = module.EasyMultiTrackProject.execute(**_h3_project_inputs(
sampling_mode=_h3_sampling_mode(sampling_mode, **{"1st_pass_only": [first_only]}),
upscale_by=[1.0],
))
assert installed == [True]
tagged = [node for node in result.expand.values() if "easy_media_segment" in node.get("_meta", {})]
assert tagged
assert all(node["_meta"]["easy_media_segment"] == 0 for node in tagged)
boundaries = [node for node in tagged if node["_meta"].get("easy_media_segment_saved")]
assert len(boundaries) == 1
assert boundaries[0]["class_type"] == "easy h3ProjectArtifact"
assert "video_path" in boundaries[0]["inputs"]
assert "context_latent" in boundaries[0]["inputs"]
assert all(
"easy_media_segment" not in node.get("_meta", {})
for node in result.expand.values()
if node["class_type"] in {
"KSamplerSelect",
"ManualSigmas",
"easy h3ProjectStaticPrepare",
"easy multiTrackTaskOutput",
}
)
conditioning = _graph_node(result, "easy minimaxH3ToVideo")
assert conditioning["_meta"]["easy_media_segment"] == 0
def test_multitrack_h3_project_prepends_shared_media_before_h3_conditioning(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
shared_image = _image_values(1)
shared_audio = {"waveform": torch.ones(1, 1, 1), "sample_rate": 1}
shared_video = object()
inputs = _h3_project_inputs()
inputs["tracks_info"][0]["tracks"][0]["segments"][0]["content"][
"task_mode"
] = "ref"
monkeypatch.setattr(
module._project_module,
"prepare_multitrack_project_media",
lambda info: (
info,
[shared_image],
[shared_audio],
[shared_video],
None,
),
)
monkeypatch.setattr(
module._project_module,
"crop_multitrack_project_media",
lambda shared_audios, shared_videos, locked_audio, *_args: (
shared_audios,
shared_videos,
locked_audio,
),
)
result = module.EasyMultiTrackProject.execute(**inputs)
conditioning = _graph_node(result, "easy minimaxH3ToVideo")
task_node_id, task_node = next(
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy multiTrackTaskOutput"
)
preloaded_media = task_node["inputs"]["tracks_info"]["_preloaded_media"]
assert preloaded_media["images"][0] is shared_image
assert preloaded_media["audio"][0] is shared_audio
assert preloaded_media["video"][0] is shared_video
assert conditioning["inputs"]["images"] == [task_node_id, 4]
assert conditioning["inputs"]["audios"] == [task_node_id, 5]
assert conditioning["inputs"]["videos"] == [task_node_id, 6]
assert not any(name.startswith("shared_") for name in conditioning["inputs"])
assert not any(
node["class_type"] == "easy h3ProjectMediaPreprocessor"
for node in result.expand.values()
)
def test_multitrack_h3_minus_one_defers_each_task_media_until_its_loop(monkeypatch):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs(segment_count=[-1])
inputs["tracks_info"][0]["tracks"][0]["segments"].append({
"start_frame": 120,
"end_frame": 240,
"content": {"user_prompt": "second", "task_mode": "default"},
})
result = module.EasyMultiTrackProject.execute(**inputs)
task_nodes = sorted(
(
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy multiTrackTaskOutput"
),
key=lambda item: item[1]["inputs"]["task_index"],
)
first_artifact_id = next(
node_id
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3ProjectArtifact"
and node["inputs"]["segment_index"] == 0
)
assert "previous" not in task_nodes[0][1]["inputs"]
assert task_nodes[1][1]["inputs"]["previous"] == [first_artifact_id, 0]
def test_multitrack_h3_project_outputs_locked_audio_used_by_generation(monkeypatch):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs()
inputs["tracks_info"][0]["tracks"].append({
"type": "audio",
"audio_locked": True,
"segments": [{
"start_frame": 0,
"end_frame": 120,
"content": {"media_type": "audio"},
}],
})
result = module.EasyMultiTrackProject.execute(**inputs)
audio_lock = _graph_node(result, "easy minimaxH3AudioLock")
align = _graph_node(result, "easy h3LockedAudioDurationAlign")
saved_video = _graph_node(result, "easy saveVideo")
assert result.values[1] == {"prepared_locked_audio": True}
assert audio_lock["inputs"]["audio"] == {"prepared_locked_audio": True}
artifact = _graph_node(result, "easy h3ProjectArtifact")
assert "locked_audio" not in artifact["inputs"]
assert align["inputs"]["fps"] == 24.0
selector = _graph_node(result, "easy h3LockedAudioSelect")
assert selector["inputs"]["locked_audio"] == {"prepared_locked_audio": True}
assert saved_video["inputs"]["input_mode.audio"][0].endswith(
"locked_audio_select_0"
)
assert not any(
node["class_type"] == "easy multiTrackTaskOutput"
and node["inputs"]["task_index"] == -1
for node in result.expand.values()
)
def test_multitrack_h3_project_outputs_none_without_locked_audio(monkeypatch):
module = _load_minimax_node(monkeypatch)
result = module.EasyMultiTrackProject.execute(**_h3_project_inputs())
assert result.values[1] is None
assert not any(
node["class_type"] == "easy h3LockedAudioDurationAlign"
for node in result.expand.values()
)
saved_video = _graph_node(result, "easy saveVideo")
audio_link = saved_video["inputs"]["input_mode.audio"]
assert result.expand[audio_link[0]]["class_type"] == "VAEDecodeAudio"
def test_muted_locked_video_preserves_timing_without_audio_lock(monkeypatch):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs()
info = inputs["tracks_info"][0]
info["tracks"].append({
"type": "video",
"audio_locked": True,
"muted": True,
"segments": [{
"start_frame": 0,
"end_frame": 120,
"content": {"media_type": "video"},
}],
})
result = module.EasyMultiTrackProject.execute(**inputs)
assert not any(
node["class_type"] == "easy minimaxH3AudioLock"
for node in result.expand.values()
)
trim = _graph_node(result, "easy h3ContextMediaTrim")
assert trim["inputs"]["output_frames"] == 120
assert trim["inputs"]["fit_video_duration"] is True
assert trim["inputs"]["pad_audio"] is True
saved_video = _graph_node(result, "easy saveVideo")
audio_link = saved_video["inputs"]["input_mode.audio"]
assert result.expand[audio_link[0]]["class_type"] == "easy h3ContextMediaTrim"
def test_locked_audio_select_falls_back_when_video_has_no_audio(monkeypatch):
module = _load_minimax_node(monkeypatch)
generated = {"waveform": torch.ones(1, 1, 4), "sample_rate": 24}
missing = module.EasyH3LockedAudioSelect.execute(generated, None)
malformed = module.EasyH3LockedAudioSelect.execute(generated, {})
assert missing.values[0] is generated
assert malformed.values[0] is generated
def test_multitrack_h3_project_does_not_log_execution_events_while_expanding(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
messages = []
monkeypatch.setattr(
module._project_module,
"log_node_info",
lambda node_name, message: messages.append((node_name, message)),
)
module.EasyMultiTrackProject.execute(**_h3_project_inputs())
assert messages == [
("MultiTrack Project", "Found 1 segments; processing 1"),
]
def test_h3_segment_execution_markers_log_the_current_segment(monkeypatch):
module = _load_minimax_node(monkeypatch)
messages = []
notifications = []
monkeypatch.setattr(
module,
"log_node_info",
lambda node_name, message: messages.append((node_name, message)),
)
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(
instance=types.SimpleNamespace(
send_sync=lambda event, payload: notifications.append((event, payload))
)
)
monkeypatch.setitem(sys.modules, "server", server)
latent = {"samples": torch.zeros(1)}
sampling_output = module.EasyH3SegmentSamplingStart.execute(
object(), object(), object(), object(), latent, "demo", 3, "first"
)
save_end_output = module.EasyH3SegmentSaveEnd.execute("video.mp4", "demo", 3)
assert sampling_output.values[-1] is latent
assert save_end_output.values == ("video.mp4",)
assert notifications == [
(
"easy_multitrack_project_refresh",
{
"project_name": "demo",
"phase": "before",
"segment_index": 3,
"sampling_pass": "first",
},
),
]
assert len(messages) == 1
assert messages[0][0] == "MultiTrack Project"
assert messages[0][1].startswith("Sampling segment 3 (first):")
assert "sampler_name=None" in messages[0][1]
def test_h3_context_media_trim_removes_head_and_grid_tail_together(monkeypatch):
module = _load_minimax_node(monkeypatch)
images = torch.arange(40, dtype=torch.float32).reshape(40, 1, 1, 1)
audio = {
"waveform": torch.arange(80, dtype=torch.float32).reshape(1, 1, 80),
"sample_rate": 48,
}
result = module.EasyH3ContextMediaTrim.execute(
images,
audio,
trim_frames=5,
output_frames=22,
fps=24.0,
)
output_images, output_audio = result.values
assert output_images[:, 0, 0, 0].tolist() == list(range(5, 27))
assert output_audio["waveform"].flatten().tolist() == list(range(10, 54))
assert output_audio["sample_rate"] == 48
def test_h3_context_media_trim_can_leave_locked_audio_short_for_alignment(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
images = torch.zeros(5, 1, 1, 3)
audio = {
"waveform": torch.arange(8, dtype=torch.float32).reshape(1, 1, 8),
"sample_rate": 2,
}
result = module.EasyH3ContextMediaTrim.execute(
images,
audio,
trim_frames=0,
output_frames=5,
pad_audio=False,
fps=1.0,
)
assert result.values[0].shape[0] == 5
assert result.values[1]["waveform"].shape[-1] == 8
@pytest.mark.parametrize(
("total_frames", "expected_start", "expected_padding"),
[
(22, 0, 0),
(120, 102, 4),
(124, 102, 0),
(153, 136, 5),
(168, 153, 7),
(240, 221, 3),
],
)
def test_h3_context_media_trim_preserves_vae_chunk_phase_for_encoding(
monkeypatch,
total_frames,
expected_start,
expected_padding,
):
module = _load_minimax_node(monkeypatch)
images = torch.arange(total_frames, dtype=torch.float32).reshape(
total_frames, 1, 1, 1
)
audio = {
"waveform": torch.zeros(1, 1, total_frames * 2),
"sample_rate": 48,
}
result = module.EasyH3ContextMediaTrim.execute(
images,
audio,
trim_frames=0,
output_frames=22,
phase_align_video_encode=True,
)
output_images, output_audio = result.values
expected = list(range(expected_start, total_frames))
expected.extend([total_frames - 1] * expected_padding)
assert output_images[:, 0, 0, 0].tolist() == expected
assert len(output_images) == 22
assert output_images[-1].item() == images[-1].item()
assert output_audio is audio
def test_h3_context_media_trim_rejects_empty_phase_aligned_video(monkeypatch):
module = _load_minimax_node(monkeypatch)
audio = {"waveform": torch.zeros(1, 1, 2), "sample_rate": 48}
with pytest.raises(ValueError, match="at least one frame"):
module.EasyH3ContextMediaTrim.execute(
torch.empty(0, 1, 1, 1),
audio,
output_frames=22,
phase_align_video_encode=True,
)
def test_h3_context_trim_embeds_phase_aligned_five_frame_anchor(monkeypatch):
module = _load_minimax_node(monkeypatch)
images = torch.arange(9 * 2 * 2 * 3, dtype=torch.float32).reshape(9, 2, 2, 3)
vae = _Vae()
trimmed = {"samples": "trimmed"}
monkeypatch.setattr(
module,
"trim_motion_context_latent",
lambda _latent, context_length: trimmed.copy(),
)
output = module.EasyH3MotionContextLatentTrim.execute(
{"samples": "full"},
"22",
anchor_images=images,
vae=vae,
).values[0]
assert len(vae.encoded) == 1
expected = images[-1:].expand(5, *images.shape[1:])
assert torch.equal(vae.encoded[0], expected)
assert output["samples"] == "trimmed"
assert output["anchor_samples"].shape[2] == 2
def test_h3_locked_audio_duration_align_matches_decoded_video_without_padding(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
images = torch.zeros(209, 1, 1, 3)
waveform = torch.linspace(-1.0, 1.0, 417_600).reshape(1, 1, -1)
result = module.EasyH3LockedAudioDurationAlign.execute(
images,
{"waveform": waveform, "sample_rate": 48_000},
fps=24.0,
)
aligned = result.values[0]
assert aligned["sample_rate"] == 48_000
assert aligned["waveform"].shape == (1, 1, 418_000)
assert aligned["waveform"][..., 0].item() == pytest.approx(-1.0, abs=1e-4)
assert aligned["waveform"][..., -1].item() == pytest.approx(1.0, abs=1e-4)
def test_h3_locked_audio_duration_align_rejects_more_than_one_audio_latent_hop(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
images = torch.zeros(209, 1, 1, 3)
waveform = torch.zeros(1, 1, 416_000)
with pytest.raises(ValueError, match="mismatch is too large"):
module.EasyH3LockedAudioDurationAlign.execute(
images,
{"waveform": waveform, "sample_rate": 48_000},
fps=24.0,
)
def test_multitrack_h3_project_waits_for_previous_artifact_before_sampling(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs()
first_segment = inputs["tracks_info"][0]["tracks"][0]["segments"][0]
inputs["tracks_info"][0]["tracks"][0]["segments"].append({
**first_segment,
"start_frame": 121,
"end_frame": 241,
"content": {**first_segment["content"]},
})
result = module.EasyMultiTrackProject.execute(**inputs)
sampling_starts = sorted(
(
node
for node in result.expand.values()
if node["class_type"] == "easy h3SegmentSamplingStart"
),
key=lambda node: node["inputs"]["segment_index"],
)
artifacts = sorted(
(
node
for node in result.expand.values()
if node["class_type"] == "easy h3ProjectArtifact"
),
key=lambda node: node["inputs"]["segment_index"],
)
assert "previous" not in sampling_starts[0]["inputs"]
assert sampling_starts[1]["inputs"]["previous"] == artifacts[1]["inputs"][
"previous"
]
def test_h3_project_artifact_schema(monkeypatch):
module = _load_minimax_node(monkeypatch)
artifact_schema = module.EasyH3ProjectArtifact.define_schema()
artifact_inputs = {port.name: port for port in artifact_schema.inputs}
assert list(artifact_inputs) == [
"project_name",
"project_save",
"segment_index",
"context_latent",
"context_latent_low",
"video_path",
"tracks_info",
"continuity_mode",
"sampling_pass",
"seed",
"previous",
"audio",
]
assert artifact_inputs["context_latent_low"].kwargs["optional"] is True
assert artifact_inputs["seed"].kwargs["optional"] is True
assert artifact_inputs["project_save"].kwargs["options"] == [
"new",
"override",
]
assert artifact_inputs["project_save"].kwargs["default"] == "new"
def test_multitrack_h3_project_render_schema_uses_project_data_widget(monkeypatch):
module = _load_minimax_node(monkeypatch)
schema = module.EasyMultiTrackProjectVideoCombine.define_schema()
inputs = {port.name: port for port in schema.inputs}
assert schema.node_id == "easy multitrackProjectVideoCombine"
assert not getattr(schema, "is_output_node", False)
assert inputs["project_name"].kwargs["force_input"] is True
assert "project_data" in inputs
assert schema.hidden == ["UNIQUE_ID"]
assert [output.name for output in schema.outputs] == [
"VIDEO",
"FILENAME_PREFIX",
]
def test_multitrack_h3_project_render_returns_video_and_filename_prefix(monkeypatch):
module = _load_minimax_node(monkeypatch)
compose_calls = []
notifications = []
monkeypatch.setattr(
module._project_module,
"compose_h3_project_video",
lambda project_name, data: compose_calls.append((project_name, data))
or Path("/temp/demo.mp4"),
)
module.EasyMultiTrackProjectVideoCombine.hidden = types.SimpleNamespace(
unique_id="17"
)
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(
instance=types.SimpleNamespace(
send_sync=lambda event, payload: notifications.append((event, payload))
)
)
monkeypatch.setitem(sys.modules, "server", server)
output = module.EasyMultiTrackProjectVideoCombine.execute(
"demo",
json.dumps({"project_name": "demo", "clips": []}),
)
assert output.values[0].path == "/temp/demo.mp4"
assert output.values[1] == "easy_media/projects/demo/out/demo"
assert compose_calls == [("demo", {"project_name": "demo", "clips": []})]
assert notifications == [(
"easy-media.project.selected",
{"node_id": "17", "project_name": "demo"},
)]
def test_project_video_combine_blocks_both_outputs_when_auto_combine_is_disabled(monkeypatch):
module = _load_minimax_node(monkeypatch)
compose = monkeypatch.setattr(
module._project_module,
"compose_h3_project_video",
lambda *_args: pytest.fail("disabled auto combine must not compose video"),
)
del compose
output = module.EasyMultiTrackProjectVideoCombine.execute(
"demo",
{"project_name": "demo", "clips": [], "auto_combine": False},
)
assert type(output.values[0]).__name__ == "ExecutionBlocker"
assert output.values[1] is output.values[0]
def test_moxie_combine_schema_exposes_components_only(monkeypatch):
_load_minimax_node(monkeypatch)
combine = _load_moxie_combine(monkeypatch)
schema = combine.MoxieCombine.define_schema()
inputs = {port.name: port for port in schema.inputs}
assert schema.node_id == "MoxieCombine"
assert schema.display_name == "Moxie Project Video Combine"
assert inputs["project_name"].kwargs["force_input"] is True
assert "project_data" in inputs
assert [output.name for output in schema.outputs] == ["images", "audio", "fps"]
def test_moxie_combine_returns_components_without_prefix(monkeypatch):
module = _load_minimax_node(monkeypatch)
combine = _load_moxie_combine(monkeypatch)
components = types.SimpleNamespace(images="images", audio="audio", frame_rate=24)
video = types.SimpleNamespace(get_components=lambda: components)
calls = []
monkeypatch.setattr(
module.EasyMultiTrackProjectVideoCombine,
"execute",
classmethod(
lambda cls, *args, **kwargs: calls.append(kwargs)
or (video, "easy_media/projects/demo/out/demo")
),
)
output = combine.MoxieCombine.execute("demo", {"project_name": "demo"})
assert output.values == ("images", "audio", 24.0)
assert calls == [{
"project_name": "demo",
"project_data": {"project_name": "demo"},
}]
def test_moxie_combine_blocks_all_outputs_when_auto_combine_is_disabled(monkeypatch):
from comfy_execution.graph_utils import ExecutionBlocker
module = _load_minimax_node(monkeypatch)
combine = _load_moxie_combine(monkeypatch)
blocker = ExecutionBlocker(None)
monkeypatch.setattr(
module.EasyMultiTrackProjectVideoCombine,
"execute",
classmethod(lambda cls, *args, **kwargs: (blocker, blocker)),
)
output = combine.MoxieCombine.execute(
"demo", {"project_name": "demo", "auto_combine": False}
)
assert output.values == (blocker, blocker, blocker)
def test_easy_h3_hard_and_hires_context_schemas_and_wrappers(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert not hasattr(module, "EasyMiniMaxH3MotionContext")
hard_schema = module.EasyMiniMaxH3MotionContextHard.define_schema()
hard_inputs = {port.name: port for port in hard_schema.inputs}
assert hard_schema.node_id == "easy MiniMaxH3MotionContextHard"
assert hard_inputs["context_length"].kwargs["default"] == "22"
assert "audio_context_length" not in hard_inputs
assert [output.name for output in hard_schema.outputs] == [
"conditioning",
"trim_frames",
"latent",
]
motion_context_calls = []
monkeypatch.setattr(
module,
"apply_reencoded_anchor_motion_context",
lambda **kwargs: motion_context_calls.append(kwargs)
or ("conditioned", 22, "hard-latent"),
)
hard_output = module.EasyMiniMaxH3MotionContextHard.execute(
"conditioning",
"vae",
{"samples": "target"},
{"samples": "context"},
)
assert hard_output.values == ("conditioned", 22, "hard-latent")
assert motion_context_calls == [
{
"conditioning": "conditioning",
"vae": "vae",
"latent": {"samples": "target"},
"context_length": "22",
"context_latent": {"samples": "context"},
"anchor_length": "5",
}
]
hires_schema = module.EasyMiniMaxH3HiResContinuity.define_schema()
assert hires_schema.node_id == "easy MiniMaxH3HiResContinuity"
monkeypatch.setattr(
module,
"apply_hires_anchor_continuity",
lambda **_kwargs: ("hires-latent", 22),
)
hires_output = module.EasyMiniMaxH3HiResContinuity.execute(
{"samples": "current"},
{"samples": "previous"},
)
assert hires_output.values == ("hires-latent", 22)
trim_schema = module.EasyH3MotionContextLatentTrim.define_schema()
assert trim_schema.node_id == "easy h3MotionContextLatentTrim"
monkeypatch.setattr(
module,
"trim_motion_context_latent",
lambda latent, context_length: {
"source": latent,
"context_length": context_length,
},
)
trim_output = module.EasyH3MotionContextLatentTrim.execute(
{"samples": "full"},
"22",
)
assert trim_output.values == (
{"source": {"samples": "full"}, "context_length": "22"},
)
def test_easy_h3_hard_and_hires_context_have_chinese_localization():
locale_path = Path(__file__).parents[1] / "locales" / "zh" / "nodeDefs.json"
if not locale_path.exists():
pytest.skip("locales/zh/nodeDefs.json is not bundled in this repository")
node_defs = json.loads(
(Path(__file__).parents[1] / "locales" / "zh" / "nodeDefs.json").read_text()
)
hard_translation = node_defs["easy MiniMaxH3MotionContextHard"]
assert set(hard_translation["inputs"]) == {
"conditioning",
"vae",
"latent",
"context_latent",
"context_length",
"video_transition_steps",
"audio_transition_steps",
"video_anchor_only",
}
assert "easy MiniMaxH3HiResContinuity" in node_defs
assert "easy MiniMaxH3MotionContextAnchor" not in node_defs
assert "easy MiniMaxH3HiResAnchorContinuity" not in node_defs
def test_multitrack_h3_project_has_matching_chinese_localization():
locale_path = Path(__file__).parents[1] / "locales" / "zh" / "nodeDefs.json"
if not locale_path.exists():
pytest.skip("locales/zh/nodeDefs.json is not bundled in this repository")
node_defs = json.loads(
(Path(__file__).parents[1] / "locales" / "zh" / "nodeDefs.json").read_text()
)
translation = node_defs["easy multitrackProject"]
assert translation["display_name"] == "多轨项目"
assert set(translation["inputs"]) == {
"model_loader",
"tracks_info",
"sampler",
"sigmas",
"project_name",
"project_save",
"sampling_plan",
"sampling_mode",
"transition_ratio",
"lowres_scale",
"enabled_tiling",
"tile_count",
"model_loader_2nd",
"1st_pass_only",
"disable_2nd_noise",
"upscale_by",
"upscale_model",
"segment_start_number",
"segment_count",
"seed",
"sampler_2nd",
"sigmas_2nd",
}
assert translation["outputs"] == {
"0": {"name": "项目名称"},
"1": {"name": "锁定音频"},
}
def test_multitrack_h3_project_expands_single_task_sampling_pipeline(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
log_messages = []
monkeypatch.setattr(
module._project_module,
"log_node_info",
lambda node_name, message=None: log_messages.append((node_name, message)),
)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
project_name="demo",
sampling_mode=_h3_sampling_mode("single"),
)
)
nodes_by_type = {node["class_type"]: node for node in result.expand.values()}
assert result.values[0][1] == 0
assert nodes_by_type["KSamplerSelect"]["inputs"]["sampler_name"] == "er_sde"
assert "ManualSigmas" in nodes_by_type
conditioning = nodes_by_type["easy minimaxH3ToVideo"]["inputs"]
assert conditioning["mode"] == "multi_frames"
assert conditioning["ref_image_size"] == "match"
assert (conditioning["width"], conditioning["height"]) == (1344, 768)
assert "SamplerCustomAdvanced" in nodes_by_type
assert "easy h3SegmentSamplingStart" in nodes_by_type
assert (
nodes_by_type["easy h3SegmentSamplingStart"]["inputs"]["project_name"]
== "demo"
)
assert "easy h3SegmentSaveEnd" in nodes_by_type
assert "VAEDecode" in nodes_by_type
assert "VAEDecodeAudio" in nodes_by_type
assert "easy h3SegmentEncodingStart" not in nodes_by_type
assert "easy saveVideo" in nodes_by_type
save_inputs = nodes_by_type["easy saveVideo"]["inputs"]
assert save_inputs["input_mode"] == "images+audio"
assert save_inputs["output_mode"] == "hide&save"
assert "input_mode.images" in save_inputs
assert "input_mode.audio" in save_inputs
assert save_inputs["input_mode.fps"] == 24.0
assert "easy h3ProjectArtifact" in nodes_by_type
save_video_id = next(
node_id
for node_id, node in result.expand.items()
if node["class_type"] == "easy saveVideo"
)
save_end_id = next(
node_id
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3SegmentSaveEnd"
)
assert nodes_by_type["easy h3SegmentSaveEnd"]["inputs"]["video_path"] == [
save_video_id,
1,
]
assert nodes_by_type["easy h3SegmentSaveEnd"]["inputs"]["project_name"] == "demo"
assert nodes_by_type["easy h3ProjectArtifact"]["inputs"]["video_path"] == [
save_end_id,
0,
]
assert nodes_by_type["easy h3ProjectArtifact"]["inputs"]["project_save"] == "new"
assert nodes_by_type["easy h3ProjectArtifact"]["inputs"]["seed"] == 42
assert log_messages == [
("MultiTrack Project", "Found 1 segments; processing 1"),
]
def test_multitrack_project_patches_sampler_when_loader_has_preview_vae(monkeypatch):
module = _load_minimax_node(monkeypatch)
module.comfy_nodes.NODE_CLASS_MAPPINGS["ImageResizeKJv2"] = _ImageResizeKJWithNvidia
module.EasyMultiTrackProject.hidden = types.SimpleNamespace(unique_id="42")
inputs = _h3_project_inputs(sampling_mode=_h3_sampling_mode("dual"))
preview_vae = object()
inputs["model_loader"][0]["preview_vae"] = preview_vae
result = module.EasyMultiTrackProject.execute(**inputs)
preview_samplers = [
node
for node in result.expand.values()
if node["class_type"] == "easy h3SamplingPreviewSampler"
]
assert len(preview_samplers) == 2
assert not any(
node["class_type"] == "SamplerCustomAdvanced"
for node in result.expand.values()
)
assert {node["inputs"]["sampling_pass"] for node in preview_samplers} == {
"first",
"second",
}
assert all(node["inputs"]["preview_vae"] is preview_vae for node in preview_samplers)
assert all(node["inputs"]["preview_node_id"] == "42" for node in preview_samplers)
assert all(node["inputs"]["preview_fps"] == 24.0 for node in preview_samplers)
def test_h3_sampling_preview_sampler_exposes_optional_tiling(monkeypatch):
module = _load_minimax_node(monkeypatch)
schema = module._project_module.EasyH3SamplingPreviewSampler.define_schema()
inputs = {port.name: port for port in schema.inputs}
assert inputs["enabled_tiling"].kwargs["default"] is False
assert inputs["enabled_tiling"].kwargs["optional"] is True
assert inputs["tile_count"].kwargs["default"] == 2
assert inputs["tile_count"].kwargs["min"] == 2
assert inputs["tile_count"].kwargs["max"] == 8
assert inputs["preview_vae"].kwargs["optional"] is True
def test_multitrack_project_tiles_inside_second_pass_sampler(monkeypatch):
module = _load_minimax_node(monkeypatch)
module.comfy_nodes.NODE_CLASS_MAPPINGS["ImageResizeKJv2"] = _ImageResizeKJWithNvidia
inputs = _h3_project_inputs(
sampling_mode=_h3_sampling_mode("dual"),
enabled_tiling=[{"enabled_tiling": ["true"], "tile_count": [4]}],
)
result = module.EasyMultiTrackProject.execute(**inputs)
nodes = list(result.expand.values())
tiled_sampler = next(
node
for node in nodes
if node["class_type"] == "easy h3SamplingPreviewSampler"
)
assert tiled_sampler["inputs"]["enabled_tiling"] is True
assert tiled_sampler["inputs"]["tile_count"] == 4
assert not any(node["class_type"] == "easy h3TiledModel" for node in nodes)
def test_multitrack_project_adds_preview_inputs_to_selflift_sampler(monkeypatch):
module = _load_minimax_node(monkeypatch)
module.EasyMultiTrackProject.hidden = types.SimpleNamespace(unique_id="84")
inputs = _h3_project_inputs(sampling_mode=_h3_sampling_mode("selflift"))
preview_vae = object()
inputs["model_loader"][0]["preview_vae"] = preview_vae
result = module.EasyMultiTrackProject.execute(**inputs)
sampler = _graph_node(result, "easy minimaxH3SelfLiftSampler")
assert sampler["inputs"]["preview_vae"] is preview_vae
assert sampler["inputs"]["preview_node_id"] == "84"
assert sampler["inputs"]["sampling_pass"] == "selflift"
def test_multitrack_h3_project_reads_ref_image_size_from_each_segment(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
inputs = _h3_project_inputs(sampling_mode=_h3_sampling_mode("single"))
inputs["tracks_info"][0]["tracks"][0]["segments"][0]["content"].update({
"task_mode": "ref",
"ref_image_size": "max",
})
result = module.EasyMultiTrackProject.execute(**inputs)
conditioning = _graph_node(result, "easy minimaxH3ToVideo")["inputs"]
assert conditioning["mode"] == "reference"
assert conditioning["ref_image_size"] == "max"
def test_multitrack_h3_project_logs_steps_and_reports_progress(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
log_messages = []
monkeypatch.setattr(
module._project_module,
"log_node_info",
lambda node_name, message=None: log_messages.append((node_name, message)),
)
module.EasyMultiTrackProject.execute(
**_h3_project_inputs(sampling_mode=_h3_sampling_mode("single"))
)
assert log_messages == [
("MultiTrack Project", "Found 1 segments; processing 1"),
]
progress = _ProgressBar.instances[-1]
assert progress.total == 100
assert progress.updates[0] == (0, 100)
assert progress.updates[-1] == (100, 100)
assert all(
current[0] <= following[0]
for current, following in zip(progress.updates, progress.updates[1:])
)
def test_multitrack_h3_project_locks_task_audio_before_sampling(monkeypatch):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs(sampling_mode=_h3_sampling_mode("single"))
task = inputs["tracks_info"][0]["tracks"][0]["segments"][0]
inputs["tracks_info"][0]["tracks"].append({
"id": "locked-audio-track",
"type": "audio",
"audio_locked": True,
"segments": [{
"id": "locked-audio",
"start_frame": task["start_frame"],
"end_frame": task["end_frame"],
"content": {"media_type": "audio"},
}],
})
result = module.EasyMultiTrackProject.execute(**inputs)
nodes = result.expand
lock_id, audio_lock = next(
(node_id, node)
for node_id, node in nodes.items()
if node["class_type"] == "easy minimaxH3AudioLock"
)
conditioning_cache_id = next(
node_id
for node_id, node in nodes.items()
if node["class_type"] == "easy h3ConditioningCache"
)
sampling_start = next(
node
for node in nodes.values()
if node["class_type"] == "easy h3SegmentSamplingStart"
)
assert audio_lock["inputs"]["latent"] == [conditioning_cache_id, 1]
assert audio_lock["inputs"]["audio"] == {"prepared_locked_audio": True}
assert audio_lock["inputs"]["remix_strength"] == 1.0
assert audio_lock["inputs"]["prepend_frames"] == 0
assert audio_lock["inputs"]["frame_rate"] == 24.0
assert sampling_start["inputs"]["latent_image"] == [lock_id, 0]
def test_multitrack_h3_fast_dual_non_turbo_uses_preset_sigmas_and_pixel_upscale(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
assert module is not None
module.comfy_nodes.NODE_CLASS_MAPPINGS["ImageResizeKJv2"] = (
_ImageResizeKJWithNvidia
)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
sampling_mode=_h3_sampling_mode("dual", disable_2nd_noise=[True]),
sampling_plan=["light"],
)
)
nodes = list(result.expand.values())
conditioning = next(
node for node in nodes if node["class_type"] == "easy minimaxH3ToVideo"
)
resize = next(node for node in nodes if node["class_type"] == "ImageResizeKJv2")
separate = next(
node for node in nodes if node["class_type"] == "LTXVSeparateAVLatent"
)
encode = next(node for node in nodes if node["class_type"] == "VAEEncode")
concat = next(
node for node in nodes if node["class_type"] == "LTXVConcatAVLatent"
)
samples = [
node for node in nodes if node["class_type"] == "SamplerCustomAdvanced"
]
assert not any(node["class_type"] == "SplitSigmas" for node in nodes)
sigma_schedules = [
node["inputs"]["sigmas"]
for node in nodes
if node["class_type"] == "ManualSigmas"
]
assert len(sigma_schedules) == 2
assert sigma_schedules[0].startswith("1.0000, 0.9901")
assert sigma_schedules[1].startswith("0.6316, 0.4877")
assert (conditioning["inputs"]["width"], conditioning["inputs"]["height"]) == (
1344,
768,
)
assert resize["inputs"]["upscale_method"] == "nvidia_rtx_vsr"
assert (resize["inputs"]["width"], resize["inputs"]["height"]) == (1664, 960)
separate_id = next(
node_id for node_id, node in result.expand.items() if node is separate
)
encode_id = next(
node_id for node_id, node in result.expand.items() if node is encode
)
assert concat["inputs"]["video_latent"] == [encode_id, 0]
assert concat["inputs"]["audio_latent"] == [separate_id, 1]
assert len(samples) == 2
sampler_names = [
node["inputs"]["sampler_name"]
for node in nodes
if node["class_type"] == "KSamplerSelect"
]
assert sampler_names == ["euler", "sa_solver"]
assert any(node["class_type"] == "DisableNoise" for node in nodes)
def test_multitrack_h3_selflift_uses_target_size_and_one_progressive_sample(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
sampling_mode=_h3_sampling_mode("selflift"),
sampling_plan=["medium"],
upscale_by=[1.25],
upscale_model=["None"],
)
)
nodes = list(result.expand.values())
selflift = next(
node
for node in nodes
if node["class_type"] == "easy minimaxH3SelfLiftSampler"
)
artifact = next(
node
for node in nodes
if node["class_type"] == "easy h3ProjectArtifact"
)
conditioning = next(
node for node in nodes if node["class_type"] == "easy minimaxH3ToVideo"
)
sigma_nodes = [node for node in nodes if node["class_type"] == "ManualSigmas"]
assert len(sigma_nodes) == 1
assert sigma_nodes[0]["inputs"]["sigmas"].startswith("1.0000, 0.9956")
assert not any(node["class_type"] == "KSamplerSelect" for node in nodes)
assert not any(node["class_type"] == "SamplerCustomAdvanced" for node in nodes)
assert not any(node["class_type"] == "VAEEncode" for node in nodes)
assert not any(node["class_type"] == "ImageResizeKJv2" for node in nodes)
assert (conditioning["inputs"]["width"], conditioning["inputs"]["height"]) == (
1344,
768,
)
assert selflift["inputs"]["transition_ratio"] == 0.6
assert selflift["inputs"]["lowres_scale"] == 0.6
assert selflift["inputs"]["rho"] == 0.0
assert selflift["inputs"]["w_min"] == 0.25
assert selflift["inputs"]["w_max"] == 0.7
assert selflift["inputs"]["enabled_tiling"] is False
assert "project_name" not in selflift["inputs"]
assert "segment_index" not in selflift["inputs"]
assert "upscale_by" not in selflift["inputs"]
assert selflift["inputs"]["upscaler_model"] == "None"
assert selflift["inputs"]["highres_model"] is selflift["inputs"]["model"]
assert artifact["inputs"]["sampling_pass"] == "single"
assert artifact["inputs"]["tracks_info"]["width"] == 1344
assert artifact["inputs"]["tracks_info"]["height"] == 768
def test_multitrack_h3_selflift_reads_nested_upscale_model(monkeypatch):
module = _load_minimax_node(monkeypatch)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
sampling_mode=_h3_sampling_mode(
"selflift",
upscale_by=[2.5],
upscale_model=["h3_upscale.safetensors"],
),
)
)
selflift = next(
node
for node in result.expand.values()
if node["class_type"] == "easy minimaxH3SelfLiftSampler"
)
assert selflift["inputs"]["lowres_scale"] == 0.6
assert "upscale_by" not in selflift["inputs"]
assert selflift["inputs"]["upscaler_model"] == "h3_upscale.safetensors"
def test_multitrack_h3_selflift_passes_dynamic_sampling_values(monkeypatch):
module = _load_minimax_node(monkeypatch)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
sampling_mode=_h3_sampling_mode(
"selflift",
transition_ratio=[0.75],
lowres_scale=[0.5],
),
enabled_tiling=[
{"enabled_tiling": ["true"], "tile_count": [4]}
],
)
)
selflift = next(
node
for node in result.expand.values()
if node["class_type"] == "easy minimaxH3SelfLiftSampler"
)
assert selflift["inputs"]["transition_ratio"] == 0.75
assert selflift["inputs"]["lowres_scale"] == 0.5
assert selflift["inputs"]["enabled_tiling"] is True
assert selflift["inputs"]["tile_count"] == 4
@pytest.mark.parametrize("continuity_mode", ["context", "context_swap"])
def test_multitrack_h3_selflift_supports_context_and_locked_audio(
monkeypatch,
continuity_mode,
):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs(sampling_mode=_h3_sampling_mode("selflift"))
info = inputs["tracks_info"][0]
info["tracks"][0]["segments"].append(
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "default",
"continuity_mode": continuity_mode,
"images": [],
"user_prompt": "continue seamlessly",
},
}
)
info["tracks"].append(
{
"id": "locked-audio-track",
"type": "audio",
"audio_locked": True,
"segments": [
{
"id": "locked-audio",
"start_frame": 0,
"end_frame": 240,
"content": {"media_type": "audio"},
}
],
}
)
result = module.EasyMultiTrackProject.execute(**inputs)
selflift_nodes = [
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy minimaxH3SelfLiftSampler"
]
assert len(selflift_nodes) == 2
context_selflift = selflift_nodes[1][1]
audio_lock_id = next(
node_id
for node_id, node in result.expand.items()
if node["class_type"] == "easy minimaxH3AudioLock"
and node["inputs"]["prepend_frames"] == 22
)
context_type = (
"easy MiniMaxH3MotionContextHard"
if continuity_mode == "context"
else "easy MiniMaxH3ContextSwap"
)
context_id, context_node = next(
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == context_type
)
assert context_node["inputs"]["latent"] == [audio_lock_id, 0]
latent_output = 2 if continuity_mode == "context" else 1
assert context_selflift["inputs"]["latent_image"] == [context_id, latent_output]
low_context_link = context_selflift["inputs"]["low_context_latent"]
low_context_node = result.expand[low_context_link[0]]
assert low_context_node["class_type"] == "easy h3MotionContextLatentTrim"
if continuity_mode == "context_swap":
assert context_selflift["inputs"]["model"] == [context_id, 0]
def test_selflift_sampler_schema_forces_euler_by_not_exposing_a_sampler(monkeypatch):
module = _load_minimax_node(monkeypatch)
schema = module.EasyMiniMaxH3SelfLiftSampler.define_schema()
inputs = {port.name: port for port in schema.inputs}
assert schema.node_id == "easy minimaxH3SelfLiftSampler"
assert schema.is_dev_only is True
assert "sampler" not in inputs
assert list(inputs)[-2:] == ["previous", "highres_model"]
assert inputs["highres_model"].kwargs["optional"] is True
assert inputs["transition_ratio"].kwargs["default"] == 0.6
assert inputs["lowres_scale"].kwargs["default"] == 0.6
assert inputs["rho"].kwargs["default"] == pytest.approx(0.1)
assert inputs["w_max"].kwargs["default"] == pytest.approx(0.7)
assert inputs["w_min"].kwargs["default"] == pytest.approx(0.25)
assert inputs["enabled_tiling"].kwargs["default"] is False
assert inputs["enabled_tiling"].kwargs["optional"] is True
assert inputs["highres_tiling"].kwargs["default"] is False
assert inputs["highres_tiling"].kwargs["optional"] is True
assert inputs["highres_tiling"].kwargs["advanced"] is True
assert inputs["low_context_latent"].kwargs["optional"] is True
assert "project_name" not in inputs
assert inputs["segment_index"].kwargs["optional"] is True
assert len(schema.outputs) == 2
assert "upscale_by" not in inputs
def test_bundled_h3_latent_upscaler_schema_exposes_three_resize_modes(monkeypatch):
module = _load_minimax_node(monkeypatch)
schema = module.EasyMiniMaxH3LatentUpscaler.define_schema()
inputs = {port.name: port for port in schema.inputs}
modes = inputs["mode"].kwargs["options"]
assert schema.node_id == "easy minimaxH3LatentUpscaler"
assert list(inputs) == [
"latent",
"model_name",
"mode",
"align",
"enable_temporal_chunking",
"force_unload",
]
assert [name for name, _ in modes] == [
"scale by multiplier",
"target dimensions",
"megapixels",
]
assert inputs["align"].kwargs["default"] == 32
assert inputs["enable_temporal_chunking"].kwargs["default"] is True
assert inputs["force_unload"].kwargs["default"] is True
locale_path = Path(__file__).parents[1] / "locales" / "zh" / "nodeDefs.json"
if not locale_path.exists():
pytest.skip("locales/zh/nodeDefs.json is not bundled in this repository")
node_defs = json.loads(
(Path(__file__).parents[1] / "locales" / "zh" / "nodeDefs.json").read_text()
)
assert set(node_defs["easy minimaxH3LatentUpscaler"]["inputs"]) == {
"latent",
"model_name",
"mode",
"scale",
"width",
"height",
"megapixels",
"align",
"enable_temporal_chunking",
"force_unload",
}
def test_bundled_h3_latent_upscaler_preserves_audio_and_resizes_mask(monkeypatch):
module = _load_minimax_node(monkeypatch)
calls = []
upscaler_module = types.ModuleType(
"easy_media.modules.selflift.h3_latent_upscale"
)
def learned_latent_lift(video, target_size, model_name, **kwargs):
calls.append((tuple(video.shape), target_size, model_name, kwargs))
return torch.full(
(*video.shape[:-2], *target_size),
7.0,
dtype=video.dtype,
)
upscaler_module.learned_latent_lift = learned_latent_lift
monkeypatch.setitem(
sys.modules,
"easy_media.modules.selflift.h3_latent_upscale",
upscaler_module,
)
video = torch.zeros(1, 24, 3, 4, 6)
audio = torch.randn(1, 32, 2, 9)
video_mask = torch.ones(1, 1, 3, 4, 6)
audio_mask = torch.zeros(1, 1, 2, 9)
latent = {
"samples": _NestedTensor((video, audio)),
"noise_mask": _NestedTensor((video_mask, audio_mask)),
"batch_index": [0],
}
output = module.EasyMiniMaxH3LatentUpscaler.execute(
latent=latent,
model_name="h3_upscale.safetensors",
mode={"mode": "target dimensions", "width": 192, "height": 128},
align=32,
enable_temporal_chunking=False,
force_unload=False,
).values[0]
upscaled_video, output_audio = output["samples"].unbind()
upscaled_mask, output_audio_mask = output["noise_mask"].unbind()
assert calls == [
(
(1, 24, 3, 4, 6),
(8, 12),
"h3_upscale.safetensors",
{"enable_temporal_chunking": False, "force_unload": False},
)
]
assert upscaled_video.shape == (1, 24, 3, 8, 12)
assert torch.equal(output_audio, audio)
assert upscaled_mask.shape == (1, 1, 3, 8, 12)
assert torch.equal(output_audio_mask, audio_mask)
assert output["batch_index"] == [0]
def test_selflift_sampler_uses_lowres_scale(monkeypatch):
module = _load_minimax_node(monkeypatch)
captured = {}
sampled = {"samples": object()}
low_context = {"samples": object()}
sampling_module = types.ModuleType("easy_media.modules.selflift.sampling")
def progressive_sample_h3(**kwargs):
captured.update(kwargs)
return sampled, low_context
sampling_module.progressive_sample_h3 = progressive_sample_h3
monkeypatch.setitem(
sys.modules,
"easy_media.modules.selflift.sampling",
sampling_module,
)
result = module.EasyMiniMaxH3SelfLiftSampler.execute(
model=object(),
positive=[],
vae=object(),
latent_image={"samples": torch.zeros(1, 24, 1, 60, 104)},
sigmas=torch.tensor([1.0, 0.0]),
seed=42,
transition_ratio=0.6,
lowres_scale=0.6,
)
assert captured["lowres_scale"] == pytest.approx(0.6)
assert captured["rho"] == pytest.approx(0.1)
assert captured["w_min"] == 0.25
assert captured["w_max"] == 0.7
assert captured["highres_tiling"] is False
assert captured["low_context_latent"] is None
assert result.values == (sampled, low_context)
def test_selflift_sampler_forwards_high_resolution_model(monkeypatch):
module = _load_minimax_node(monkeypatch)
captured = {}
sampling_module = types.ModuleType("easy_media.modules.selflift.sampling")
def progressive_sample_h3(**kwargs):
captured.update(kwargs)
return {"samples": object()}, {"samples": object()}
sampling_module.progressive_sample_h3 = progressive_sample_h3
monkeypatch.setitem(sys.modules, "easy_media.modules.selflift.sampling", sampling_module)
first_model = object()
second_model = object()
module.EasyMiniMaxH3SelfLiftSampler.execute(
model=first_model,
highres_model=second_model,
positive=[],
vae=object(),
latent_image={"samples": torch.zeros(1, 24, 1, 60, 104)},
sigmas=torch.tensor([1.0, 0.0]),
seed=42,
)
assert captured["model"] is first_model
assert captured["highres_model"] is second_model
def test_selflift_sampler_forwards_artifact_correction_controls(monkeypatch):
module = _load_minimax_node(monkeypatch)
captured = {}
sampling_module = types.ModuleType("easy_media.modules.selflift.sampling")
def progressive_sample_h3(**kwargs):
captured.update(kwargs)
return {"samples": object()}, {"samples": object()}
sampling_module.progressive_sample_h3 = progressive_sample_h3
monkeypatch.setitem(
sys.modules,
"easy_media.modules.selflift.sampling",
sampling_module,
)
module.EasyMiniMaxH3SelfLiftSampler.execute(
model=object(),
positive=[],
vae=object(),
latent_image={"samples": torch.zeros(1, 24, 1, 60, 104)},
sigmas=torch.tensor([1.0, 0.0]),
seed=42,
rho=0.2,
w_max=0.8,
w_min=0.3,
)
assert captured["rho"] == pytest.approx(0.2)
assert captured["w_max"] == pytest.approx(0.8)
assert captured["w_min"] == pytest.approx(0.3)
def test_selflift_sampler_uses_saved_low_context_for_masked_video(monkeypatch):
module = _load_minimax_node(monkeypatch)
captured = {}
sampled = {"samples": object()}
low_result = {"samples": object()}
sampling_module = types.ModuleType("easy_media.modules.selflift.sampling")
def progressive_sample_h3(**kwargs):
captured.update(kwargs)
return sampled, low_result
sampling_module.progressive_sample_h3 = progressive_sample_h3
monkeypatch.setitem(
sys.modules,
"easy_media.modules.selflift.sampling",
sampling_module,
)
video = torch.zeros(1, 24, 7, 4, 6)
audio = torch.zeros(1, 32, 2, 20)
video_mask = torch.ones(1, 1, 7, 4, 6)
video_mask[:, :, :2] = 0.0
audio_mask = torch.ones(1, 1, 2, 20)
saved_low_context = {
"samples": _NestedTensor(
(torch.ones(1, 24, 2, 3, 4), torch.ones_like(audio))
)
}
result = module.EasyMiniMaxH3SelfLiftSampler.execute(
model=object(),
positive=[],
vae=object(),
latent_image={
"samples": _NestedTensor((video, audio)),
"noise_mask": _NestedTensor((video_mask, audio_mask)),
},
sigmas=torch.tensor([1.0, 0.5, 0.0]),
seed=42,
enabled_tiling=True,
tile_count=4,
low_context_latent=saved_low_context,
)
assert captured["highres_tiling"] is True
assert captured["tile_count"] == 4
assert captured["low_context_latent"] is saved_low_context
assert captured["rho"] == 0.1
assert captured["w_min"] == 0.25
assert captured["w_max"] == 0.7
assert result.values == (sampled, low_result)
def test_selflift_sampler_keeps_audio_only_mask_on_selflift(monkeypatch):
module = _load_minimax_node(monkeypatch)
captured = {}
sampled = {"samples": object()}
low_context = {"samples": object()}
sampling_module = types.ModuleType("easy_media.modules.selflift.sampling")
def progressive_sample_h3(**kwargs):
captured.update(kwargs)
return sampled, low_context
sampling_module.progressive_sample_h3 = progressive_sample_h3
monkeypatch.setitem(
sys.modules,
"easy_media.modules.selflift.sampling",
sampling_module,
)
video = torch.zeros(1, 24, 7, 4, 6)
audio = torch.zeros(1, 32, 2, 20)
video_mask = torch.ones(1, 1, 7, 4, 6)
audio_mask = torch.zeros(1, 1, 2, 20)
result = module.EasyMiniMaxH3SelfLiftSampler.execute(
model=object(),
positive=[],
vae=object(),
latent_image={
"samples": _NestedTensor((video, audio)),
"noise_mask": _NestedTensor((video_mask, audio_mask)),
},
sigmas=torch.tensor([1.0, 0.5, 0.0]),
seed=42,
)
assert captured["rho"] == pytest.approx(0.1)
assert captured["lowres_scale"] == pytest.approx(0.6)
assert result.values == (sampled, low_context)
@pytest.mark.parametrize("upscale_model", ["None", "h3_upscale.safetensors"])
@pytest.mark.parametrize(
("upscale_by", "expected_size"),
[(None, (1664, 960)), ([1.0], (1344, 768)), ([1.234], (1664, 960)), (2.0, (2688, 1536))],
)
def test_multitrack_project_uses_flat_upscale_by(
monkeypatch, tmp_path, upscale_model, upscale_by, expected_size
):
module = _load_minimax_node(monkeypatch)
module.comfy_nodes.NODE_CLASS_MAPPINGS["ImageResizeKJv2"] = _ImageResizeKJWithNvidia
monkeypatch.setattr(module.folder_paths, "get_output_directory", lambda: str(tmp_path))
inputs = _h3_project_inputs(sampling_mode=["dual"], upscale_model=[upscale_model])
if upscale_by is not None:
inputs["upscale_by"] = upscale_by
result = module.EasyMultiTrackProject.execute(**inputs)
conditioning = _graph_node(result, "easy minimaxH3ToVideo")["inputs"]
assert (conditioning["width"], conditioning["height"]) == (1344, 768)
artifact_info = _graph_node(result, "easy h3ProjectArtifact")["inputs"]["tracks_info"]
assert (artifact_info["width"], artifact_info["height"]) == expected_size
manifest = json.loads((tmp_path / "easy_media/projects/default/project.json").read_text())
assert (manifest["width"], manifest["height"]) == expected_size
assert (inputs["tracks_info"][0]["width"], inputs["tracks_info"][0]["height"]) == (1344, 768)
upscale_type = "ImageResizeKJv2" if upscale_model == "None" else "easy minimaxH3LatentUpscaler"
if expected_size == (1344, 768):
assert not any(node["class_type"] == upscale_type for node in result.expand.values())
else:
upscale = _graph_node(result, upscale_type)["inputs"]
prefix = "" if upscale_model == "None" else "mode."
assert (upscale[f"{prefix}width"], upscale[f"{prefix}height"]) == expected_size
conditionings = [node["inputs"] for node in result.expand.values()
if node["class_type"] == "easy minimaxH3ToVideo"]
assert (conditionings[1]["width"], conditionings[1]["height"]) == expected_size
def test_multitrack_reference_dual_reuses_conditioning_for_second_pass(monkeypatch):
module = _load_minimax_node(monkeypatch)
module.comfy_nodes.NODE_CLASS_MAPPINGS["ImageResizeKJv2"] = _ImageResizeKJWithNvidia
inputs = _h3_project_inputs(sampling_mode=_h3_sampling_mode("dual"))
inputs["tracks_info"][0]["tracks"][0]["segments"][0]["content"]["task_mode"] = "ref"
result = module.EasyMultiTrackProject.execute(**inputs)
graph = result.expand
conditioning_nodes = [
node for node in graph.values()
if node["class_type"] == "easy minimaxH3ToVideo"
]
assert len(conditioning_nodes) == 1
assert conditioning_nodes[0]["inputs"]["mode"] == "reference"
cache_id = next(
node_id for node_id, node in graph.items()
if node["class_type"] == "easy h3ConditioningCache"
)
guiders = [
node for node in graph.values()
if node["class_type"] == "BasicGuider"
]
assert len(guiders) == 2
assert all(node["inputs"]["conditioning"] == [cache_id, 0] for node in guiders)
def test_multitrack_h3_project_forwards_override_save_mode(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(project_save=["override"])
)
artifact = _graph_node(result, "easy h3ProjectArtifact")
assert artifact["inputs"]["project_save"] == "override"
def test_multitrack_project_clears_remaining_old_segments_only_for_unlimited_override(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
clear_calls = []
monkeypatch.setattr(
module._project_module,
"clear_h3_project_segments_from",
lambda project_name, start_index, output_directory: clear_calls.append(
(project_name, start_index, output_directory)
) or [],
)
module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
project_save=["new"],
segment_start_number=[1],
segment_count=[-1],
)
)
assert clear_calls == []
module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
project_save=["override"],
segment_start_number=[1],
segment_count=[-1],
)
)
assert clear_calls == [("default", 0, "/tmp")]
clear_calls.clear()
module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
project_save=["override"],
segment_start_number=[1],
segment_count=[1],
)
)
assert clear_calls == []
def test_multitrack_project_rejects_zero_start_number(monkeypatch):
module = _load_minimax_node(monkeypatch)
with pytest.raises(ValueError, match="segment_start_number must be at least 1"):
module.EasyMultiTrackProject.execute(
**_h3_project_inputs(segment_start_number=[0])
)
def test_multitrack_h3_medium_dual_uses_selected_latent_upscale_model(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
assert module is not None
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
sampling_mode=_h3_sampling_mode("dual"),
upscale_model=["h3_upscale.safetensors"],
)
)
nodes = list(result.expand.values())
sampler_names = [
node["inputs"]["sampler_name"]
for node in nodes
if node["class_type"] == "KSamplerSelect"
]
upscale = next(
node
for node in nodes
if node["class_type"] == "easy minimaxH3LatentUpscaler"
)
separate = next(
node for node in nodes if node["class_type"] == "LTXVSeparateAVLatent"
)
concat = next(
node for node in nodes if node["class_type"] == "LTXVConcatAVLatent"
)
assert sampler_names == ["er_sde", "sa_solver"]
assert sum(node["class_type"] == "ManualSigmas" for node in nodes) == 2
assert upscale["inputs"]["model_name"] == "h3_upscale.safetensors"
assert upscale["inputs"]["mode"] == "target dimensions"
assert upscale["inputs"]["align"] == 32
assert upscale["inputs"]["enable_temporal_chunking"] is True
assert upscale["inputs"]["force_unload"] is True
assert (
upscale["inputs"]["mode.width"],
upscale["inputs"]["mode.height"],
) == (
1664,
960,
)
separate_id = next(
node_id for node_id, node in result.expand.items() if node is separate
)
upscale_id = next(
node_id for node_id, node in result.expand.items() if node is upscale
)
assert upscale["inputs"]["latent"] == [separate_id, 0]
assert concat["inputs"]["video_latent"] == [upscale_id, 0]
assert concat["inputs"]["audio_latent"] == [separate_id, 1]
assert not any(
node["class_type"] == "LatentUpscaleModelLoader" for node in nodes
)
assert not any(node["class_type"] == "ImageResizeKJv2" for node in nodes)
def test_multitrack_h3_selected_upscale_model_uses_bundled_node(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
sampling_mode=_h3_sampling_mode("dual"),
upscale_model=["h3_upscale.safetensors"],
)
)
assert _graph_node(result, "easy minimaxH3LatentUpscaler")
def test_multitrack_h3_second_pass_at_one_x_reuses_first_pass_latent(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
sampling_mode=_h3_sampling_mode("dual", upscale_by=[1.0]),
)
)
nodes = list(result.expand.values())
assert sum(node["class_type"] == "SamplerCustomAdvanced" for node in nodes) == 2
assert sum(
node["class_type"] == "easy h3SegmentSamplingStart" for node in nodes
) == 2
assert sum(node["class_type"] == "easy h3SegmentSaveEnd" for node in nodes) == 1
assert not any(node["class_type"] == "ImageResizeKJv2" for node in nodes)
assert not any(
node["class_type"] == "easy minimaxH3LatentUpscaler" for node in nodes
)
def test_multitrack_h3_connected_sampler_and_sigmas_force_custom(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
custom_sampler = object()
custom_sigmas = torch.tensor([1.0, 0.0])
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
sampling_plan=["medium"],
sampler=[custom_sampler],
sigmas=[custom_sigmas],
)
)
nodes = list(result.expand.values())
sampling_start = next(
node
for node in nodes
if node["class_type"] == "easy h3SegmentSamplingStart"
)
assert sampling_start["inputs"]["sampler"] is custom_sampler
assert sampling_start["inputs"]["sigmas"] is custom_sigmas
assert not any(node["class_type"] == "KSamplerSelect" for node in nodes)
assert not any(node["class_type"] == "ManualSigmas" for node in nodes)
def test_multitrack_h3_dynamic_dual_sampler_and_sigmas_override_each_preset(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
assert module is not None
first_pass_sampler = object()
first_pass_sigmas = torch.tensor([1.0, 0.0])
second_pass_sampler = object()
second_pass_sigmas = torch.tensor([0.8, 0.0])
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
sampler=[first_pass_sampler],
sigmas=[first_pass_sigmas],
seed=[12],
sampling_mode=_h3_sampling_mode(
"dual",
sampler_2nd=[second_pass_sampler],
sigmas_2nd=[second_pass_sigmas],
upscale_by=[1.0],
),
)
)
samples = [
node
for node in result.expand.values()
if node["class_type"] == "SamplerCustomAdvanced"
]
noise_seeds = [
node["inputs"]["noise_seed"]
for node in result.expand.values()
if node["class_type"] == "RandomNoise"
]
sampling_starts = [
node
for node in result.expand.values()
if node["class_type"] == "easy h3SegmentSamplingStart"
]
assert sampling_starts[0]["inputs"]["sampler"] is first_pass_sampler
assert sampling_starts[0]["inputs"]["sigmas"] is first_pass_sigmas
assert sampling_starts[1]["inputs"]["sampler"] is second_pass_sampler
assert sampling_starts[1]["inputs"]["sigmas"] is second_pass_sigmas
assert samples[1]["inputs"]["sampler"] == [
next(
node_id
for node_id, node in result.expand.items()
if node is sampling_starts[1]
),
2,
]
assert noise_seeds == [12, 12]
assert not any(node["class_type"] == "KSamplerSelect" for node in result.expand.values())
assert not any(node["class_type"] == "ManualSigmas" for node in result.expand.values())
def test_multitrack_h3_dynamic_dual_model_loader_uses_second_model_for_second_pass(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
assert module is not None
first_model = _MiniMaxH3Model()
second_model = _MiniMaxH3Model()
first_vae = object()
first_audio_vae = object()
second_vae = object()
second_audio_vae = object()
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
model_loader=[
{
"model": first_model,
"clip": object(),
"vae": first_vae,
"audio_vae": first_audio_vae,
}
],
sampling_mode=_h3_sampling_mode(
"dual",
model_loader_2nd=[
{
"model": second_model,
"clip": object(),
"vae": second_vae,
"audio_vae": second_audio_vae,
}
],
upscale_by=[1.0],
),
)
)
guider_models = [
node["inputs"]["model"]
for node in result.expand.values()
if node["class_type"] == "BasicGuider"
]
assert guider_models == [first_model, second_model]
assert _graph_node(result, "VAEDecode")["inputs"]["vae"] is first_vae
assert _graph_node(result, "VAEDecodeAudio")["inputs"]["vae"] is first_audio_vae
def test_multitrack_h3_selflift_uses_second_model_only_for_high_resolution(monkeypatch):
module = _load_minimax_node(monkeypatch)
first_model = _MiniMaxH3Model()
second_model = _MiniMaxH3Model()
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
model_loader=[{
"model": first_model,
"clip": object(),
"vae": object(),
"audio_vae": object(),
}],
sampling_mode=_h3_sampling_mode(
"selflift",
model_loader_2nd=[{"model": second_model}],
),
)
)
selflift = _graph_node(result, "easy minimaxH3SelfLiftSampler")
assert selflift["inputs"]["model"] is first_model
assert selflift["inputs"]["highres_model"] is second_model
def test_linked_selflift_forwards_second_model_loader(monkeypatch):
module = _load_minimax_node(monkeypatch)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
model_loader=[["first-loader", 0]],
sampling_mode=_h3_sampling_mode(
"selflift", model_loader_2nd=[["second-loader", 0]]
),
)
)
model_id, model_prepare = next(
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3ProjectStaticPrepare"
and node_id.endswith("project_model_prepare")
)
selflift = _graph_node(result, "easy minimaxH3SelfLiftSampler")
assert model_prepare["inputs"]["model_loader_2nd"] == ["second-loader", 0]
assert selflift["inputs"]["model"] == [model_id, 2]
assert selflift["inputs"]["highres_model"] == [model_id, 3]
def test_multitrack_h3_rejects_incomplete_custom_sampling(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
with pytest.raises(ValueError, match="both sampler and sigmas"):
module.EasyMultiTrackProject.execute(
**_h3_project_inputs(sampler=[object()])
)
def test_multitrack_h3_first_pass_preview_skips_second_model_loader(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
sampling_mode=_h3_sampling_mode(
"dual",
**{"1st_pass_only": [True]},
model_loader_2nd=[{}],
)
)
)
assert sum(
node["class_type"] == "SamplerCustomAdvanced"
for node in result.expand.values()
) == 1
def test_multitrack_h3_first_pass_preview_only_builds_first_selected_task(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
info = _h3_project_inputs()["tracks_info"][0]
second = {
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "l2v",
"continuity_mode": "context",
"images": [{"media_index": 0}],
"user_prompt": "continue",
},
}
info["tracks"][0]["segments"].append(second)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
tracks_info=[info],
sampling_mode=_h3_sampling_mode("dual", **{"1st_pass_only": [True]}),
sampling_plan=["light"],
)
)
nodes = list(result.expand.values())
assert sum(node["class_type"] == "easy multiTrackTaskOutput" for node in nodes) == 1
assert sum(node["class_type"] == "SamplerCustomAdvanced" for node in nodes) == 1
assert not any(node["class_type"] == "ImageResizeKJv2" for node in nodes)
conditioning = next(
node for node in nodes if node["class_type"] == "easy minimaxH3ToVideo"
)
assert (conditioning["inputs"]["width"], conditioning["inputs"]["height"]) == (
1344,
768,
)
artifact = next(
node for node in nodes if node["class_type"] == "easy h3ProjectArtifact"
)
assert artifact["inputs"]["sampling_pass"] == "first"
first_pass_sample_id = next(
node_id
for node_id, node in result.expand.items()
if node["class_type"] == "SamplerCustomAdvanced"
)
assert artifact["inputs"]["context_latent"] == [first_pass_sample_id, 1]
assert artifact["inputs"]["context_latent_low"] == [first_pass_sample_id, 1]
def test_multitrack_h3_dual_resumes_saved_first_pass_checkpoint(
monkeypatch, tmp_path
):
module = _load_minimax_node(monkeypatch)
assert module is not None
monkeypatch.setattr(
module.folder_paths,
"get_output_directory",
lambda: str(tmp_path),
)
project_dir = tmp_path / "easy_media" / "projects" / "demo"
project_dir.mkdir(parents=True)
(project_dir / "context_latent_0_1.safetensors").write_bytes(b"checkpoint")
(project_dir / "project.json").write_text(
json.dumps({
"segments": {
"0": {
"active_generation": 1,
"generations": {
"1": {
"context_latent": "context_latent_0_1.safetensors",
"sampling_pass": "first",
}
},
}
}
}),
encoding="utf-8",
)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
project_name="demo",
project_save=["override"],
sampling_mode=_h3_sampling_mode("dual", upscale_by=[1.0]),
)
)
nodes = list(result.expand.values())
sampling_starts = [
node
for node in nodes
if node["class_type"] == "easy h3SegmentSamplingStart"
]
assert sum(
node["class_type"] == "SamplerCustomAdvanced" for node in nodes
) == 1
assert [node["inputs"]["sampling_pass"] for node in sampling_starts] == [
"second"
]
checkpoint_load = next(
node
for node in nodes
if node["class_type"] == "easy h3ProjectContextLatentLoad"
)
assert checkpoint_load["inputs"] == {
"project_name": "demo",
"segment_index": 0,
}
artifact = next(
node for node in nodes if node["class_type"] == "easy h3ProjectArtifact"
)
assert artifact["inputs"]["sampling_pass"] == "second"
retained_manifest = json.loads(
(project_dir / "project.json").read_text(encoding="utf-8")
)
assert "0" in retained_manifest["segments"]
def test_multitrack_h3_context_chain_uses_previous_segment_latent(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
module.comfy_nodes.NODE_CLASS_MAPPINGS["MiniMaxH3MotionContextTrim"] = (
_MiniMaxMotionContextTrim
)
info = _h3_project_inputs()["tracks_info"][0]
info["tracks"][0]["segments"].append(
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "l2v",
"continuity_mode": "context",
"images": [{"media_index": 0}],
"user_prompt": "continue",
},
}
)
info["tracks"].append(
{
"id": "locked-audio-track",
"type": "audio",
"audio_locked": True,
"segments": [
{
"id": "locked-audio",
"start_frame": 0,
"end_frame": 240,
"content": {"media_type": "audio"},
}
],
}
)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(tracks_info=[info])
)
nodes = list(result.expand.values())
motion_id, motion = next(
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy MiniMaxH3MotionContextHard"
)
first_context_trim_id = motion["inputs"]["context_latent"][0]
first_context_trim = result.expand[first_context_trim_id]
assert first_context_trim["class_type"] == "easy h3MotionContextLatentTrim"
assert first_context_trim["inputs"]["context_length"] == "22"
first_context_source = result.expand[first_context_trim["inputs"]["latent"][0]]
assert first_context_source["class_type"] == "LTXVConcatAVLatent"
assert motion["inputs"]["context_latent"] == [first_context_trim_id, 0]
assert "audio_context_length" not in motion["inputs"]
assert motion["inputs"]["context_length"] == "22"
audio_locks = [
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy minimaxH3AudioLock"
]
assert [node["inputs"]["prepend_frames"] for _, node in audio_locks] == [0, 22]
context_audio_lock_id = next(
node_id
for node_id, node in audio_locks
if node["inputs"]["prepend_frames"] == 22
)
assert motion["inputs"]["latent"] == [context_audio_lock_id, 0]
math_id, math_node = next(
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "ComfyMathExpression"
)
context_conditioning = next(
node
for node in nodes
if node["class_type"] == "easy minimaxH3ToVideo"
and node["inputs"]["length"] == [math_id, 1]
)
assert math_node["inputs"]["expression"] == "a + 34"
task_length_link = math_node["inputs"]["values.a"]
assert task_length_link == 124
assert "a" not in math_node["inputs"]
assert context_conditioning["inputs"]["length"] == [math_id, 1]
trim = next(
node
for node in nodes
if node["class_type"] == "easy h3ContextMediaTrim"
and node["inputs"]["trim_frames"] == [motion_id, 1]
)
assert trim["inputs"]["trim_frames"] == [motion_id, 1]
assert trim["inputs"]["output_frames"] == 120
assert trim["inputs"]["pad_audio"] is False
assert "fit_video_duration" not in trim["inputs"]
trim_id = next(
node_id for node_id, node in result.expand.items() if node is trim
)
context_align = next(
node
for node in nodes
if node["class_type"] == "easy h3LockedAudioDurationAlign"
and node["inputs"]["audio"] == [trim_id, 1]
)
assert context_align["inputs"]["images"] == [trim_id, 0]
context_video = next(
node for node in nodes
if node["class_type"] == "easy saveVideo"
and node["inputs"]["input_mode.images"] == [trim_id, 0]
)
# The task audio already excludes the context prefix; do not trim it again.
context_selector = result.expand[
context_video["inputs"]["input_mode.audio"][0]
]
assert context_selector["class_type"] == "easy h3LockedAudioSelect"
assert context_selector["inputs"]["locked_audio"] == {
"prepared_locked_audio": True,
}
artifacts = [
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3ProjectArtifact"
]
assert len(artifacts) == 2
assert artifacts[1][1]["inputs"]["previous"] == [artifacts[0][0], 0]
def test_multitrack_h3_context_uses_embedded_five_frame_anchor(monkeypatch):
module = _load_minimax_node(monkeypatch)
info = _h3_project_inputs()["tracks_info"][0]
info["tracks"][0]["segments"].append(
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "default",
"continuity_mode": "context",
"images": [],
"user_prompt": "test direct anchor",
},
}
)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(tracks_info=[info])
)
motion = next(
node
for node in result.expand.values()
if node["class_type"] == "easy MiniMaxH3MotionContextHard"
)
context_id, context_output = motion["inputs"]["context_latent"]
assert context_output == 0
context_trim = result.expand[context_id]
assert context_trim["class_type"] == "easy h3MotionContextLatentTrim"
assert set(context_trim["inputs"]) == {
"latent",
"context_length",
"anchor_images",
"vae",
}
first_artifact = next(
node
for node_id, node in result.expand.items()
if "artifact_0" in node_id and node["class_type"] == "easy h3ProjectArtifact"
)
assert first_artifact["inputs"]["context_latent"] == [context_id, 0]
assert "anchor_latent" not in first_artifact["inputs"]
assert not any(
node["class_type"] in {
"easy h3VideoAnchorEncode",
"easy MiniMaxH3MotionContextAnchor",
}
for node in result.expand.values()
)
def test_multitrack_h3_partial_shot_run_embeds_anchor_in_context_latent(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
info = _h3_project_inputs()["tracks_info"][0]
info["tracks"][0]["segments"].append(
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "default",
"continuity_mode": "context",
"images": [],
"user_prompt": "generate later",
},
}
)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
tracks_info=[info],
segment_start_number=[1],
segment_count=[1],
)
)
context_id, context_trim = next(
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3MotionContextLatentTrim"
)
artifact = next(
node
for node in result.expand.values()
if node["class_type"] == "easy h3ProjectArtifact"
)
assert artifact["inputs"]["context_latent"] == [context_id, 0]
assert "anchor_images" in context_trim["inputs"]
assert "vae" in context_trim["inputs"]
assert "anchor_latent" not in artifact["inputs"]
def test_multitrack_h3_first_context_task_does_not_add_an_empty_prefix(monkeypatch):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs()
info = inputs["tracks_info"][0]
info["tracks"][0]["segments"][0]["content"]["continuity_mode"] = "context"
info["tracks"].append(
{
"type": "audio",
"audio_locked": True,
"segments": [
{
"start_frame": 0,
"end_frame": 120,
"content": {"media_type": "audio"},
}
],
}
)
result = module.EasyMultiTrackProject.execute(**inputs)
nodes = list(result.expand.values())
conditioning = next(
node for node in nodes if node["class_type"] == "easy minimaxH3ToVideo"
)
audio_lock = next(
node for node in nodes if node["class_type"] == "easy minimaxH3AudioLock"
)
assert conditioning["inputs"]["length"] == 124
assert audio_lock["inputs"]["prepend_frames"] == 0
delivery_trim = next(
node for node in nodes
if node["class_type"] == "easy h3ContextMediaTrim"
and not node["inputs"].get("phase_align_video_encode")
)
assert delivery_trim["inputs"]["output_frames"] == 120
assert "fit_video_duration" not in delivery_trim["inputs"]
assert not any(
node["class_type"] in {
"ComfyMathExpression",
"easy MiniMaxH3MotionContextHard",
}
for node in nodes
)
def test_multitrack_h3_dual_context_uses_separate_low_and_hires_latents(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
module.comfy_nodes.NODE_CLASS_MAPPINGS.update(
{
"ImageResizeKJv2": _ImageResizeKJWithNvidia,
"MiniMaxH3MotionContextTrim": _MiniMaxMotionContextTrim,
}
)
info = _h3_project_inputs()["tracks_info"][0]
info["tracks"][0]["segments"].append(
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "l2v",
"continuity_mode": "context",
"images": [{"media_index": 0}],
"user_prompt": "continue",
},
}
)
info["tracks"][0]["segments"].append(
{
"start_frame": 240,
"end_frame": 360,
"content": {
"task_mode": "default",
"continuity_mode": "shot",
"images": [],
"user_prompt": "cut to another shot",
},
}
)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
tracks_info=[info],
sampling_mode=["dual"],
sampling_plan=["light"],
)
)
samples = [
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "SamplerCustomAdvanced"
]
motion = next(
node
for node in result.expand.values()
if node["class_type"] == "easy MiniMaxH3MotionContextHard"
)
low_trim_id, low_trim = next(
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3MotionContextLatentTrim"
and node["inputs"]["latent"] == [samples[0][0], 1]
)
assert motion["inputs"]["context_latent"] == [low_trim_id, 0]
assert low_trim["inputs"]["context_length"] == "22"
hires = next(
node
for node in result.expand.values()
if node["class_type"] == "easy MiniMaxH3HiResContinuity"
)
high_trim_id, high_trim = next(
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3MotionContextLatentTrim"
and node["inputs"]["latent"] == [samples[1][0], 1]
)
assert hires["inputs"]["previous_hires_latent"] == [high_trim_id, 0]
assert high_trim["inputs"]["context_length"] == "22"
motion_id = next(
node_id
for node_id, node in result.expand.items()
if node is motion
)
low_context_conditioning_id = motion["inputs"]["conditioning"][0]
low_context_conditioning = result.expand[low_context_conditioning_id]
encoded_low_conditioning = result.expand[
low_context_conditioning["inputs"]["conditioning"][0]
]
hires_context_conditioning_id = next(
node_id
for node_id, node in result.expand.items()
if node["class_type"] == "easy minimaxH3ToVideo"
and node["inputs"]["prompt"]
== encoded_low_conditioning["inputs"]["prompt"]
and node["inputs"]["width"] == 1664
and node["inputs"]["height"] == 960
)
context_guiders = [
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "BasicGuider"
and tuple(node["inputs"]["conditioning"]) in {
(motion_id, 0),
(hires_context_conditioning_id, 0),
}
]
assert len(context_guiders) == 2
first_context_guider = next(
(node_id, node)
for node_id, node in context_guiders
if node["inputs"]["conditioning"] == [motion_id, 0]
)
second_context_guider = next(
(node_id, node)
for node_id, node in context_guiders
if node["inputs"]["conditioning"] == [hires_context_conditioning_id, 0]
)
second_sampling_start = [
node
for node in result.expand.values()
if node["class_type"] == "easy h3SegmentSamplingStart"
and node["inputs"]["sampling_pass"] == "second"
and node["inputs"]["segment_index"] == 1
][0]
first_second_pass_start = [
node
for node in result.expand.values()
if node["class_type"] == "easy h3SegmentSamplingStart"
and node["inputs"]["sampling_pass"] == "second"
and node["inputs"]["segment_index"] == 0
][0]
final_second_pass_start = [
node
for node in result.expand.values()
if node["class_type"] == "easy h3SegmentSamplingStart"
and node["inputs"]["sampling_pass"] == "second"
and node["inputs"]["segment_index"] == 2
][0]
normal_sigma_node = result.expand[first_second_pass_start["inputs"]["sigmas"][0]]
context_sigma_node = result.expand[second_sampling_start["inputs"]["sigmas"][0]]
assert normal_sigma_node["class_type"] == "ManualSigmas"
assert normal_sigma_node["inputs"]["sigmas"].startswith("0.6316, 0.4877")
assert context_sigma_node["class_type"] == "ManualSigmas"
assert context_sigma_node["inputs"]["sigmas"] == (
"0.50, 0.30, 0.14, 0.06, 0.0"
)
assert final_second_pass_start["inputs"]["sigmas"] == (
first_second_pass_start["inputs"]["sigmas"]
)
assert first_context_guider[1]["inputs"]["conditioning"] == [motion_id, 0]
assert second_sampling_start["inputs"]["guider"] == [
second_context_guider[0],
0,
]
second_segment_artifact = [
node
for node in result.expand.values()
if node["class_type"] == "easy h3ProjectArtifact"
][1]
hires_context_link = second_segment_artifact["inputs"]["context_latent"]
low_context_link = second_segment_artifact["inputs"]["context_latent_low"]
hires_trim = result.expand[hires_context_link[0]]
assert hires_trim["class_type"] == "easy h3MotionContextLatentTrim"
assert result.expand[hires_trim["inputs"]["latent"][0]]["class_type"] == (
"LTXVConcatAVLatent"
)
low_context_trim = result.expand[low_context_link[0]]
assert low_context_trim["class_type"] == "easy h3MotionContextLatentTrim"
assert "anchor_images" not in low_context_trim["inputs"]
assert hires_context_link != low_context_link
context_concat_links = [
hires_trim["inputs"]["latent"],
low_context_trim["inputs"]["latent"],
]
for concat_link in context_concat_links:
concat = result.expand[concat_link[0]]
video_encode = result.expand[concat["inputs"]["video_latent"][0]]
video_trim = result.expand[video_encode["inputs"]["pixels"][0]]
audio_encode = result.expand[concat["inputs"]["audio_latent"][0]]
assert video_encode["class_type"] == "VAEEncode"
assert video_trim["class_type"] == "easy h3ContextMediaTrim"
assert video_trim["inputs"]["output_frames"] == 22
assert video_trim["inputs"]["phase_align_video_encode"] is True
assert audio_encode["class_type"] == "VAEEncodeAudio"
assert audio_encode["inputs"]["audio"] == [
video_encode["inputs"]["pixels"][0],
1,
]
def test_multitrack_h3_context_swap_uses_drift_control_only_in_first_pass(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
module.comfy_nodes.NODE_CLASS_MAPPINGS.update(
{
"ImageResizeKJv2": _ImageResizeKJWithNvidia,
"MiniMaxH3MotionContextTrim": _MiniMaxMotionContextTrim,
}
)
info = _h3_project_inputs()["tracks_info"][0]
info["tracks"][0]["segments"].append(
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "ref",
"continuity_mode": "context_swap",
"images": [{"media_index": 0}],
"user_prompt": "replace the character and preserve motion",
},
}
)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
tracks_info=[info],
sampling_mode=["dual"],
sampling_plan=["light"],
)
)
noise_nodes = [
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy MiniMaxH3ContextSwap"
]
assert len(noise_nodes) == 1
first_noise_id, first_noise = noise_nodes[0]
assert "first_pass_context_swap_noise" in first_noise_id
hires = _graph_node(result, "easy MiniMaxH3HiResContinuity")
assert first_noise["inputs"]["context_length"] == "22"
assert not any(
node["class_type"] == "easy MiniMaxH3MotionContextHard"
for node in result.expand.values()
)
artifacts = [
node
for node in result.expand.values()
if node["class_type"] == "easy h3ProjectArtifact"
]
low_context_id = first_noise["inputs"]["context_latent"][0]
low_context = result.expand[low_context_id]
assert low_context["class_type"] == "easy h3MotionContextLatentTrim"
assert artifacts[0]["inputs"]["context_latent_low"] == [low_context_id, 0]
assert result.expand[low_context["inputs"]["latent"][0]]["class_type"] == (
"SamplerCustomAdvanced"
)
assert hires["inputs"]["previous_hires_latent"] == artifacts[0]["inputs"][
"context_latent"
]
assert artifacts[1]["inputs"]["continuity_mode"] == "context_swap"
assert all(
artifact["inputs"]["context_latent"] != [first_noise_id, 0]
for artifact in artifacts
)
def test_multitrack_h3_connected_second_pass_sampling_overrides_context_preset(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
info = _h3_project_inputs()["tracks_info"][0]
info["tracks"][0]["segments"].append(
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "l2v",
"continuity_mode": "context",
"images": [{"media_index": 0}],
"user_prompt": "continue",
},
}
)
second_pass_sampler = object()
second_pass_sigmas = torch.tensor([0.4, 0.2, 0.0])
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
tracks_info=[info],
sampling_plan=["medium"],
sampling_mode=_h3_sampling_mode(
"dual",
sampler_2nd=[second_pass_sampler],
sigmas_2nd=[second_pass_sigmas],
upscale_by=[1.0],
),
)
)
second_pass_starts = [
node
for node in result.expand.values()
if node["class_type"] == "easy h3SegmentSamplingStart"
and node["inputs"]["sampling_pass"] == "second"
]
assert len(second_pass_starts) == 2
assert all(
node["inputs"]["sampler"] is second_pass_sampler
and node["inputs"]["sigmas"] is second_pass_sigmas
for node in second_pass_starts
)
assert not any(
node["class_type"] == "ManualSigmas"
and node["inputs"]["sigmas"] == "0.50, 0.30, 0.14, 0.06, 0.0"
for node in result.expand.values()
)
def test_multitrack_h3_consecutive_context_uses_previous_trimmed_latent(monkeypatch):
module = _load_minimax_node(monkeypatch)
info = _h3_project_inputs()["tracks_info"][0]
task_track = info["tracks"][0]
task_track["segments"].extend(
[
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "l2v",
"continuity_mode": "context",
"images": [],
"user_prompt": "continue once",
},
},
{
"start_frame": 240,
"end_frame": 360,
"content": {
"task_mode": "l2v",
"continuity_mode": "context",
"images": [],
"user_prompt": "continue twice",
},
},
]
)
info["tracks"].append(
{
"type": "audio",
"audio_locked": True,
"segments": [
{
"start_frame": 0,
"end_frame": 360,
"content": {"media_type": "audio"},
}
],
}
)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(tracks_info=[info])
)
motions = [
node
for node in result.expand.values()
if node["class_type"] == "easy MiniMaxH3MotionContextHard"
]
artifacts = [
node
for node in result.expand.values()
if node["class_type"] == "easy h3ProjectArtifact"
]
assert len(motions) == 2
assert len(artifacts) == 3
first_context_link = artifacts[0]["inputs"]["context_latent"]
first_context_trim = result.expand[first_context_link[0]]
assert first_context_trim["class_type"] == (
"easy h3MotionContextLatentTrim"
)
first_context_source = result.expand[first_context_trim["inputs"]["latent"][0]]
assert first_context_source["class_type"] == "LTXVConcatAVLatent"
assert motions[0]["inputs"]["context_latent"] == first_context_link
second_context_link = artifacts[1]["inputs"]["context_latent"]
assert result.expand[second_context_link[0]]["class_type"] == (
"easy h3MotionContextLatentTrim"
)
assert motions[1]["inputs"]["context_latent"] == second_context_link
second_context_trim = result.expand[second_context_link[0]]
second_context_source = result.expand[second_context_trim["inputs"]["latent"][0]]
assert second_context_source["class_type"] == "LTXVConcatAVLatent"
assert [
node["inputs"]["prepend_frames"]
for node in result.expand.values()
if node["class_type"] == "easy minimaxH3AudioLock"
] == [0, 22, 22]
def test_multitrack_h3_loop_start_loads_previous_project_context(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
project_module = sys.modules["easy_media.nodes.project"]
monkeypatch.setattr(
project_module, "has_h3_context_latent", lambda *args, **kwargs: True
)
module.comfy_nodes.NODE_CLASS_MAPPINGS.update(
{
"MiniMaxH3MotionContextTrim": _MiniMaxMotionContextTrim,
}
)
info = _h3_project_inputs()["tracks_info"][0]
info["tracks"][0]["segments"].append(
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "l2v",
"continuity_mode": "context",
"images": [{"media_index": 0}],
"user_prompt": "continue",
},
}
)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
tracks_info=[info],
segment_start_number=[2],
segment_count=[1],
)
)
nodes = list(result.expand.values())
load = next(
node
for node in nodes
if node["class_type"] == "easy h3ProjectContextLatentLoad"
)
assert load["inputs"]["segment_index"] == 0
assert load["inputs"]["resolution"] == "high"
assert any(
node["class_type"] == "easy MiniMaxH3MotionContextHard" for node in nodes
)
def test_multitrack_h3_context_loop_start_loads_single_saved_context(monkeypatch):
module = _load_minimax_node(monkeypatch)
project_module = sys.modules["easy_media.nodes.project"]
monkeypatch.setattr(
project_module, "has_h3_context_latent", lambda *args, **kwargs: True
)
info = _h3_project_inputs()["tracks_info"][0]
info["tracks"][0]["segments"].append(
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "default",
"continuity_mode": "context",
"images": [],
"user_prompt": "resume direct anchor",
},
}
)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
tracks_info=[info],
segment_start_number=[2],
segment_count=[1],
)
)
loads = {
node["inputs"]["resolution"]: (node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3ProjectContextLatentLoad"
}
assert set(loads) == {"high"}
motion = next(
node
for node in result.expand.values()
if node["class_type"] == "easy MiniMaxH3MotionContextHard"
)
assert motion["inputs"]["context_latent"] == [loads["high"][0], 0]
def test_multitrack_h3_context_swap_loop_start_uses_saved_high_context(monkeypatch):
module = _load_minimax_node(monkeypatch)
project_module = sys.modules["easy_media.nodes.project"]
monkeypatch.setattr(
project_module, "has_h3_context_latent", lambda *args, **kwargs: True
)
info = _h3_project_inputs()["tracks_info"][0]
info["tracks"][0]["segments"].append(
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "default",
"continuity_mode": "context_swap",
"images": [],
"user_prompt": "resume swap",
},
}
)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
tracks_info=[info],
segment_start_number=[2],
segment_count=[1],
)
)
loads = [
(node_id, node)
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3ProjectContextLatentLoad"
]
assert len(loads) == 1
assert loads[0][1]["inputs"]["resolution"] == "high"
swap = next(
node
for node in result.expand.values()
if node["class_type"] == "easy MiniMaxH3ContextSwap"
)
assert swap["inputs"]["context_latent"] == [loads[0][0], 0]
def test_multitrack_h3_context_start_rejects_missing_previous_latent(monkeypatch):
module = _load_minimax_node(monkeypatch)
project_module = sys.modules["easy_media.nodes.project"]
monkeypatch.setattr(
project_module, "has_h3_context_latent", lambda *args, **kwargs: False
)
info = _h3_project_inputs()["tracks_info"][0]
info["tracks"][0]["segments"].append(
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "l2v",
"continuity_mode": "context",
"images": [{"media_index": 0}],
},
}
)
with pytest.raises(ValueError, match="segment 1 has no active context latent"):
module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
tracks_info=[info],
segment_start_number=[2],
segment_count=[1],
)
)
def test_multitrack_h3_selflift_context_start_requires_exact_low_latent(monkeypatch):
module = _load_minimax_node(monkeypatch)
project_module = sys.modules["easy_media.nodes.project"]
checked_resolutions = []
def has_context(*args, **kwargs):
checked_resolutions.append(
(kwargs.get("resolution"), kwargs.get("allow_low_fallback", True))
)
return kwargs.get("resolution") == "high"
monkeypatch.setattr(project_module, "has_h3_context_latent", has_context)
info = _h3_project_inputs()["tracks_info"][0]
info["tracks"][0]["segments"].append(
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "l2v",
"continuity_mode": "context_swap",
"images": [{"media_index": 0}],
},
}
)
with pytest.raises(ValueError, match="no active low-resolution context latent"):
module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
tracks_info=[info],
sampling_mode=_h3_sampling_mode("selflift"),
segment_start_number=[2],
segment_count=[1],
)
)
assert checked_resolutions == [("high", True), ("low", False)]
def test_multitrack_h3_selflift_context_swap_start_uses_both_saved_resolutions(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
project_module = sys.modules["easy_media.nodes.project"]
monkeypatch.setattr(
project_module, "has_h3_context_latent", lambda *args, **kwargs: True
)
info = _h3_project_inputs()["tracks_info"][0]
info["tracks"][0]["segments"].append(
{
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "default",
"continuity_mode": "context_swap",
"images": [],
},
}
)
result = module.EasyMultiTrackProject.execute(
**_h3_project_inputs(
tracks_info=[info],
sampling_mode=_h3_sampling_mode("selflift"),
segment_start_number=[2],
segment_count=[1],
)
)
loads = {
node["inputs"]["resolution"]: node_id
for node_id, node in result.expand.items()
if node["class_type"] == "easy h3ProjectContextLatentLoad"
}
assert set(loads) == {"high", "low"}
swap = _graph_node(result, "easy MiniMaxH3ContextSwap")
selflift = _graph_node(result, "easy minimaxH3SelfLiftSampler")
assert swap["inputs"]["context_latent"] == [loads["high"], 0]
assert selflift["inputs"]["low_context_latent"] == [loads["low"], 0]
def test_multitrack_h3_project_uses_prompt_graph_as_last_turbo_fallback(
monkeypatch, capsys
):
module = _load_minimax_node(monkeypatch)
assert module is not None
prompt = {
"3": {
"class_type": "easy multitrackProject",
"inputs": {"model_loader": ["2", 0]},
},
"2": {
"class_type": "easy modelLoaderPack",
"inputs": {"model": ["1", 0]},
},
"1": {
"class_type": "LoraLoaderModelOnly",
"inputs": {"lora_name": "minimax_h3_turbo_4step.safetensors"},
},
}
graph_result = types.SimpleNamespace(
is_turbo=True,
as_dict=lambda: {
"status": "turbo",
"is_turbo": True,
"source": "graph_prompt",
"evidence": "node 1",
"patch_count": 0,
},
)
graph_calls = []
monkeypatch.setattr(
module._project_module,
"detect_turbo_lora_from_prompt",
lambda received_prompt, node_id: graph_calls.append(
(received_prompt, node_id)
)
or graph_result,
)
module.EasyMultiTrackProject.hidden = types.SimpleNamespace(
prompt=prompt,
unique_id="3",
)
module.EasyMultiTrackProject.execute(**_h3_project_inputs())
assert graph_calls == [(prompt, "3")]
assert graph_calls == [(prompt, "3")]
def test_multitrack_h3_project_skips_prompt_graph_after_model_turbo_match(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
assert module is not None
model_result = types.SimpleNamespace(
is_turbo=True,
as_dict=lambda: {
"status": "turbo",
"is_turbo": True,
"source": "model_patches",
"evidence": "patch match",
"patch_count": 1,
},
)
monkeypatch.setattr(
module._project_module,
"detect_turbo_model",
lambda model: model_result,
)
def fail_graph_fallback(prompt, node_id):
raise AssertionError("graph fallback must not run after a model match")
monkeypatch.setattr(
module._project_module, "detect_turbo_lora_from_prompt", fail_graph_fallback
)
module.EasyMultiTrackProject.execute(**_h3_project_inputs())
def test_multitrack_h3_project_requires_a_model_loader_dictionary(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
with pytest.raises(TypeError, match="FAST_MODEL_LOADER dictionary"):
module.EasyMultiTrackProject.execute(
model_loader=[],
tracks_info=[{}],
)
def test_multitrack_h3_project_requires_core_model_components(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
with pytest.raises(ValueError, match="clip, vae"):
module.EasyMultiTrackProject.execute(
model_loader=[{"model": object()}],
tracks_info=[{}],
)
def test_multitrack_project_rejects_non_minimax_h3_model(monkeypatch):
module = _load_minimax_node(monkeypatch)
with pytest.raises(ValueError, match="supports only MiniMaxH3"):
module.EasyMultiTrackProject.execute(
**_h3_project_inputs(model_loader=[{
"model": object(),
"clip": object(),
"vae": object(),
"audio_vae": object(),
}])
)
def test_h3_project_artifact_writes_manifest_and_rotates_ten_generations(
monkeypatch, tmp_path
):
module = _load_minimax_node(monkeypatch)
assert module is not None
monkeypatch.setattr(
module.folder_paths,
"get_output_directory",
lambda: str(tmp_path),
)
info = _h3_project_inputs()["tracks_info"][0]
project_dir = tmp_path / "easy_media" / "projects" / "demo"
project_dir.mkdir(parents=True)
for run in range(12):
staged = project_dir / f".staged_{run}.mp4"
staged.write_bytes(f"video-{run}".encode())
high_context = _h3_context_latent(run, video_steps=12)
high_context["anchor_samples"] = _h3_video_anchor_latent(run)["samples"]
low_context = _h3_context_latent(-run, video_steps=12)
low_context["anchor_samples"] = _h3_video_anchor_latent(-run)["samples"]
output = module.EasyH3ProjectArtifact.execute(
project_name="demo",
project_save="new",
segment_index=0,
context_latent=high_context,
context_latent_low=low_context,
video_path=f"output/{staged.relative_to(tmp_path)}",
tracks_info=info,
seed=0xFFFFFFFFFFFFFFFF,
)
assert output.values == ("demo",)
assert len(list(project_dir.glob("video_0_*.mp4"))) == 10
assert not list(project_dir.glob("latent_0_*"))
assert len(list(project_dir.glob("context_latent_0_*.safetensors"))) == 10
assert len(list(project_dir.glob("context_latent_low_0_*.safetensors"))) == 10
assert not list(project_dir.glob("anchor_latent_0_*.safetensors"))
assert not list(project_dir.glob("anchor_latent_low_0_*.safetensors"))
manifest = json.loads((project_dir / "project.json").read_text())
assert manifest["version"] == 2
assert manifest["project_name"] == "demo"
assert (manifest["width"], manifest["height"], manifest["fps"]) == (
1344,
768,
24.0,
)
assert "tracks_info" not in manifest
assert manifest["task_segments"] == [
{
"index": 0,
"continuity_mode": "shot",
"task_mode": "default",
"audio_locked": False,
},
]
assert manifest["segments"]["0"]["task_mode"] == "default"
assert len(manifest["segments"]["0"]["generations"]) == 10
loaded = module.EasyH3ProjectContextLatentLoad.execute("demo", 0)
loaded_streams = loaded.values[0]["samples"].unbind()
assert len(loaded_streams) == 2
assert all(isinstance(stream, torch.Tensor) for stream in loaded_streams)
assert loaded_streams[0].shape[2] == 7
assert loaded_streams[1].shape[-1] == 37
assert loaded.values[0]["anchor_samples"].shape == (1, 24, 2, 2, 2)
loaded_low = module.EasyH3ProjectContextLatentLoad.execute(
"demo", 0, resolution="low"
)
loaded_low_streams = loaded_low.values[0]["samples"].unbind()
assert loaded_low_streams[0].shape[2] == 12
assert loaded_low_streams[1].shape[-1] == 65
assert loaded_low.values[0]["anchor_samples"].shape == (1, 24, 2, 2, 2)
active_generation = str(manifest["segments"]["0"]["active_generation"])
active_files = manifest["segments"]["0"]["generations"][active_generation]
assert active_files["context_latent_low"].startswith("context_latent_low_0_")
assert "anchor_latent" not in active_files
assert "anchor_latent_low" not in active_files
assert active_files["continuity_mode"] == "shot"
assert active_files["seed"] == 0xFFFFFFFFFFFFFFFF
def test_h3_project_artifact_notifies_only_after_new_video_is_in_manifest(
monkeypatch, tmp_path
):
module = _load_minimax_node(monkeypatch)
monkeypatch.setattr(
module.folder_paths,
"get_output_directory",
lambda: str(tmp_path),
)
project_dir = tmp_path / "easy_media" / "projects" / "demo"
project_dir.mkdir(parents=True)
staged = project_dir / ".staged.mp4"
staged.write_bytes(b"video")
notifications = []
def send_sync(event, payload):
manifest = json.loads((project_dir / "project.json").read_text())
active_generation = str(
manifest["segments"]["0"]["active_generation"]
)
video_name = manifest["segments"]["0"]["generations"][
active_generation
]["video"]
notifications.append((event, payload, (project_dir / video_name).is_file()))
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(
instance=types.SimpleNamespace(send_sync=send_sync)
)
monkeypatch.setitem(sys.modules, "server", server)
module.EasyH3ProjectArtifact.execute(
project_name="demo",
project_save="new",
segment_index=0,
context_latent=_h3_context_latent(),
video_path=f"output/{staged.relative_to(tmp_path)}",
tracks_info=_h3_project_inputs()["tracks_info"][0],
)
assert notifications == [
(
"easy_multitrack_project_refresh",
{"project_name": "demo", "phase": "after_save", "segment_index": 0},
True,
)
]
def test_h3_project_artifact_preserves_complete_first_pass_checkpoint(
monkeypatch, tmp_path
):
module = _load_minimax_node(monkeypatch)
monkeypatch.setattr(
module.folder_paths,
"get_output_directory",
lambda: str(tmp_path),
)
info = _h3_project_inputs()["tracks_info"][0]
project_dir = tmp_path / "easy_media" / "projects" / "demo"
project_dir.mkdir(parents=True)
staged = project_dir / ".first-pass.mp4"
staged.write_bytes(b"first-pass")
module.EasyH3ProjectArtifact.execute(
project_name="demo",
project_save="new",
segment_index=0,
context_latent=_h3_context_latent(1, video_steps=12),
context_latent_low=_h3_context_latent(1, video_steps=12),
video_path=f"output/{staged.relative_to(tmp_path)}",
tracks_info=info,
sampling_pass="first",
)
high = module.EasyH3ProjectContextLatentLoad.execute("demo", 0).values[0]
low = module.EasyH3ProjectContextLatentLoad.execute(
"demo", 0, resolution="low"
).values[0]
for latent in (high, low):
video, audio = latent["samples"].unbind()
assert video.shape[2] == 12
assert audio.shape[-1] == 65
def test_h3_project_context_round_trip_matches_runtime_context(monkeypatch, tmp_path):
module = _load_minimax_node(monkeypatch)
monkeypatch.setattr(
module.folder_paths,
"get_output_directory",
lambda: str(tmp_path),
)
project_dir = tmp_path / "easy_media" / "projects" / "demo"
project_dir.mkdir(parents=True)
staged = project_dir / ".context-round-trip.mp4"
staged.write_bytes(b"video")
high = module.trim_motion_context_latent(
_h3_context_latent(3, video_steps=12)
)
low = module.trim_motion_context_latent(
_h3_context_latent(7, video_steps=12)
)
high["anchor_samples"] = _h3_video_anchor_latent(3)["samples"]
low["anchor_samples"] = _h3_video_anchor_latent(7)["samples"]
module.EasyH3ProjectArtifact.execute(
project_name="demo",
project_save="new",
segment_index=0,
context_latent=high,
context_latent_low=low,
video_path=f"output/{staged.relative_to(tmp_path)}",
tracks_info=_h3_project_inputs()["tracks_info"][0],
sampling_pass="single",
)
for resolution, runtime in (("high", high), ("low", low)):
loaded = module.EasyH3ProjectContextLatentLoad.execute(
"demo", 0, resolution=resolution
).values[0]
for actual, expected in zip(
loaded["samples"].unbind(), runtime["samples"].unbind()
):
assert torch.equal(actual, expected)
assert torch.equal(loaded["anchor_samples"], runtime["anchor_samples"])
def test_h3_project_artifact_override_reuses_latest_generation(monkeypatch, tmp_path):
module = _load_minimax_node(monkeypatch)
assert module is not None
monkeypatch.setattr(
module.folder_paths,
"get_output_directory",
lambda: str(tmp_path),
)
info = _h3_project_inputs()["tracks_info"][0]
project_dir = tmp_path / "easy_media" / "projects" / "default"
project_dir.mkdir(parents=True)
for run, project_save in enumerate(("new", "new", "override")):
staged = project_dir / f".override_{run}.mp4"
staged.write_bytes(f"video-{run}".encode())
module.EasyH3ProjectArtifact.execute(
project_name="",
project_save=project_save,
segment_index=3,
context_latent=_h3_context_latent(run),
video_path=f"output/{staged.relative_to(tmp_path)}",
tracks_info=info,
)
assert sorted(path.name for path in project_dir.glob("video_3_*.mp4")) == [
"video_3_1.mp4",
"video_3_2.mp4",
]
assert (project_dir / "video_3_1.mp4").read_bytes() == b"video-0"
assert (project_dir / "video_3_2.mp4").read_bytes() == b"video-2"
assert not list(project_dir.glob("latent_3_*"))
assert sorted(
path.name for path in project_dir.glob("context_latent_3_*.safetensors")
) == [
"context_latent_3_1.safetensors",
"context_latent_3_2.safetensors",
]
manifest = json.loads((project_dir / "project.json").read_text())
assert manifest["segments"]["3"]["active_generation"] == 2
assert set(manifest["segments"]["3"]["generations"]) == {"1", "2"}
@pytest.mark.parametrize("project_save", ["new", "override"])
def test_h3_project_artifact_uses_embedded_audio_without_sidecar(
monkeypatch, tmp_path, project_save,
):
module = _load_minimax_node(monkeypatch)
monkeypatch.setattr(
module.folder_paths,
"get_output_directory",
lambda: str(tmp_path),
)
info = _h3_project_inputs()["tracks_info"][0]
project_dir = tmp_path / "easy_media" / "projects" / "demo"
project_dir.mkdir(parents=True)
if project_save == "override":
(project_dir / "locked_audio_0_1.wav").write_bytes(b"legacy-audio")
(project_dir / "project.json").write_text(json.dumps({
"segments": {"0": {"generations": {"1": {
"locked_audio": "locked_audio_0_1.wav",
}}}},
}))
staged = project_dir / ".staged.mp4"
staged.write_bytes(b"video")
module.EasyH3ProjectArtifact.execute(
project_name="demo",
project_save=project_save,
segment_index=0,
context_latent=_h3_context_latent(1),
video_path=f"output/{staged.relative_to(tmp_path)}",
tracks_info=info,
)
manifest = json.loads((project_dir / "project.json").read_text())
generation = manifest["segments"]["0"]["generations"]["1"]
assert "locked_audio" not in generation
assert not list(project_dir.glob("locked_audio_*.wav"))
assert (project_dir / generation["video"]).read_bytes() == b"video"
def test_reference_bridge_uses_fixed_inputs_instead_of_autogrow(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
schema = module.EasyMiniMaxH3ReferenceToVideoBridge.define_schema()
input_names = [port.name for port in schema.inputs]
assert schema.node_id == module.REFERENCE_BRIDGE_NODE_ID
assert "ref_images" not in input_names
assert input_names[-18:] == [
*[f"ref_image_{index}" for index in range(9)],
*[f"ref_video_{index}" for index in range(3)],
*[f"ref_video_audio_{index}" for index in range(3)],
*[f"ref_audio_{index}" for index in range(3)],
]
def test_multi_frames_routes_first_and_last_expanded_images(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
clip = _Clip()
vae = _Vae()
output = module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(
clip=[clip],
vae=[vae],
images=[[_image_values(0, 1)], _image_values(2)],
)
)
conditioning = _graph_node(output, "MiniMaxH3ImageToVideo")
assert conditioning["inputs"]["first_frame"][0, 0, 0, 0].item() == 0
assert conditioning["inputs"]["last_frame"][0, 0, 0, 0].item() == 2
def test_multi_frames_with_one_image_routes_only_first_frame(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
output = module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(images=[_image_values(7)])
)
conditioning = _graph_node(output, "MiniMaxH3ImageToVideo")
assert "first_frame" in conditioning["inputs"]
assert "last_frame" not in conditioning["inputs"]
def test_last_frame_routes_only_last_expanded_image(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
output = module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(
mode=["last_frame"],
images=[[_image_values(0, 1)], _image_values(2)],
)
)
conditioning = _graph_node(output, "MiniMaxH3ImageToVideo")
assert "first_frame" not in conditioning["inputs"]
assert conditioning["inputs"]["last_frame"][0, 0, 0, 0].item() == 2
def test_last_frame_with_one_image_routes_only_last_frame(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
output = module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(mode=["last_frame"], images=[_image_values(7)])
)
conditioning = _graph_node(output, "MiniMaxH3ImageToVideo")
assert "first_frame" not in conditioning["inputs"]
assert conditioning["inputs"]["last_frame"].shape == (1, 1, 1, 1)
assert conditioning["inputs"]["last_frame"].item() == 7
@pytest.mark.parametrize("mode", ["reference", "multi_frames", "last_frame"])
def test_empty_media_routes_to_text_to_video_for_every_mode(monkeypatch, mode):
module = _load_minimax_node(monkeypatch)
assert module is not None
clip = _Clip()
output = module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(clip=[clip], mode=[mode])
)
conditioning = _graph_node(output, "MiniMaxH3ImageToVideo")
assert conditioning["inputs"]["prompt"] == "prompt"
assert "first_frame" not in conditioning["inputs"]
assert "last_frame" not in conditioning["inputs"]
@pytest.mark.parametrize(
("mode", "media_kind"),
[
("multi_frames", "video"),
("multi_frames", "audio"),
("last_frame", "video"),
("last_frame", "audio"),
],
)
def test_frame_modes_with_video_or_audio_route_to_reference_subgraph(
monkeypatch, mode, media_kind
):
module = _load_minimax_node(monkeypatch)
assert module is not None
audio = {"waveform": torch.ones(1, 1, 4), "sample_rate": 32000}
overrides = {
"audio_vae": [_AudioVae()],
"images": [_image_values(1, 2)],
"videos": [object()] if media_kind == "video" else [],
"audios": [audio] if media_kind == "audio" else [],
}
output = module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(mode=[mode], **overrides)
)
nodes_by_type = {node["class_type"]: node for node in output.expand.values()}
conditioning = nodes_by_type[module.REFERENCE_BRIDGE_NODE_ID]
assert "MiniMaxH3ImageToVideo" not in nodes_by_type
assert "ref_image_0" in conditioning["inputs"]
assert "ref_image_1" in conditioning["inputs"]
if media_kind == "video":
assert "GetVideoComponents" in nodes_by_type
assert "ref_video_0" in conditioning["inputs"]
else:
assert conditioning["inputs"]["ref_audio_0"] is audio
def test_reference_video_extraction_is_deferred_to_a_cacheable_subnode(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
video = object()
output = module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(
mode=["reference"],
audio_vae=[_AudioVae()],
videos=[video],
)
)
assert output.expand is not None
nodes_by_type = {node["class_type"]: node for node in output.expand.values()}
components = nodes_by_type["GetVideoComponents"]
conditioning = nodes_by_type[module.REFERENCE_BRIDGE_NODE_ID]
components_id = next(
node_id
for node_id, node in output.expand.items()
if node["class_type"] == "GetVideoComponents"
)
assert components["inputs"] == {"video": video}
assert "easy minimaxH3ResampleVideoFrames" not in nodes_by_type
assert conditioning["inputs"]["ref_video_0"] == [components_id, 0]
assert conditioning["inputs"]["ref_video_audio_0"] == [components_id, 1]
def test_locked_video_timing_is_forwarded_to_reference_bridge(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
output = module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(
mode=["reference"],
videos=[object()],
locked_video_timing_frames=[56],
)
)
conditioning = _graph_node(output, module.REFERENCE_BRIDGE_NODE_ID)
assert conditioning["inputs"]["locked_video_timing_frames"] == 56
def test_easy_node_reports_progress_for_each_media_input(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
audio = {"waveform": torch.ones(1, 1, 4), "sample_rate": 32000}
module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(
mode=["reference"],
audio_vae=[_AudioVae()],
images=[_image_values(1, 2)],
videos=[object(), object()],
audios=[audio],
)
)
progress = _ProgressBar.instances[-1]
assert progress.total == 6
assert progress.updates == [
(1, 6),
(2, 6),
(3, 6),
(4, 6),
(5, 6),
(6, 6),
]
def test_easy_node_reports_complete_progress_for_empty_inputs(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
module.EasyMiniMaxH3ToVideo.execute(**_base_inputs(mode=["reference"]))
progress = _ProgressBar.instances[-1]
assert progress.total == 1
assert progress.updates == [(1, 1)]
def test_multi_frames_reports_progress_for_each_expanded_image(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(
mode=["multi_frames"],
images=[_image_values(1, 2, 3)],
)
)
progress = _ProgressBar.instances[-1]
assert progress.total == 4
assert progress.updates == [(1, 4), (2, 4), (3, 4), (4, 4)]
def test_reference_encodes_images_video_audio_and_standalone_audio(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
clip = _Clip()
vae = _Vae()
audio_vae = _AudioVae()
video_audio = {"waveform": torch.ones(1, 1, 8), "sample_rate": 32000}
class _Video:
def get_components(self):
return types.SimpleNamespace(
images=_image_values(0, 1, 2, 3, 4),
audio=video_audio,
frame_rate=Fraction(12),
)
standalone_audio = {"waveform": torch.zeros(1, 1, 4), "sample_rate": 16000}
output = module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(
clip=[clip],
vae=[vae],
mode=["reference"],
audio_vae=[audio_vae],
images=[_image_values(9, 10)],
videos=[[_Video()]],
audios=[[standalone_audio]],
)
)
conditioning = _graph_node(output, module.REFERENCE_BRIDGE_NODE_ID)
components = _Video().get_components()
fallback_output = module.MiniMaxH3ReferenceToVideoFallback.execute(
clip=clip,
vae=vae,
audio_vae=audio_vae,
prompt="prompt",
width=32,
height=32,
length=5,
ref_images={
"ref_image_0": _image_values(9),
"ref_image_1": _image_values(10),
},
ref_videos={"ref_video_0": components.images},
ref_video_audios={"ref_video_audio_0": components.audio},
ref_audios={"ref_audio_0": standalone_audio},
)
refs = fallback_output.values[0][0][1]["minimax_refs"]
assert [ref["kind"] for ref in refs] == ["image", "image", "video_audio", "audio"]
assert [
item["type"] for item in clip.tokenize_calls[0]["kwargs"]["minimax_ref_items"]
] == [
"image",
"image",
"audio",
"video",
"audio",
]
assert vae.encoded[-1].shape[0] == 5
assert len(audio_vae.encoded) == 2
assert conditioning["inputs"]["ref_video_0"][1] == 0
def test_reference_bridge_groups_fixed_inputs_before_direct_execute(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
calls = []
class _NativeReferenceNode:
@classmethod
def execute(cls, **kwargs):
calls.append(kwargs)
return _NodeOutput("conditioning", "latent")
module.comfy_nodes.NODE_CLASS_MAPPINGS[
"MiniMaxH3ReferenceToVideo"
] = _NativeReferenceNode
audio = {"waveform": torch.ones(1, 1, 8), "sample_rate": 32000}
output = module.EasyMiniMaxH3ReferenceToVideoBridge.execute(
clip=_Clip(),
vae=_Vae(),
audio_vae=_AudioVae(),
prompt="prompt",
width=32,
height=32,
length=5,
ref_image_0=_image_values(9),
ref_video_0=_image_values(0, 1, 2, 3, 4),
ref_video_audio_0=audio,
ref_audio_0=audio,
)
assert output.values == ("conditioning", "latent")
assert list(calls[0]["ref_images"]) == ["ref_image_0"]
assert list(calls[0]["ref_videos"]) == ["ref_video_0"]
assert list(calls[0]["ref_video_audios"]) == ["ref_video_audio_0"]
assert list(calls[0]["ref_audios"]) == ["ref_audio_0"]
def test_reference_bridge_pads_video_tail_without_resizing(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
calls = []
def unexpected_resize(*_args, **_kwargs):
raise AssertionError("reference bridge must leave spatial resizing to core")
monkeypatch.setattr(module, "_resize", unexpected_resize)
class _NativeReferenceNode:
@classmethod
def execute(cls, **kwargs):
calls.append(kwargs)
return _NodeOutput("conditioning", "latent")
module.comfy_nodes.NODE_CLASS_MAPPINGS[
"MiniMaxH3ReferenceToVideo"
] = _NativeReferenceNode
frames = _image_values(*range(120))
module.EasyMiniMaxH3ReferenceToVideoBridge.execute(
clip=_Clip(),
vae=_Vae(),
prompt="prompt",
width=32,
height=32,
length=120,
ref_video_0=frames,
)
aligned = calls[0]["ref_videos"]["ref_video_0"]
assert aligned.shape == (124, 1, 1, 1)
assert aligned[:120, 0, 0, 0].tolist() == list(map(float, range(120)))
assert aligned[120:, 0, 0, 0].tolist() == [119.0] * 4
@pytest.mark.parametrize("source_frame_count", [39, 40, 41, 55, 56, 57])
def test_reference_bridge_uniformly_fits_locked_video_timing(
monkeypatch,
source_frame_count,
):
module = _load_minimax_node(monkeypatch)
assert module is not None
calls = []
class _NativeReferenceNode:
@classmethod
def execute(cls, **kwargs):
calls.append(kwargs)
return _NodeOutput("conditioning", "latent")
module.comfy_nodes.NODE_CLASS_MAPPINGS[
"MiniMaxH3ReferenceToVideo"
] = _NativeReferenceNode
target_frame_count = module._align_frame_count(source_frame_count)
frames = _image_values(*range(source_frame_count))
module.EasyMiniMaxH3ReferenceToVideoBridge.execute(
clip=_Clip(),
vae=_Vae(),
prompt="prompt",
width=32,
height=32,
length=target_frame_count,
locked_video_timing_frames=target_frame_count,
ref_video_0=frames,
)
fitted = calls[0]["ref_videos"]["ref_video_0"]
expected_indexes = torch.linspace(
0,
source_frame_count - 1,
target_frame_count,
).round().long()
assert fitted.shape == (target_frame_count, 1, 1, 1)
assert torch.equal(fitted[:, 0, 0, 0], expected_indexes.float())
restored_indexes = torch.linspace(
0,
target_frame_count - 1,
source_frame_count,
).round().long()
assert fitted.index_select(0, restored_indexes)[:, 0, 0, 0].tolist() == list(
map(float, range(source_frame_count))
)
def test_reference_fallback_pads_video_tail_instead_of_dropping_frames(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
vae = _Vae()
frames = _image_values(*range(120))
module.MiniMaxH3ReferenceToVideoFallback.execute(
clip=_Clip(),
vae=vae,
audio_vae=None,
prompt="prompt",
width=32,
height=32,
length=120,
ref_videos={"ref_video_0": frames},
)
encoded = vae.encoded[-1]
assert encoded.shape[0] == 124
assert encoded[119:, 0, 0, 0].tolist() == [119.0] * 5
def test_reference_bridge_directly_executes_fallback_when_native_is_missing(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
output = module.EasyMiniMaxH3ReferenceToVideoBridge.execute(
clip=_Clip(),
vae=_Vae(),
prompt="prompt",
width=32,
height=32,
length=5,
ref_image_0=_image_values(9),
)
refs = output.values[0][0][1]["minimax_refs"]
assert [ref["kind"] for ref in refs] == ["image"]
def test_reference_audio_requires_audio_vae(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
audio = {"waveform": torch.ones(1, 1, 4), "sample_rate": 32000}
with pytest.raises(ValueError, match="audio_vae is required"):
module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(mode=["reference"], audios=[audio])
)
def test_silent_reference_video_does_not_require_audio_vae(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
output = module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(mode=["reference"], videos=[object()])
)
conditioning = _graph_node(output, module.REFERENCE_BRIDGE_NODE_ID)
assert conditioning["inputs"]["audio_vae"] is None
@pytest.mark.parametrize(
("overrides", "media_name", "limit"),
[
({"images": [_image_values(*range(10))]}, "images", 9),
({"videos": [object()] * 4, "audio_vae": [_AudioVae()]}, "videos", 3),
(
{
"audios": [{"waveform": torch.ones(1, 1, 4), "sample_rate": 32000}] * 4,
"audio_vae": [_AudioVae()],
},
"audios",
3,
),
],
)
def test_reference_media_over_native_limits_is_rejected(
monkeypatch, overrides, media_name, limit
):
module = _load_minimax_node(monkeypatch)
assert module is not None
with pytest.raises(
ValueError,
match=rf"reference mode supports at most {limit} {media_name}",
):
module.EasyMiniMaxH3ToVideo.execute(
**_base_inputs(mode=["reference"], **overrides)
)
def test_minimax_node_has_complete_chinese_localization():
locale_path = Path(__file__).parents[1] / "locales" / "zh" / "nodeDefs.json"
locale_path = Path(__file__).parents[1] / "locales" / "zh" / "nodeDefs.json"
if not locale_path.exists():
pytest.skip("locales/zh/nodeDefs.json is not bundled in this repository")
node_defs = json.loads(locale_path.read_text(encoding="utf-8"))
translation = node_defs["easy minimaxH3ToVideo"]
assert translation["display_name"] == "简易 MiniMax H3 视频生成"
assert set(translation["inputs"]) == {
"clip",
"vae",
"audio_vae",
"images",
"videos",
"audios",
"prompt",
"mode",
"width",
"height",
"length",
"ref_image_size",
}
assert translation["inputs"]["mode"]["options"] == {
"reference": "参考生视频",
"multi_frames": "首尾帧生视频",
"last_frame": "尾帧生视频",
}
assert translation["inputs"]["ref_image_size"]["options"] == {
"match": "匹配生成尺寸",
"max": "最大参考尺寸",
}
assert translation["outputs"] == {
"0": {"name": "正向条件"},
"1": {"name": "潜空间"},
}
assert "重采样" not in translation["description"]
assert "重采样" not in translation["inputs"]["videos"]["tooltip"]
assert "任何参考视频" in translation["inputs"]["audio_vae"]["tooltip"]
assert "仅缩小,不放大" in translation["inputs"]["ref_image_size"]["tooltip"]
assert "输入后会自动改走参考生视频" in translation["inputs"]["videos"]["tooltip"]
assert "输入后会自动改走参考生视频" in translation["inputs"]["audios"]["tooltip"]
for node_id in ["MiniMaxH3ImageToVideo", "MiniMaxH3ReferenceToVideo"]:
fallback_translation = node_defs[node_id]
assert fallback_translation["display_name"]
assert fallback_translation["description"]
assert fallback_translation["inputs"]
assert fallback_translation["outputs"] == {
"0": {"name": "正向条件"},
"1": {"name": "潜空间"},
}
def test_remove_h3_motion_context_latent_schema(monkeypatch):
module = _load_minimax_node(monkeypatch)
assert module is not None
schema = module.EasyRemoveH3MotionContextLatent.define_schema()
assert schema.node_id == "easy removeH3MotionContextLatent"
assert schema.display_name == "!!Remove h3 motion context latent"
assert schema.is_output_node is True
assert schema.not_idempotent is True
assert schema.inputs[0].name == "filename_path"
assert schema.inputs[0].kwargs["default"] == "h3_context/clip"
assert schema.inputs[1].name == "input"
assert [output.name for output in schema.outputs] == ["output", "deleted_count"]
def test_remove_h3_motion_context_latent_has_matching_chinese_localization():
locale_path = Path(__file__).parents[1] / "locales" / "zh" / "nodeDefs.json"
if not locale_path.exists():
pytest.skip("locales/zh/nodeDefs.json is not bundled in this repository")
node_defs = json.loads(
(Path(__file__).parents[1] / "locales" / "zh" / "nodeDefs.json").read_text()
)
translation = node_defs["easy removeH3MotionContextLatent"]
assert translation["inputs"]["input"]["name"] == "输入"
assert translation["outputs"] == {
"0": {"name": "输出"},
"1": {"name": "已删除数量"},
}
def test_remove_h3_motion_context_latent_deletes_matching_output_files(
monkeypatch, tmp_path
):
module = _load_minimax_node(monkeypatch)
assert module is not None
latent_directory = tmp_path / "h3_context"
latent_directory.mkdir()
matching_files = [
latent_directory / "clip_00001.safetensors",
latent_directory / "clip_00002_.safetensors",
]
for path in matching_files:
path.write_bytes(b"latent")
preserved_file = latent_directory / "other_00001.safetensors"
preserved_file.write_bytes(b"latent")
folder_paths = types.ModuleType("folder_paths")
folder_paths.get_output_directory = lambda: str(tmp_path)
monkeypatch.setitem(sys.modules, "folder_paths", folder_paths)
monkeypatch.setattr(module, "folder_paths", folder_paths)
passthrough = object()
output = module.EasyRemoveH3MotionContextLatent.execute(
passthrough,
"h3_context/clip",
)
assert output.values == (passthrough, 2)
assert not any(path.exists() for path in matching_files)
assert preserved_file.exists()
@pytest.mark.parametrize(
"filename_path",
["", "../clip", "h3_context/../clip", "/h3_context/clip"],
)
def test_remove_h3_motion_context_latent_rejects_unsafe_paths(
monkeypatch, tmp_path, filename_path
):
module = _load_minimax_node(monkeypatch)
assert module is not None
folder_paths = types.ModuleType("folder_paths")
folder_paths.get_output_directory = lambda: str(tmp_path)
monkeypatch.setitem(sys.modules, "folder_paths", folder_paths)
with pytest.raises(ValueError, match="filename_path"):
module.EasyRemoveH3MotionContextLatent.execute(object(), filename_path)
@pytest.mark.parametrize("mode,first_only", [("single", False), ("dual", False), ("dual", True)])
@pytest.mark.parametrize("with_context", [False, True])
def test_audio_only_project_never_decodes_or_saves_video(monkeypatch, mode, first_only, with_context):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs(
sampling_mode=_h3_sampling_mode(mode, **{"1st_pass_only": first_only}),
upscale_model=["unused-upscaler"],
)
info = inputs["tracks_info"][0]
info.update(width=32, height=32)
if with_context:
info["tracks"][0]["segments"].append({
"start_frame": 120, "end_frame": 240,
"content": {"continuity_mode": "context", "user_prompt": "continue"},
})
result = module.EasyMultiTrackProject.execute(**inputs)
types_in_graph = {node["class_type"] for node in result.expand.values()}
assert "VAEDecodeAudio" in types_in_graph
assert not types_in_graph.intersection({
"VAEDecode", "VAEEncode", "ImageResizeKJv2", "easy minimaxH3LatentUpscaler",
"easy saveVideo", "easy h3SegmentEncodingStart", "easy h3SegmentSaveEnd",
"easy h3LockedAudioDurationAlign",
})
artifacts = [n for n in result.expand.values() if n["class_type"] == "easy h3ProjectArtifact"]
for artifact in artifacts:
assert "audio" in artifact["inputs"]
assert "video_path" not in artifact["inputs"]
if with_context and not first_only:
assert "easy h3AudioContextLatent" in types_in_graph
trims = [n for n in result.expand.values() if n["class_type"] == "easy h3ContextMediaTrim"]
assert trims
assert all("images" not in n["inputs"] for n in trims)
@pytest.mark.parametrize("dimensions", [(32, 64), (64, 32), (64, 64)])
def test_audio_only_project_requires_both_dimensions_to_be_32(monkeypatch, dimensions):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs(sampling_mode=_h3_sampling_mode("single"))
inputs["tracks_info"][0].update(width=dimensions[0], height=dimensions[1])
result = module.EasyMultiTrackProject.execute(**inputs)
assert _graph_node(result, "VAEDecode")
assert _graph_node(result, "easy saveVideo")
def test_audio_only_project_saves_original_locked_audio(monkeypatch):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs()
inputs["tracks_info"][0].update(width=32, height=32)
inputs["tracks_info"][0]["tracks"].append({
"type": "audio", "audio_locked": True,
"segments": [{"start_frame": 0, "end_frame": 120, "content": {"media_type": "audio"}}],
})
result = module.EasyMultiTrackProject.execute(**inputs)
artifact = _graph_node(result, "easy h3ProjectArtifact")
selector = result.expand[artifact["inputs"]["audio"][0]]
assert selector["class_type"] == "easy h3LockedAudioSelect"
assert selector["inputs"]["locked_audio"] == {"prepared_locked_audio": True}
def test_audio_only_context_trims_samples_and_retains_encoded_audio(monkeypatch):
module = _load_minimax_node(monkeypatch)
audio = {"waveform": torch.arange(80).reshape(1, 1, 80).float(), "sample_rate": 48}
trimmed = module.EasyH3ContextMediaTrim.execute(
audio=audio, trim_frames=5, output_frames=22, fps=24,
)
assert trimmed.values[0] is None
assert trimmed.values[1]["waveform"].flatten().tolist() == list(range(10, 54))
encoded = torch.randn(1, 32, 2, 37)
context = module.EasyH3AudioContextLatent.execute({"samples": encoded}, 22)
video, retained_audio = context.values[0]["samples"].unbind()
assert video.shape == (1, 24, 7, 2, 2)
assert torch.count_nonzero(video) == 0
assert retained_audio is encoded
def test_audio_only_artifact_writes_wav_manifest_and_cleans_up(monkeypatch, tmp_path):
import soundfile as sf
module = _load_minimax_node(monkeypatch)
monkeypatch.setattr(module.folder_paths, "get_output_directory", lambda: str(tmp_path))
info = _h3_project_inputs()["tracks_info"][0]
info.update(width=32, height=32)
audio = {"waveform": torch.linspace(-0.5, 0.5, 4800).reshape(1, 1, -1), "sample_rate": 48000}
project_dir = tmp_path / "easy_media" / "projects" / "audio-demo"
for mode in ("new", "new", "override"):
module.EasyH3ProjectArtifact.execute(
project_name="audio-demo", project_save=mode, segment_index=0,
context_latent=_h3_context_latent(), tracks_info=info, audio=audio,
)
assert len(list(project_dir.glob("audio_0_*.wav"))) == 2
assert not list(project_dir.glob("*.mp4"))
manifest = json.loads((project_dir / "project.json").read_text())
active = manifest["segments"]["0"]["generations"]["1"]
assert "video" not in active
waveform, sr = sf.read(project_dir / active["audio"])
assert sr == 48000
assert len(waveform) == 4800
assert torch.allclose(torch.from_numpy(waveform), audio["waveform"].flatten().double(), atol=1e-6)
loaded_video, loaded_audio = module.EasyH3ProjectContextLatentLoad.execute(
"audio-demo", 0
).values[0]["samples"].unbind()
assert loaded_video.shape[2] == 7
assert loaded_audio.shape[-1] == 37
module._project_module.clear_h3_project_segments_from("audio-demo", 0, tmp_path)
assert not list(project_dir.glob("*.wav"))
assert not list(project_dir.glob("*.safetensors"))
@pytest.mark.parametrize("hidden_id", ["15", ["15"]])
def test_audio_only_project_rejects_video_combine_before_any_project_changes(monkeypatch, hidden_id):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs(project_save=["override"], segment_count=[-1])
inputs["tracks_info"][0].update(width=32, height=32)
module.EasyMultiTrackProject.hidden = types.SimpleNamespace(
unique_id=hidden_id,
prompt={"17": {
"class_type": "easy multitrackProjectVideoCombine",
"inputs": {"project_name": ["15", 0]},
}},
)
def unexpected_work(*args, **kwargs):
pytest.fail("Unsupported audio/video combination must fail before project or sampling work")
for name in ("initialize_h3_project", "clear_h3_project_segments_from", "GraphBuilder", "detect_turbo_model"):
monkeypatch.setattr(module._project_module, name, unexpected_work)
with pytest.raises(ValueError, match="Project audio merging is not implemented yet"):
module.EasyMultiTrackProject.execute(**inputs)
@pytest.mark.parametrize("width,height,source", [
(32, 32, ["99", 0]),
(32, 32, "another-project"),
(32, 64, ["15", 0]),
(64, 32, ["15", 0]),
])
def test_audio_project_preflight_does_not_block_other_projects_or_video(monkeypatch, width, height, source):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs(sampling_mode=_h3_sampling_mode("single"))
inputs["tracks_info"][0].update(width=width, height=height)
module.EasyMultiTrackProject.hidden = types.SimpleNamespace(
unique_id="15",
prompt={"17": {
"class_type": "easy multitrackProjectVideoCombine",
"inputs": {"project_name": source},
}},
)
result = module.EasyMultiTrackProject.execute(**inputs)
assert _graph_node(result, "easy h3ProjectArtifact")
def test_project_timing_keeps_native_nodes_and_inputs(monkeypatch):
module = _load_minimax_node(monkeypatch)
module.comfy_nodes.NODE_CLASS_MAPPINGS["ImageResizeKJv2"] = _ImageResizeKJWithNvidia
inputs = _h3_project_inputs(
project_name="timing-demo",
sampling_mode=_h3_sampling_mode("dual"),
)
inputs["tracks_info"][0]["tracks"][0]["segments"].append({
"start_frame": 120,
"end_frame": 240,
"content": {
"task_mode": "l2v",
"continuity_mode": "context",
"images": [],
"user_prompt": "continue",
},
})
module.GraphBuilder.set_default_prefix("timing", 0, 0)
result = module.EasyMultiTrackProject.execute(**inputs)
tagged = [node for node in result.expand.values() if "easy_media_timing" in node.get("_meta", {})]
assert len(tagged) >= 5
assert {node["class_type"] for node in tagged} == {
"SamplerCustomAdvanced",
"VAEEncode",
"VAEEncodeAudio",
"VAEDecode",
"VAEDecodeAudio",
}
labels = [node["_meta"]["easy_media_timing"] for node in tagged]
assert len(set(labels)) == len(labels)
assert all(label.startswith("timing-demo / ") for label in labels)
assert any("first_pass_sample_0" in label for label in labels)
assert any("second_pass_sample_0" in label for label in labels)
assert any("hires_context_1_video_encode" in label for label in labels)
assert any("hires_context_1_audio_encode" in label for label in labels)
assert any("low_context_1_video_encode" in label for label in labels)
assert any("low_context_1_audio_encode" in label for label in labels)
monkeypatch.setattr(
module._project_module,
"_timed_h3_project_graph",
lambda graph, _name, _segments=None: graph.finalize(),
)
module.GraphBuilder.set_default_prefix("timing", 0, 0)
without_timing = module.EasyMultiTrackProject.execute(**inputs)
graph_without_metadata = {
node_id: {key: value for key, value in node.items() if key != "_meta"}
for node_id, node in result.expand.items()
}
assert graph_without_metadata == without_timing.expand
@pytest.mark.parametrize("duration", [120, 125, 124])
@pytest.mark.parametrize("continuity", ["shot", "context", "context_swap"])
@pytest.mark.parametrize("sampling", ["single", "dual"])
@pytest.mark.parametrize("track_type", ["audio", "video"])
def test_locked_media_preserves_source_span_in_both_passes(
monkeypatch, duration, continuity, sampling, track_type,
):
module = _load_minimax_node(monkeypatch)
module.comfy_nodes.NODE_CLASS_MAPPINGS["ImageResizeKJv2"] = _ImageResizeKJWithNvidia
inputs = _h3_project_inputs(sampling_mode=_h3_sampling_mode(sampling))
info = inputs["tracks_info"][0]
info["tracks"][0]["segments"] = [
{
"start_frame": index * duration,
"end_frame": (index + 1) * duration,
"content": {"task_mode": "ref", "continuity_mode": continuity},
}
for index in range(2)
]
info["tracks"].append({
"type": track_type, "audio_locked": True,
"segments": [{
"start_frame": 0, "end_frame": duration * 2,
"content": {"media_type": track_type},
}],
})
result = module.EasyMultiTrackProject.execute(**inputs)
graph = result.expand
expected_generated = {120: 124, 125: 141, 124: 124}[duration]
conditioning = [n for node_id, n in graph.items()
if n["class_type"] == "easy minimaxH3ToVideo"
and "second_pass_conditioning" not in node_id]
for index, node in enumerate(conditioning):
length = node["inputs"]["length"]
if index == 1 and continuity != "shot":
expression = graph[length[0]]["inputs"]
assert expression["values.a"] == expected_generated
assert expression["expression"] == "a + 34"
else:
assert length == expected_generated
if track_type == "video":
assert node["inputs"]["locked_video_timing_frames"] == expected_generated
else:
assert "locked_video_timing_frames" not in node["inputs"]
trims = [
n for n in graph.values()
if n["class_type"] == "easy h3ContextMediaTrim"
and not n["inputs"].get("phase_align_video_encode")
]
assert len(trims) == (4 if sampling == "dual" else 2)
assert all(n["inputs"]["output_frames"] == duration for n in trims)
assert all(
n["inputs"].get("fit_video_duration", False) is (track_type == "video")
for n in trims
)
saves = [n for n in graph.values() if n["class_type"] == "easy saveVideo"]
assert len(saves) == 2
for node in saves:
trim = graph[node["inputs"]["input_mode.images"][0]]
assert trim["inputs"]["output_frames"] == duration
selector = graph[node["inputs"]["input_mode.audio"][0]]
assert selector["class_type"] == "easy h3LockedAudioSelect"
assert selector["inputs"]["locked_audio"] == {"prepared_locked_audio": True}
# Every delivered segment, including the initial shot, supplies fresh context.
encodes = [n for n in graph.values() if n["class_type"] == "VAEEncode"
and "hires_context" in n["inputs"]["pixels"][0]]
assert len(encodes) == 2
def test_audio_locked_context_mv_delivers_exact_timeline_spans(monkeypatch):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs(sampling_mode=_h3_sampling_mode("single"))
info = inputs["tracks_info"][0]
durations = [187, 185, 197, 223, 203, 191, 228, 204, 195, 222, 209]
cursor = 0
task_segments = []
for duration in durations:
task_segments.append({
"start_frame": cursor,
"end_frame": cursor + duration,
"content": {
"task_mode": "default",
"continuity_mode": "context",
},
})
cursor += duration
info["tracks"][0]["segments"] = task_segments
info["tracks"].append({
"type": "audio",
"audio_locked": True,
"segments": [{
"start_frame": 0,
"end_frame": cursor,
"content": {"media_type": "audio"},
}],
})
result = module.EasyMultiTrackProject.execute(**inputs)
delivery_trims = sorted(
(
node for node in result.expand.values()
if node["class_type"] == "easy h3ContextMediaTrim"
and not node["inputs"].get("phase_align_video_encode")
),
key=lambda node: node["_meta"]["easy_media_segment"],
)
assert [node["inputs"]["output_frames"] for node in delivery_trims] == durations
assert all("fit_video_duration" not in node["inputs"] for node in delivery_trims)
assert sum(node["inputs"]["output_frames"] for node in delivery_trims) == 2244
def test_audio_lock_priority_keeps_locked_video_timing(monkeypatch):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs(sampling_mode=_h3_sampling_mode("single"))
info = inputs["tracks_info"][0]
info["tracks"][0]["segments"][0]["end_frame"] = 125
info["tracks"].extend([
{
"type": "video",
"audio_locked": True,
"segments": [{
"start_frame": 0,
"end_frame": 125,
"content": {"media_type": "video"},
}],
},
{
"type": "audio",
"audio_locked": True,
"segments": [{
"start_frame": 0,
"end_frame": 125,
"content": {"media_type": "audio"},
}],
},
])
result = module.EasyMultiTrackProject.execute(**inputs)
conditioning = _graph_node(result, "easy minimaxH3ToVideo")
assert conditioning["inputs"]["length"] == 141
audio_lock = _graph_node(result, "easy minimaxH3AudioLock")
assert audio_lock["inputs"]["audio"] == {"prepared_locked_audio": True}
@pytest.mark.parametrize("track_type,locked,audio_only,preserves_timing,fits_video", [
("video", False, False, False, False),
("audio", False, False, False, False),
("audio", True, False, True, False),
("video", True, False, True, True),
("video", True, True, False, False),
("audio", True, True, False, False),
])
@pytest.mark.parametrize("duration", [120, 125, 131])
def test_source_timing_policy_leaves_other_tasks_unchanged(
monkeypatch, track_type, locked, audio_only, preserves_timing, fits_video, duration,
):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs(sampling_mode=_h3_sampling_mode("single"))
info = inputs["tracks_info"][0]
info["tracks"][0]["segments"][0]["end_frame"] = duration
if audio_only:
info.update(width=32, height=32)
info["tracks"].append({
"type": track_type, "audio_locked": locked,
"segments": [{"start_frame": 0, "end_frame": duration,
"content": {"media_type": track_type}}],
})
result = module.EasyMultiTrackProject.execute(**inputs)
length = _graph_node(result, "easy minimaxH3ToVideo")["inputs"]["length"]
if preserves_timing:
assert length == module._align_frame_count(duration)
trims = [
node for node in result.expand.values()
if node["class_type"] == "easy h3ContextMediaTrim"
and not node["inputs"].get("phase_align_video_encode")
]
assert len(trims) == 1
assert trims[0]["inputs"]["output_frames"] == duration
assert trims[0]["inputs"].get("fit_video_duration", False) is fits_video
else:
assert isinstance(length, list)
assert length[1] == 3
assert result.expand[length[0]]["class_type"] == "easy multiTrackTaskOutput"
assert not any(
node["class_type"] == "easy h3ContextMediaTrim"
for node in result.expand.values()
)
@pytest.mark.parametrize("duration", [120, 125, 124])
@pytest.mark.parametrize("prefix", [0, 22])
def test_video_locked_trim_fits_full_generated_span_without_dropping_tail(
monkeypatch, duration, prefix,
):
module = _load_minimax_node(monkeypatch)
generated = module._align_frame_count(duration) + (34 if prefix else 0)
images = torch.arange(generated, dtype=torch.float32).reshape(-1, 1, 1, 1)
audio = {"waveform": torch.arange(generated * 2).reshape(1, 1, -1), "sample_rate": 48}
result = module.EasyH3ContextMediaTrim.execute(
images,
audio,
trim_frames=prefix,
output_frames=duration,
pad_audio=False,
fit_video_duration=True,
fps=24,
)
available = images[prefix:]
expected_indexes = torch.linspace(0, len(available) - 1, duration).round().long()
assert torch.equal(result.values[0], available.index_select(0, expected_indexes))
assert result.values[0][0].item() == images[prefix].item()
assert result.values[0][-1].item() == images[-1].item()
assert torch.equal(result.values[1]["waveform"], audio["waveform"][..., prefix * 2:(prefix + duration) * 2])
def test_audio_locked_trim_discards_generated_tail_without_time_compression(
monkeypatch,
):
module = _load_minimax_node(monkeypatch)
duration = 187
prefix = 22
generated = module._align_frame_count(duration) + 34
images = torch.arange(generated, dtype=torch.float32).reshape(-1, 1, 1, 1)
audio = {
"waveform": torch.arange(generated * 2).reshape(1, 1, -1),
"sample_rate": 48,
}
result = module.EasyH3ContextMediaTrim.execute(
images,
audio,
trim_frames=prefix,
output_frames=duration,
pad_audio=False,
fps=24,
)
assert torch.equal(result.values[0], images[prefix:prefix + duration])
assert result.values[0][-1].item() == images[prefix + duration - 1].item()
assert result.values[0][-1].item() != images[-1].item()
assert torch.equal(
result.values[1]["waveform"],
audio["waveform"][..., prefix * 2:(prefix + duration) * 2],
)
@pytest.mark.parametrize("track_type", ["video"])
def test_audio_lock_first_pass_checkpoint_keeps_full_sampling_latent(monkeypatch, track_type):
module = _load_minimax_node(monkeypatch)
inputs = _h3_project_inputs(
sampling_mode=_h3_sampling_mode("dual", **{"1st_pass_only": True}),
)
info = inputs["tracks_info"][0]
info["tracks"][0]["segments"][0]["end_frame"] = 125
info["tracks"].append({
"type": track_type, "audio_locked": True,
"segments": [{"start_frame": 0, "end_frame": 125,
"content": {"media_type": track_type}}],
})
result = module.EasyMultiTrackProject.execute(**inputs)
artifact = _graph_node(result, "easy h3ProjectArtifact")["inputs"]
assert artifact["sampling_pass"] == "first"
checkpoint = artifact["context_latent"]
assert result.expand[checkpoint[0]]["class_type"] == "SamplerCustomAdvanced"
assert checkpoint[1] == 1
# The clean low context is still used for continuing the next segment.
low = artifact["context_latent_low"]
assert result.expand[low[0]]["class_type"] == "LTXVConcatAVLatent"
def test_minimax_prompt_override_node_serializes_ordered_prompts(monkeypatch):
module = _load_minimax_node(monkeypatch)
result = module.EasyMinimaxPromptOverride.execute(
{
"prompt_1": "second prompt",
"prompt_0": "@图片1 first prompt",
"prompt_2": None,
},
duration="10,5,10",
generation_type="r2v,i2v,l2v",
continuity_mode="shot,context,context_swap",
system_prompt="custom system prompt",
video_track_lock=1,
audio_track_lock=2,
)
payload = json.loads(result.values[0])
assert payload["type"] == "minimax_prompt_override"
assert payload["prompts"] == ["@图片1 first prompt", "second prompt"]
assert payload["duration"] == "10,5,10"
assert payload["generation_type"] == "r2v,i2v,l2v"
assert payload["continuity_mode"] == "shot,context,context_swap"
assert payload["system_prompt"] == "custom system prompt"
assert payload["video_track_lock"] == 1
assert payload["audio_track_lock"] == 2
def test_minimax_prompt_override_node_has_complete_chinese_localization():
locale_path = Path(__file__).parents[1] / "locales" / "zh" / "nodeDefs.json"
if not locale_path.exists():
pytest.skip("locales/zh/nodeDefs.json is not bundled in this repository")
node_defs = json.loads(
(Path(__file__).parents[1] / "locales" / "zh" / "nodeDefs.json").read_text()
)
translation = node_defs["easy minimaxPromptOverride"]
assert translation["display_name"] == "MiniMax 提示词覆盖"
assert set(translation["inputs"]) == {
"prompts",
"duration",
"generation_type",
"continuity_mode",
"system_prompt",
"video_track_lock",
"audio_track_lock",
}
assert translation["inputs"]["duration"]["name"] == "生成时长"
assert translation["inputs"]["generation_type"]["name"] == "生成类型"
assert translation["inputs"]["continuity_mode"]["name"] == "衔接模式"
assert translation["inputs"]["system_prompt"]["name"] == "系统提示词"
assert translation["inputs"]["video_track_lock"]["name"] == "视频轨锁定"
assert translation["inputs"]["audio_track_lock"]["name"] == "音频轨锁定"
assert translation["outputs"] == {"0": {"name": "提示词覆盖"}}