Download tests/test_minimax_node.py from turtle89431/Moxie-Multimedia: direct link, hf CLI and curl.
- Browser
- Download file 214 kB
-
https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/tests/test_minimax_node.py
- Command line
-
hf download hf://turtle89431/Moxie-Multimedia/tests/test_minimax_node.py
-
curl -L -o test_minimax_node.py https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/tests/test_minimax_node.py
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: | |
| def Input(name, **kwargs): | |
| return _Port(name, **kwargs) | |
| def Output(name=None, **kwargs): | |
| return _Port(name, **kwargs) | |
| class _DynamicCombo(_PortType): | |
| 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 | |
| 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: | |
| 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", | |
| } | |
| 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 | |
| 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 | |
| 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) | |
| 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"} | |
| 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 | |
| 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"] | |
| 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: | |
| 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: | |
| 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 | |
| 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: | |
| 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 | |
| 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() | |
| 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) | |
| 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) | |
| 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")) | |
| 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) | |
| 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 | |
| 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} | |
| 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() | |
| ) | |
| 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], | |
| ) | |
| 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": "提示词覆盖"}} | |