Download nodes/project.py from turtle89431/Moxie-Multimedia: direct link, hf CLI and curl.
- Browser
- Download file 103 kB
-
https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/nodes/project.py
- Command line
-
hf download hf://turtle89431/Moxie-Multimedia/nodes/project.py
-
curl -L -o project.py https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/nodes/project.py
103 kB
| from __future__ import annotations | |
| import copy | |
| import json | |
| import math | |
| from typing import Any | |
| import folder_paths | |
| import nodes as comfy_nodes | |
| from comfy_api.latest import InputImpl, io | |
| from comfy_execution.graph_utils import ExecutionBlocker, GraphBuilder, is_link | |
| from comfy.utils import ProgressBar | |
| from ..utils import instrument_node_timing, log_node_info | |
| from ..utils.h3_presets import get_h3_preset_keys, load_h3_presets, select_h3_preset | |
| from ..utils.h3_project import ( | |
| clear_h3_project_segments_from, | |
| compose_h3_project_video, | |
| h3_generation_mode, | |
| h3_locked_audio_track, | |
| h3_locked_video_track, | |
| minimax_frame_count, | |
| h3_project_filename_prefix, | |
| h3_second_pass_dimensions, | |
| has_h3_context_latent, | |
| has_h3_first_pass_checkpoint, | |
| h3_task_entries, | |
| h3_task_type, | |
| initialize_h3_project, | |
| parse_tracks_info, | |
| crop_multitrack_project_media, | |
| prepare_multitrack_project_media, | |
| prepare_multitrack_project_task_info, | |
| safe_h3_project_name, | |
| select_h3_task_entries, | |
| validate_h3_project_outputs, | |
| ) | |
| from ..utils.models import detect_turbo_lora_from_prompt, detect_turbo_model | |
| from ..utils.rtx_vsr import restore_project_video_resolution | |
| from ..utils.project_memory import ( | |
| BOUNDARY_META, | |
| SEGMENT_META, | |
| install_project_memory_cleanup, | |
| ) | |
| from ..utils.multitrack import ( | |
| MULTITRACK_RUNTIME_CACHE_KEY, | |
| multitrack_runtime_cache, | |
| ) | |
| TYPE_FAST_MODEL_LOADER = io.Custom(io_type="FAST_MODEL_LOADER") | |
| TYPE_TRACKS_INFO = io.Custom(io_type="TRACKS_INFO") | |
| TYPE_PROJECT_DATA = io.Custom(io_type="PROJECT_DATA") | |
| TYPE_H3_PROJECT_STATIC_DATA = io.Custom(io_type="H3_PROJECT_STATIC_DATA") | |
| H3_CONTEXT_CONTINUITY_MODES = {"context", "context_swap"} | |
| H3_CONTEXT_SOURCE_FRAMES = 22 | |
| def _first_input(value: Any, default: Any = None) -> Any: | |
| if isinstance(value, (list, tuple)): | |
| return value[0] if value else default | |
| return value if value is not None else default | |
| def _raw_project_input(value: Any) -> Any: | |
| """Unwrap list inputs without splitting a raw graph link.""" | |
| if is_link(value): | |
| return value | |
| if isinstance(value, (list, tuple)) and len(value) == 1 and is_link(value[0]): | |
| return value[0] | |
| return _first_input(value) | |
| def _hidden_project_input_link(hidden_inputs: Any, name: str) -> Any | None: | |
| """Recover the original prompt link after an input was materialized.""" | |
| prompt = _first_input(getattr(hidden_inputs, "prompt", None)) | |
| unique_id = str(_first_input(getattr(hidden_inputs, "unique_id", None), "")) | |
| if not isinstance(prompt, dict) or not unique_id: | |
| return None | |
| node = prompt.get(unique_id) | |
| if not isinstance(node, dict): | |
| return None | |
| inputs = node.get("inputs") | |
| if not isinstance(inputs, dict): | |
| return None | |
| value = inputs.get(name) | |
| return value if is_link(value) else None | |
| def _require_minimax_h3_model(model: Any) -> None: | |
| """Reject project expansion for models other than ComfyUI's MiniMaxH3.""" | |
| base_model = getattr(model, "model", None) | |
| model_config = getattr(base_model, "model_config", None) | |
| config_name = type(model_config).__name__ if model_config is not None else "unknown" | |
| unet_config = getattr(model_config, "unet_config", None) | |
| image_model = ( | |
| unet_config.get("image_model") | |
| if isinstance(unet_config, dict) | |
| else None | |
| ) | |
| if config_name != "MiniMaxH3" and image_model != "minimax_h3": | |
| raise ValueError( | |
| "easy multitrackProject currently supports only MiniMaxH3 models; " | |
| f"received {config_name}." | |
| ) | |
| def _h3_node_mapping(node_id: str) -> Any | None: | |
| mappings = getattr(comfy_nodes, "NODE_CLASS_MAPPINGS", {}) | |
| return mappings.get(node_id) if isinstance(mappings, dict) else None | |
| def _h3_required_node_defaults(node_id: str) -> dict[str, Any]: | |
| node_class = _h3_node_mapping(node_id) | |
| if node_class is None: | |
| return {} | |
| input_types = getattr(node_class, "INPUT_TYPES", None) | |
| if not callable(input_types): | |
| return {} | |
| try: | |
| schema = input_types() | |
| except (AttributeError, RuntimeError, TypeError) as error: | |
| raise RuntimeError(f"Unable to inspect {node_id} inputs: {error}") from error | |
| required = schema.get("required", {}) if isinstance(schema, dict) else {} | |
| defaults: dict[str, Any] = {} | |
| for name, specification in required.items(): | |
| if ( | |
| isinstance(specification, (list, tuple)) | |
| and len(specification) > 1 | |
| and isinstance(specification[1], dict) | |
| and "default" in specification[1] | |
| ): | |
| defaults[name] = specification[1]["default"] | |
| elif ( | |
| isinstance(specification, (list, tuple)) | |
| and specification | |
| and isinstance(specification[0], (list, tuple)) | |
| and specification[0] | |
| ): | |
| defaults[name] = specification[0][0] | |
| return defaults | |
| def _h3_image_resize_inputs( | |
| image: Any, width: int, height: int | |
| ) -> dict[str, Any]: | |
| node_class = _h3_node_mapping("ImageResizeKJv2") | |
| if node_class is None: | |
| raise RuntimeError( | |
| "Dual H3 sampling without a latent upscale model requires " | |
| "ImageResizeKJv2 from ComfyUI-KJNodes." | |
| ) | |
| input_types = getattr(node_class, "INPUT_TYPES", None) | |
| schema = input_types() if callable(input_types) else {} | |
| required = schema.get("required", {}) if isinstance(schema, dict) else {} | |
| method_spec = required.get("upscale_method") | |
| methods = method_spec[0] if isinstance(method_spec, (list, tuple)) else [] | |
| if "nvidia_rtx_vsr" not in methods: | |
| raise RuntimeError( | |
| "The installed ImageResizeKJv2 does not support the required " | |
| "nvidia_rtx_vsr upscale method." | |
| ) | |
| inputs = _h3_required_node_defaults("ImageResizeKJv2") | |
| inputs.update( | |
| { | |
| "image": image, | |
| "width": width, | |
| "height": height, | |
| "upscale_method": "nvidia_rtx_vsr", | |
| } | |
| ) | |
| return inputs | |
| def _h3_latent_upscale_inputs( | |
| latent: Any, | |
| model_name: str, | |
| width: int, | |
| height: int, | |
| ) -> dict[str, Any]: | |
| return { | |
| "latent": latent, | |
| "model_name": model_name, | |
| "mode": "target dimensions", | |
| "mode.width": width, | |
| "mode.height": height, | |
| "align": 32, | |
| "enable_temporal_chunking": True, | |
| "force_unload": True, | |
| } | |
| def _h3_encode_context_media( | |
| graph: GraphBuilder, | |
| images: Any, | |
| audio: Any, | |
| vae: Any, | |
| audio_vae: Any, | |
| node_prefix: str, | |
| context_frames: int = 22, | |
| ) -> Any: | |
| """Encode a phase-aligned video suffix and the delivered audio timeline.""" | |
| video_tail = graph.node( | |
| "easy h3ContextMediaTrim", | |
| id=f"{node_prefix}_encode_trim", | |
| images=images, | |
| audio=audio, | |
| trim_frames=0, | |
| output_frames=int(context_frames), | |
| phase_align_video_encode=True, | |
| ) | |
| encoded_video = graph.node( | |
| "VAEEncode", | |
| id=f"{node_prefix}_video_encode", | |
| pixels=video_tail.out(0), | |
| vae=vae, | |
| ) | |
| encoded_audio = graph.node( | |
| "VAEEncodeAudio", | |
| id=f"{node_prefix}_audio_encode", | |
| audio=video_tail.out(1), | |
| vae=audio_vae, | |
| ) | |
| return graph.node( | |
| "LTXVConcatAVLatent", | |
| id=f"{node_prefix}_concat", | |
| video_latent=encoded_video.out(0), | |
| audio_latent=encoded_audio.out(0), | |
| ).out(0) | |
| def _h3_encode_audio_context( | |
| graph: GraphBuilder, | |
| audio: Any, | |
| audio_vae: Any, | |
| trim_frames: Any, | |
| output_frames: Any, | |
| fps: float, | |
| pad_audio: bool, | |
| prefix: str, | |
| ) -> tuple[Any, Any]: | |
| """Trim and encode an audio-only context without touching the video VAE.""" | |
| trimmed = graph.node( | |
| "easy h3ContextMediaTrim", | |
| id=f"{prefix}_trim", | |
| audio=audio, | |
| trim_frames=trim_frames, | |
| output_frames=output_frames, | |
| fps=fps, | |
| pad_audio=pad_audio, | |
| ).out(1) | |
| encoded = graph.node( | |
| "VAEEncodeAudio", id=f"{prefix}_encode", audio=trimmed, vae=audio_vae, | |
| ).out(0) | |
| context = graph.node( | |
| "easy h3AudioContextLatent", id=f"{prefix}_latent", | |
| audio_latent=encoded, output_frames=output_frames, | |
| ).out(0) | |
| return trimmed, context | |
| def _timed_h3_project_graph( | |
| graph: GraphBuilder, | |
| project_name: str, | |
| segment_nodes: dict[str, int] | None = None, | |
| ) -> dict[str, Any]: | |
| """Tag timing and segment cache lifetimes without changing node inputs.""" | |
| expanded = graph.finalize() | |
| timed_types = { | |
| "SamplerCustomAdvanced", | |
| "easy h3SamplingPreviewSampler", | |
| "VAEEncode", | |
| "VAEEncodeAudio", | |
| "VAEDecode", | |
| "VAEDecodeAudio", | |
| } | |
| persistent_media_types = { | |
| "easy h3ProjectStaticPrepare", | |
| "easy multiTrackTaskOutput", | |
| } | |
| for node_id, node in expanded.items(): | |
| if ( | |
| segment_nodes is not None | |
| and node_id in segment_nodes | |
| and node["class_type"] not in persistent_media_types | |
| ): | |
| node.setdefault("_meta", {})[SEGMENT_META] = segment_nodes[node_id] | |
| if node["class_type"] == "easy h3ProjectArtifact": | |
| node["_meta"][BOUNDARY_META] = True | |
| if node["class_type"] not in timed_types: | |
| continue | |
| target = _h3_node_mapping(node["class_type"]) | |
| if target is not None: | |
| instrument_node_timing(target) | |
| operation = node_id.rsplit(".", 1)[-1] | |
| node.setdefault("_meta", {})["easy_media_timing"] = f"{project_name} / {operation}" | |
| if segment_nodes: | |
| install_project_memory_cleanup() | |
| return expanded | |
| def _h3_resolve_pass_sampling( | |
| graph: GraphBuilder, | |
| *, | |
| pass_name: str, | |
| sampler: Any, | |
| sigmas: Any, | |
| preset_name: str, | |
| has_second_pass: bool, | |
| is_turbo: bool, | |
| ) -> tuple[Any, Any]: | |
| if preset_name == "custom" or sampler is not None or sigmas is not None: | |
| if sampler is None or sigmas is None: | |
| raise ValueError( | |
| f"Custom H3 {pass_name.replace('_', ' ')} sampling requires " | |
| "both sampler and sigmas." | |
| ) | |
| return sampler, sigmas | |
| preset = select_h3_preset( | |
| load_h3_presets(), | |
| preset_name, | |
| "dual" if has_second_pass else "single", | |
| is_turbo, | |
| ) | |
| sampler_key = "sampler_2nd" if pass_name == "second_pass" else "sampler" | |
| sigmas_key = "sigmas_2nd" if pass_name == "second_pass" else "sigmas" | |
| sampler = graph.node( | |
| "KSamplerSelect", | |
| id=f"{pass_name}_sampler", | |
| sampler_name=preset.get(sampler_key, preset["sampler"]), | |
| ).out(0) | |
| sigma_node = graph.node( | |
| "ManualSigmas", | |
| id=f"{pass_name}_sigmas", | |
| sigmas=preset.get(sigmas_key, preset["sigmas"]), | |
| ) | |
| sigmas = sigma_node.out(0) | |
| if has_second_pass and "split_step" in preset: | |
| split = graph.node( | |
| "SplitSigmas", | |
| id=f"{pass_name}_split_sigmas", | |
| sigmas=sigmas, | |
| step=int(preset["split_step"]), | |
| ) | |
| sigmas = split.out(1 if pass_name == "second_pass" else 0) | |
| return sampler, sigmas | |
| def _h3_resolve_context_second_pass_sigmas( | |
| graph: GraphBuilder, | |
| *, | |
| preset_name: str, | |
| is_turbo: bool, | |
| has_custom_second_pass_sampling: bool, | |
| ) -> Any | None: | |
| """Build the preset-only sigma schedule used by context pass two.""" | |
| if has_custom_second_pass_sampling or preset_name == "custom": | |
| return None | |
| preset = select_h3_preset( | |
| load_h3_presets(), | |
| preset_name, | |
| "dual", | |
| is_turbo, | |
| ) | |
| context_sigmas = preset.get("sigmas_2nd_context") | |
| if context_sigmas is None: | |
| return None | |
| return graph.node( | |
| "ManualSigmas", | |
| id="second_pass_context_sigmas", | |
| sigmas=context_sigmas, | |
| ).out(0) | |
| def _h3_sampling_mode_config(value: Any) -> tuple[str, dict[str, Any]]: | |
| """Normalize the DynamicCombo value and accept a plain legacy mode value.""" | |
| config = _first_input(value) | |
| if config is None: | |
| return "single", {} | |
| if isinstance(config, str): | |
| config = {"sampling_mode": config} | |
| if not isinstance(config, dict): | |
| raise TypeError("sampling_mode must be a DynamicCombo configuration dictionary.") | |
| sampling_mode = str(_first_input(config.get("sampling_mode"), "single")) | |
| # Keep workflows saved with the original mode name loadable. | |
| if sampling_mode == "dual_selflift": | |
| sampling_mode = "selflift" | |
| if sampling_mode not in {"single", "dual", "selflift", "passthrough"}: | |
| raise ValueError("sampling_mode must be 'single', 'dual', 'selflift', or 'passthrough'") | |
| return sampling_mode, config | |
| def _h3_tiling_config(value: Any) -> tuple[bool, int]: | |
| config = _first_input(value) | |
| if config is None: | |
| return False, 0 | |
| if isinstance(config, str): | |
| enabled = config.lower() == "true" | |
| return enabled, 2 if enabled else 0 | |
| if not isinstance(config, dict): | |
| raise TypeError("enabled_tiling must be a DynamicCombo configuration dictionary") | |
| enabled = str(_first_input(config.get("enabled_tiling"), "false")).lower() == "true" | |
| if not enabled: | |
| return False, 0 | |
| tile_count = int(_first_input(config.get("tile_count"), 2)) | |
| if not 2 <= tile_count <= 8: | |
| raise ValueError("tile_count must be between 2 and 8") | |
| return True, tile_count | |
| def _h3_resolve_selflift_sigmas( | |
| graph: GraphBuilder, | |
| *, | |
| sigmas: Any, | |
| preset_name: str, | |
| is_turbo: bool, | |
| ) -> Any: | |
| """Resolve one complete schedule for the progressive SelfLift sampler.""" | |
| if sigmas is not None: | |
| return sigmas | |
| if preset_name == "custom": | |
| raise ValueError( | |
| "selflift with the custom sampling plan requires sigmas; " | |
| "the sampler itself is always Euler" | |
| ) | |
| preset = select_h3_preset( | |
| load_h3_presets(), | |
| preset_name, | |
| "single", | |
| is_turbo, | |
| ) | |
| return graph.node( | |
| "ManualSigmas", | |
| id="selflift_sigmas", | |
| sigmas=preset["sigmas"], | |
| ).out(0) | |
| def _h3_second_pass_model( | |
| value: Any, | |
| *, | |
| model: Any, | |
| ) -> Any: | |
| """Resolve the optional second-pass model while preserving latent compatibility.""" | |
| model_loader = _first_input(value) | |
| if model_loader is None: | |
| return model | |
| if not isinstance(model_loader, dict): | |
| raise TypeError("model_loader_2nd must contain a FAST_MODEL_LOADER dictionary.") | |
| second_model = model_loader.get("model") | |
| if second_model is None: | |
| raise ValueError("model_loader_2nd is missing required component: model") | |
| return second_model | |
| class EasyH3SamplingPreviewSampler(io.ComfyNode): | |
| """SamplerCustomAdvanced with an optional preview-VAE event callback.""" | |
| def define_schema(cls) -> io.Schema: | |
| return io.Schema( | |
| node_id="easy h3SamplingPreviewSampler", | |
| display_name="H3 Sampling Preview Sampler", | |
| category="EasyUse/H3/dev", | |
| inputs=[ | |
| io.Noise.Input("noise"), | |
| io.Guider.Input("guider"), | |
| io.Sampler.Input("sampler"), | |
| io.Sigmas.Input("sigmas"), | |
| io.Latent.Input("latent_image"), | |
| io.Boolean.Input("enabled_tiling", default=False, optional=True), | |
| io.Int.Input( | |
| "tile_count", | |
| default=2, | |
| min=2, | |
| max=8, | |
| step=1, | |
| optional=True, | |
| ), | |
| io.Vae.Input("preview_vae", optional=True), | |
| io.String.Input("preview_node_id", default="", optional=True), | |
| io.Int.Input( | |
| "generated_frame_count", default=1, min=1, optional=True | |
| ), | |
| io.Float.Input("preview_fps", default=24.0, min=0.01, optional=True), | |
| io.Int.Input("segment_index", default=0, min=0, optional=True), | |
| io.String.Input("sampling_pass", default="sampling", optional=True), | |
| ], | |
| outputs=[ | |
| io.Latent.Output(display_name="output"), | |
| io.Latent.Output(display_name="denoised_output"), | |
| ], | |
| is_dev_only=True, | |
| ) | |
| def execute( | |
| cls, | |
| noise: Any, | |
| guider: Any, | |
| sampler: Any, | |
| sigmas: Any, | |
| latent_image: dict[str, Any], | |
| enabled_tiling: bool = False, | |
| tile_count: int = 2, | |
| preview_vae: Any | None = None, | |
| preview_node_id: str = "", | |
| generated_frame_count: int = 1, | |
| preview_fps: float = 24.0, | |
| segment_index: int = 0, | |
| sampling_pass: str = "sampling", | |
| ) -> io.NodeOutput: | |
| import comfy.model_management | |
| import comfy.nested_tensor | |
| import comfy.sample | |
| import comfy.utils | |
| import latent_preview | |
| from ..utils.sampling_preview import ( | |
| create_preview_callback, | |
| preview_frame_count, | |
| preview_playback_fps, | |
| ) | |
| latent = latent_image.copy() | |
| samples = comfy.sample.fix_empty_latent_channels( | |
| guider.model_patcher, | |
| latent["samples"], | |
| latent.get("downscale_ratio_spacial"), | |
| latent.get("downscale_ratio_temporal"), | |
| ) | |
| latent["samples"] = samples | |
| sampling_guider = guider | |
| if enabled_tiling: | |
| streams = ( | |
| list(samples.unbind()) | |
| if getattr(samples, "is_nested", False) | |
| else [samples] | |
| ) | |
| from ..modules.selflift.h3_tiling import tiled_model | |
| patched_model = tiled_model( | |
| guider.model_patcher, | |
| [tuple(stream.shape) for stream in streams], | |
| tile_count=int(tile_count), | |
| ) | |
| sampling_guider = copy.copy(guider) | |
| sampling_guider.model_patcher = patched_model | |
| sampling_guider.model_options = patched_model.model_options | |
| noise_mask = latent.get("noise_mask") | |
| x0_output: dict[str, Any] = {} | |
| if preview_vae is not None and preview_node_id: | |
| preview_callback = create_preview_callback( | |
| sampling_guider.model_patcher, | |
| preview_vae, | |
| node_id=preview_node_id, | |
| requested_frames=preview_frame_count(generated_frame_count), | |
| fps=preview_playback_fps(generated_frame_count, preview_fps), | |
| segment_index=int(segment_index), | |
| sampling_pass=sampling_pass, | |
| ) | |
| progress = comfy.utils.ProgressBar(int(sigmas.shape[-1]) - 1) | |
| def callback(step: int, x0: Any, state: Any, total: int) -> None: | |
| x0_output["x0"] = x0 | |
| preview_callback(step, x0, state, total) | |
| progress.update_absolute(step + 1, total) | |
| else: | |
| callback = latent_preview.prepare_callback( | |
| sampling_guider.model_patcher, | |
| int(sigmas.shape[-1]) - 1, | |
| x0_output, | |
| ) | |
| disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED | |
| sampled = sampling_guider.sample( | |
| noise.generate_noise(latent), | |
| samples, | |
| sampler, | |
| sigmas, | |
| denoise_mask=noise_mask, | |
| callback=callback, | |
| disable_pbar=disable_pbar, | |
| seed=noise.seed, | |
| ).to(comfy.model_management.intermediate_device()) | |
| output = latent.copy() | |
| output.pop("downscale_ratio_spacial", None) | |
| output.pop("downscale_ratio_temporal", None) | |
| output["samples"] = sampled | |
| if "x0" not in x0_output: | |
| return io.NodeOutput(output, output) | |
| denoised = x0_output["x0"] | |
| if getattr(sampled, "is_nested", False) and not getattr( | |
| denoised, "is_nested", False | |
| ): | |
| latent_shapes = [tensor.shape for tensor in sampled.unbind()] | |
| denoised = comfy.nested_tensor.NestedTensor( | |
| comfy.utils.unpack_latents(denoised, latent_shapes) | |
| ) | |
| denoised = sampling_guider.model_patcher.model.process_latent_out( | |
| denoised.cpu() | |
| ) | |
| denoised_output = latent.copy() | |
| denoised_output["samples"] = denoised | |
| return io.NodeOutput(output, denoised_output) | |
| class EasyH3ProjectStaticPrepare(io.ComfyNode): | |
| """Resolve seed-independent model, media, and sampling resources.""" | |
| def define_schema(cls) -> io.Schema: | |
| return io.Schema( | |
| node_id="easy h3ProjectStaticPrepare", | |
| display_name="H3 Project Static Prepare", | |
| category="EasyUse/H3/dev", | |
| is_dev_only=True, | |
| enable_expand=True, | |
| inputs=[ | |
| TYPE_FAST_MODEL_LOADER.Input("model_loader", optional=True), | |
| TYPE_FAST_MODEL_LOADER.Input("model_loader_2nd", optional=True), | |
| TYPE_TRACKS_INFO.Input("tracks_info", optional=True), | |
| TYPE_H3_PROJECT_STATIC_DATA.Input("project_static", optional=True), | |
| io.AnyType.Input( | |
| "previous", | |
| optional=True, | |
| tooltip="Optional dependency on the previous saved segment.", | |
| ), | |
| io.Int.Input("task_start_frame", default=0, min=0, optional=True), | |
| io.Int.Input("task_index", default=0, min=0, optional=True), | |
| io.Int.Input("task_duration_frames", default=1, min=1, optional=True), | |
| io.Float.Input("fps", default=24.0, min=0.001, optional=True), | |
| io.Combo.Input("generation_mode", options=["reference", "multi_frames", "last_frame"], default="multi_frames", optional=True), | |
| io.String.Input("sampling_plan", default="light", optional=True), | |
| io.Combo.Input("sampling_mode", options=["single", "dual", "selflift", "passthrough"], default="single", optional=True), | |
| io.Sampler.Input("sampler", optional=True, raw_link=True), | |
| io.Sigmas.Input("sigmas", optional=True, raw_link=True), | |
| io.Sampler.Input("sampler_2nd", optional=True, raw_link=True), | |
| io.Sigmas.Input("sigmas_2nd", optional=True, raw_link=True), | |
| io.Boolean.Input("run_second_pass", default=False, optional=True), | |
| io.Boolean.Input("has_context_second_pass", default=False, optional=True), | |
| io.Boolean.Input("turbo_hint", default=False, optional=True), | |
| ], | |
| outputs=[ | |
| TYPE_H3_PROJECT_STATIC_DATA.Output("PROJECT_STATIC"), | |
| TYPE_TRACKS_INFO.Output("TASK_TRACKS_INFO"), | |
| io.Model.Output("MODEL"), io.Model.Output("MODEL_2ND"), | |
| io.Clip.Output("CLIP"), io.Vae.Output("VAE"), | |
| io.Vae.Output("AUDIO_VAE"), io.Vae.Output("PREVIEW_VAE"), | |
| io.Audio.Output("LOCKED_AUDIO"), | |
| io.Sampler.Output("SAMPLER"), io.Sigmas.Output("SIGMAS"), | |
| io.Sampler.Output("SAMPLER_2ND"), io.Sigmas.Output("SIGMAS_2ND"), | |
| io.Sigmas.Output("CONTEXT_SIGMAS_2ND"), | |
| ], | |
| ) | |
| def execute( | |
| cls, | |
| model_loader: Any | None = None, | |
| model_loader_2nd: Any | None = None, | |
| tracks_info: Any | None = None, | |
| project_static: Any | None = None, | |
| previous: Any | None = None, | |
| task_start_frame: int = 0, | |
| task_index: int = 0, | |
| task_duration_frames: int = 1, | |
| fps: float = 24.0, | |
| generation_mode: str = "multi_frames", | |
| sampling_plan: str = "light", | |
| sampling_mode: str = "single", | |
| sampler: Any | None = None, | |
| sigmas: Any | None = None, | |
| sampler_2nd: Any | None = None, | |
| sigmas_2nd: Any | None = None, | |
| run_second_pass: bool = False, | |
| has_context_second_pass: bool = False, | |
| turbo_hint: bool = False, | |
| ) -> io.NodeOutput: | |
| del previous | |
| try: | |
| if project_static is not None: | |
| if not isinstance(project_static, dict): | |
| raise TypeError("project_static must contain H3 project static data") | |
| segment_cache = project_static.get("_segment_cache") | |
| segment_cache_key = ( | |
| "h3_project_segment", | |
| int(task_start_frame), | |
| int(task_index), | |
| int(task_duration_frames), | |
| float(fps), | |
| str(generation_mode), | |
| ) | |
| cached_segment = ( | |
| segment_cache.get(segment_cache_key) | |
| if isinstance(segment_cache, dict) | |
| else None | |
| ) | |
| if ( | |
| isinstance(cached_segment, tuple) | |
| and len(cached_segment) == 2 | |
| ): | |
| cached_task_info = cached_segment[0] | |
| cached_task_info["_easy_media_cache_status"] = { | |
| **project_static.get("_cache_status", {}), | |
| "segment_media": "cache-hit", | |
| } | |
| return io.NodeOutput( | |
| project_static, | |
| cached_task_info, | |
| None, None, None, None, None, None, | |
| cached_segment[1], | |
| None, None, None, None, None, | |
| ) | |
| task_audio, task_video, task_locked_audio = crop_multitrack_project_media( | |
| project_static["shared_audio"], project_static["shared_video"], | |
| project_static["full_locked_audio"], int(task_start_frame), | |
| int(task_duration_frames), float(fps), | |
| ) | |
| task_info = prepare_multitrack_project_task_info( | |
| project_static["task_tracks_info_base"], project_static["shared_images"], | |
| task_audio if generation_mode == "reference" else [], | |
| task_video if generation_mode == "reference" else [], | |
| task_entry=next( | |
| (entry for index, entry in enumerate(h3_task_entries(project_static["task_tracks_info_base"])) | |
| if index == int(task_index)), | |
| None, | |
| ), | |
| ) | |
| multitrack_runtime_cache(task_info, create=True) | |
| task_info["_easy_media_cache_status"] = { | |
| **project_static.get("_cache_status", {}), | |
| "segment_media": "first-load", | |
| } | |
| if isinstance(segment_cache, dict): | |
| segment_cache[segment_cache_key] = ( | |
| task_info, | |
| task_locked_audio, | |
| ) | |
| return io.NodeOutput( | |
| project_static, task_info, None, None, None, None, None, | |
| None, task_locked_audio, | |
| None, None, None, None, None, | |
| ) | |
| media_data = None | |
| locked = None | |
| if tracks_info is not None: | |
| runtime_cache = multitrack_runtime_cache( | |
| tracks_info, | |
| create=True, | |
| ) | |
| cached_media = ( | |
| runtime_cache.get("h3_project_media") | |
| if isinstance(runtime_cache, dict) | |
| else None | |
| ) | |
| if isinstance(cached_media, dict): | |
| media_data = cached_media | |
| locked = cached_media.get("full_locked_audio") | |
| media_data["_cache_status"] = { | |
| "project_media": "cache-hit", | |
| } | |
| else: | |
| info = parse_tracks_info(tracks_info) | |
| log_node_info( | |
| "H3 Project Static Prepare", | |
| "loading project media (incl. locked video audio)", | |
| ) | |
| task_base, images, audio, video, locked = ( | |
| prepare_multitrack_project_media(info) | |
| ) | |
| log_node_info("H3 Project Static Prepare", "project media loaded") | |
| task_base.pop(MULTITRACK_RUNTIME_CACHE_KEY, None) | |
| media_data = { | |
| "task_tracks_info_base": task_base, | |
| "shared_images": images, | |
| "shared_audio": audio, | |
| "shared_video": video, | |
| "full_locked_audio": locked, | |
| "_segment_cache": {}, | |
| "_cache_status": { | |
| "project_media": "first-load", | |
| }, | |
| } | |
| if isinstance(runtime_cache, dict): | |
| runtime_cache["h3_project_media"] = media_data | |
| if model_loader is None: | |
| return io.NodeOutput( | |
| media_data, None, None, None, None, None, None, None, | |
| locked, None, None, None, None, None, | |
| ) | |
| loader = _first_input(model_loader) | |
| if not isinstance(loader, dict): | |
| raise TypeError("model_loader must contain a FAST_MODEL_LOADER dictionary") | |
| model, clip, vae = loader.get("model"), loader.get("clip"), loader.get("vae") | |
| audio_vae, preview_vae = loader.get("audio_vae"), loader.get("preview_vae") | |
| missing = [name for name, value in (("model", model), ("clip", clip), | |
| ("vae", vae), ("audio_vae", audio_vae)) if value is None] | |
| if missing: | |
| raise ValueError("model_loader is missing required components: " + ", ".join(missing)) | |
| _require_minimax_h3_model(model) | |
| second_model = _h3_second_pass_model(model_loader_2nd, model=model) | |
| _require_minimax_h3_model(second_model) | |
| static_data = { | |
| "model": model, "second_model": second_model, "clip": clip, | |
| "vae": vae, "audio_vae": audio_vae, | |
| "preview_vae": preview_vae, | |
| } | |
| if media_data is not None: | |
| static_data = {**media_data, **static_data} | |
| first_is_turbo = detect_turbo_model(model).is_turbo or bool(turbo_hint) | |
| second_is_turbo = first_is_turbo | |
| if run_second_pass and second_model is not model: | |
| second_is_turbo = detect_turbo_model(second_model).is_turbo | |
| graph = GraphBuilder() | |
| first_sampler = None | |
| if sampling_mode == "passthrough": | |
| first_sampler = first_sigmas = None | |
| elif sampling_mode == "selflift": | |
| first_sigmas = _h3_resolve_selflift_sigmas( | |
| graph, sigmas=sigmas, preset_name=str(sampling_plan), | |
| is_turbo=first_is_turbo, | |
| ) | |
| else: | |
| first_sampler, first_sigmas = _h3_resolve_pass_sampling( | |
| graph, pass_name="first_pass", sampler=sampler, sigmas=sigmas, | |
| preset_name=str(sampling_plan), has_second_pass=sampling_mode == "dual", | |
| is_turbo=first_is_turbo, | |
| ) | |
| second_sampler = second_sigmas = context_sigmas = None | |
| if run_second_pass: | |
| custom_second = sampler_2nd is not None or sigmas_2nd is not None | |
| second_sampler, second_sigmas = _h3_resolve_pass_sampling( | |
| graph, pass_name="second_pass", sampler=sampler_2nd, | |
| sigmas=sigmas_2nd, preset_name=str(sampling_plan), | |
| has_second_pass=True, is_turbo=second_is_turbo, | |
| ) | |
| if has_context_second_pass: | |
| context_sigmas = _h3_resolve_context_second_pass_sigmas( | |
| graph, preset_name=str(sampling_plan), is_turbo=second_is_turbo, | |
| has_custom_second_pass_sampling=custom_second, | |
| ) | |
| return io.NodeOutput( | |
| static_data, None, model, second_model, clip, vae, audio_vae, | |
| preview_vae, locked, first_sampler, first_sigmas, second_sampler, | |
| second_sigmas, context_sigmas, expand=graph.finalize(), | |
| ) | |
| except (KeyError, TypeError, ValueError, RuntimeError) as error: | |
| raise RuntimeError(f"Failed to prepare H3 project static inputs: {error}") from error | |
| class EasyMultiTrackProject(io.ComfyNode): | |
| def _sampling_plan_options(cls) -> list[str]: | |
| """Return sorted sampling plan keys: user presets first, then 'custom'.""" | |
| return get_h3_preset_keys() | |
| def define_schema(cls) -> io.Schema: | |
| return io.Schema( | |
| node_id="easy multitrackProject", | |
| display_name="MultiTrack Project", | |
| category="EasyUse/MultiTrackEditor", | |
| description=( | |
| "Build and execute a multi-track Moxie project with optional " | |
| "first-pass and second-pass sampling." | |
| ), | |
| is_input_list=True, | |
| enable_expand=True, | |
| not_idempotent=True, | |
| inputs=[ | |
| TYPE_TRACKS_INFO.Input("tracks_info"), | |
| TYPE_FAST_MODEL_LOADER.Input( | |
| "model_loader", raw_link=True, lazy=True, | |
| ), | |
| TYPE_FAST_MODEL_LOADER.Input( | |
| "model_loader_2nd", | |
| optional=True, | |
| raw_link=True, | |
| lazy=True, | |
| tooltip=( | |
| "Optional second-pass model for Dual sampling or the " | |
| "high-resolution SelfLift stage. Encoding and VAE " | |
| "components remain from the first-pass loader." | |
| ), | |
| ), | |
| io.Sampler.Input( | |
| "sampler", optional=True, raw_link=True, lazy=True, | |
| ), | |
| io.Sampler.Input("sampler_2nd", optional=True, raw_link=True, lazy=True, tooltip=( | |
| "Optional second-pass sampler. " | |
| )), | |
| io.Sigmas.Input( | |
| "sigmas", optional=True, raw_link=True, lazy=True, | |
| ), | |
| io.Sigmas.Input("sigmas_2nd", optional=True, raw_link=True, lazy=True, tooltip=( | |
| "Optional second-pass sigmas. " | |
| )), | |
| io.String.Input("project_name", default=""), | |
| io.Combo.Input( | |
| "project_save", | |
| options=["new", "override"], | |
| default="override", | |
| ), | |
| io.Int.Input( | |
| "segment_start_number", | |
| default=1, | |
| min=1, | |
| max=0x7FFFFFFF, | |
| step=1, | |
| tooltip="The task segment start number." | |
| ), | |
| io.Int.Input( | |
| "segment_count", | |
| default=-1, | |
| min=-1, | |
| max=0x7FFFFFFF, | |
| step=1, | |
| tooltip=( | |
| "Maximum task segments in this queue. When set to -1, " | |
| "override mode deletes saved segments from " | |
| "segment_start_number onward before regeneration; new " | |
| "mode preserves existing video and latent files." | |
| ), | |
| ), | |
| io.Int.Input( | |
| "seed", | |
| default=42, | |
| min=0, | |
| max=0xFFFFFFFFFFFFFFFF, | |
| step=1, | |
| control_after_generate=io.ControlAfterGenerate.fixed, | |
| ), | |
| io.Combo.Input( | |
| "sampling_plan", | |
| options=cls._sampling_plan_options(), | |
| default="light", | |
| ), | |
| io.DynamicCombo.Input( | |
| "sampling_mode", | |
| options=[ | |
| io.DynamicCombo.Option("single", []), | |
| io.DynamicCombo.Option("dual", []), | |
| io.DynamicCombo.Option( | |
| "selflift", | |
| [ | |
| io.Float.Input( | |
| "transition_ratio", | |
| default=0.6, | |
| min=0.05, | |
| max=0.95, | |
| step=0.05, | |
| tooltip=( | |
| "Fraction of denoiser evaluations performed " | |
| "at low resolution." | |
| ), | |
| ), | |
| io.Float.Input( | |
| "lowres_scale", | |
| default=0.6, | |
| min=0.25, | |
| max=1.0, | |
| step=0.001, | |
| round=0.001, | |
| extra_dict={"precision": 3}, | |
| tooltip=( | |
| "Scale of the low-resolution prefix relative " | |
| "to the target latent." | |
| ), | |
| ), | |
| ], | |
| ), | |
| io.DynamicCombo.Option("passthrough", []), | |
| ], | |
| ), | |
| io.Boolean.Input( | |
| "1st_pass_only", | |
| default=False, | |
| tooltip=( | |
| "Run and save only the first selected segment's " | |
| "first pass. Turn this off on the next run to " | |
| "resume directly from that checkpoint at pass two." | |
| ), | |
| ), | |
| io.Boolean.Input("disable_2nd_noise", default=False, tooltip="Disable noise in second-pass for dual-sampling"), | |
| io.Float.Input( | |
| "upscale_by", | |
| default=1.250, | |
| min=1.0, | |
| max=8.0, | |
| step=0.001, | |
| round=0.001, | |
| extra_dict={"precision": 3}, | |
| ), | |
| io.Combo.Input( | |
| "upscale_model", | |
| options=["None"] | |
| + folder_paths.get_filename_list("latent_upscale_models"), | |
| default="None", | |
| ), | |
| io.DynamicCombo.Input( | |
| "enabled_tiling", | |
| options=[ | |
| io.DynamicCombo.Option("false", []), | |
| io.DynamicCombo.Option( | |
| "true", | |
| [ | |
| io.Int.Input( | |
| "tile_count", | |
| default=2, | |
| min=2, | |
| max=8, | |
| step=1, | |
| tooltip="Spatial tile count shared by Dual and SelfLift high-res sampling.", | |
| ), | |
| ], | |
| ), | |
| ], | |
| tooltip="Enable shared high-resolution H3 spatial tiling.", | |
| ), | |
| ], | |
| hidden=[io.Hidden.prompt, io.Hidden.unique_id], | |
| outputs=[ | |
| io.String.Output("PROJECT_NAME"), | |
| io.Audio.Output("LOCKED_AUDIO"), | |
| ], | |
| ) | |
| def execute(cls, **kwargs: Any) -> io.NodeOutput: | |
| node_name = "MultiTrack Project" | |
| progress_total = 100 | |
| progress_bar = ProgressBar(progress_total) | |
| progress_value = 0 | |
| def report_step(target: float) -> None: | |
| nonlocal progress_value | |
| progress_value = max( | |
| progress_value, | |
| min(progress_total, int(round(target))), | |
| ) | |
| progress_bar.update_absolute(progress_value, progress_total) | |
| report_step(0) | |
| selected_model_loader = _raw_project_input(kwargs.get("model_loader")) | |
| uses_linked_prepare = is_link(selected_model_loader) | |
| if not uses_linked_prepare and not isinstance(selected_model_loader, dict): | |
| raise TypeError("model_loader must contain a FAST_MODEL_LOADER dictionary.") | |
| model = None if uses_linked_prepare else selected_model_loader.get("model") | |
| clip = None if uses_linked_prepare else selected_model_loader.get("clip") | |
| vae = None if uses_linked_prepare else selected_model_loader.get("vae") | |
| audio_vae = None if uses_linked_prepare else selected_model_loader.get("audio_vae") | |
| preview_vae = None if uses_linked_prepare else selected_model_loader.get("preview_vae") | |
| preview_node_id = str( | |
| _first_input( | |
| getattr(getattr(cls, "hidden", None), "unique_id", None), | |
| "", | |
| ) | |
| ) | |
| has_sampling_preview = bool(preview_node_id) and (uses_linked_prepare or preview_vae is not None) | |
| missing_components = [] if uses_linked_prepare else [ | |
| name | |
| for name, value in (("model", model), ("clip", clip), ("vae", vae)) | |
| if value is None | |
| ] | |
| if missing_components: | |
| raise ValueError( | |
| "model_loader is missing required components: " | |
| + ", ".join(missing_components) | |
| ) | |
| if not uses_linked_prepare: | |
| _require_minimax_h3_model(model) | |
| info = parse_tracks_info(kwargs.get("tracks_info")) | |
| hidden_inputs = getattr(cls, "hidden", None) | |
| validate_h3_project_outputs( | |
| info, | |
| getattr(hidden_inputs, "prompt", None), | |
| getattr(hidden_inputs, "unique_id", None), | |
| ) | |
| report_step(5) | |
| sampling_mode, sampling_config = _h3_sampling_mode_config( | |
| kwargs.get("sampling_mode") | |
| ) | |
| is_passthrough = sampling_mode == "passthrough" | |
| is_selflift = sampling_mode == "selflift" | |
| has_second_pass = sampling_mode == "dual" | |
| transition_ratio = 0.6 | |
| lowres_scale = 0.6 | |
| tiling_enabled, tile_count = _h3_tiling_config(kwargs.get("enabled_tiling")) | |
| if is_selflift: | |
| transition_ratio = float( | |
| _first_input(sampling_config.get("transition_ratio"), 0.6) | |
| ) | |
| lowres_scale = float( | |
| _first_input(sampling_config.get("lowres_scale"), 0.6) | |
| ) | |
| legacy_highres_tiling = bool( | |
| _first_input(sampling_config.get("highres_tiling"), False) | |
| ) | |
| if legacy_highres_tiling and not tiling_enabled: | |
| tiling_enabled = True | |
| tile_count = 0 | |
| first_pass_only = bool( | |
| _first_input( | |
| sampling_config.get("1st_pass_only"), | |
| _first_input(kwargs.get("1st_pass_only"), False), | |
| ) | |
| ) | |
| run_second_pass = has_second_pass and not first_pass_only | |
| if is_selflift: | |
| first_pass_only = False | |
| disable_2nd_noise = bool( | |
| _first_input( | |
| sampling_config.get("disable_2nd_noise"), | |
| _first_input(kwargs.get("disable_2nd_noise"), False), | |
| ) | |
| ) | |
| second_model = model | |
| second_model_loader = None | |
| if run_second_pass or is_selflift: | |
| configured_second_loader = sampling_config.get("model_loader_2nd") | |
| second_model_loader = _raw_project_input( | |
| configured_second_loader if configured_second_loader is not None | |
| else kwargs.get("model_loader_2nd") | |
| ) | |
| if not uses_linked_prepare: | |
| second_model = _h3_second_pass_model(second_model_loader, model=model) | |
| _require_minimax_h3_model(second_model) | |
| report_step(10) | |
| hidden_inputs = getattr(cls, "hidden", None) | |
| prompt_turbo_detection = None | |
| turbo_detection = None if uses_linked_prepare else detect_turbo_model(model) | |
| if uses_linked_prepare or not turbo_detection.is_turbo: | |
| prompt_turbo_detection = detect_turbo_lora_from_prompt( | |
| getattr(hidden_inputs, "prompt", None), | |
| getattr(hidden_inputs, "unique_id", None), | |
| ) | |
| if uses_linked_prepare or prompt_turbo_detection is not None: | |
| turbo_detection = prompt_turbo_detection | |
| first_is_turbo = bool(turbo_detection and turbo_detection.is_turbo) | |
| report_step(15) | |
| second_turbo_detection = turbo_detection | |
| if run_second_pass and not uses_linked_prepare and second_model is not model: | |
| second_turbo_detection = detect_turbo_model(second_model) | |
| report_step(16) | |
| second_is_turbo = bool(second_turbo_detection and second_turbo_detection.is_turbo) | |
| safe_project_name = safe_h3_project_name(kwargs.get("project_name")) | |
| project_save = str(_first_input(kwargs.get("project_save"), "new")) | |
| if project_save not in {"new", "override"}: | |
| raise ValueError("project_save must be 'new' or 'override'") | |
| upscale_by = float( | |
| _first_input( | |
| sampling_config.get("upscale_by"), | |
| _first_input(kwargs.get("upscale_by"), 1.250), | |
| ) | |
| ) | |
| if not math.isfinite(upscale_by) or upscale_by < 1: | |
| raise ValueError( | |
| "upscale_by must be a finite value greater than or equal to 1" | |
| ) | |
| editor_width = int(info["width"]) | |
| editor_height = int(info["height"]) | |
| audio_only = (editor_width, editor_height) == (32, 32) | |
| fps = float(info["frame_rate"]) | |
| # Hidden half-res sampling (single-pass projects only): condition and | |
| # sample at half the editor-selected resolution; Moxie Project Video | |
| # Combine restores the exact editor resolution in pixel space before | |
| # its images output (utils/rtx_vsr.py). Every other sampling mode | |
| # keeps the editor size exactly as configured. | |
| first_pass_width, first_pass_height = editor_width, editor_height | |
| if sampling_mode == "single" and not audio_only: | |
| half_width = max(32, editor_width // 2) | |
| half_height = max(32, editor_height // 2) | |
| if (half_width, half_height) != (32, 32): | |
| first_pass_width, first_pass_height = half_width, half_height | |
| target_width, target_height = h3_second_pass_dimensions( | |
| editor_width, | |
| editor_height, | |
| run_second_pass, | |
| upscale_by, | |
| ) | |
| # Keep task inputs at the configured size; exports use the final size. | |
| output_info = {**info, "width": target_width, "height": target_height} | |
| initialize_h3_project( | |
| safe_project_name, | |
| output_info, | |
| folder_paths.get_output_directory(), | |
| ) | |
| all_entries = h3_task_entries(info) | |
| segment_start_number = int(_first_input(kwargs.get("segment_start_number"), 1)) | |
| if segment_start_number < 1: | |
| raise ValueError("segment_start_number must be at least 1") | |
| segment_start_index = segment_start_number - 1 | |
| segment_count = int(_first_input(kwargs.get("segment_count"), -1)) | |
| selected_entries = select_h3_task_entries( | |
| all_entries, | |
| segment_start_index, | |
| segment_count, | |
| ) | |
| if not selected_entries: | |
| raise ValueError( | |
| "No H3 task segments are available from segment_start_number." | |
| ) | |
| first_selected_index, first_selected_entry = selected_entries[0] | |
| first_selected_task = first_selected_entry.get("task", {}) | |
| first_selected_content = ( | |
| first_selected_task.get("content", {}) | |
| if isinstance(first_selected_task, dict) | |
| else {} | |
| ) | |
| first_selected_continuity = ( | |
| str(first_selected_content.get("continuity_mode", "shot")).lower() | |
| if isinstance(first_selected_content, dict) | |
| else "shot" | |
| ) | |
| if first_selected_continuity == "context_test": | |
| first_selected_continuity = "context" | |
| if ( | |
| first_selected_index > 0 | |
| and first_selected_continuity in H3_CONTEXT_CONTINUITY_MODES | |
| and not is_passthrough | |
| ): | |
| previous_index = first_selected_index - 1 | |
| if not has_h3_context_latent( | |
| safe_project_name, | |
| previous_index, | |
| resolution="high", | |
| output_directory=folder_paths.get_output_directory(), | |
| ): | |
| raise ValueError( | |
| f"Cannot start segment {first_selected_index + 1} with " | |
| f"{first_selected_continuity}: segment {previous_index + 1} " | |
| "has no active context latent. Generate or restore the " | |
| "previous segment first." | |
| ) | |
| if is_selflift and not has_h3_context_latent( | |
| safe_project_name, | |
| previous_index, | |
| resolution="low", | |
| output_directory=folder_paths.get_output_directory(), | |
| allow_low_fallback=False, | |
| ): | |
| raise ValueError( | |
| f"Cannot start SelfLift segment {first_selected_index + 1} " | |
| f"with {first_selected_continuity}: segment " | |
| f"{previous_index + 1} has no active low-resolution context " | |
| "latent. Regenerate the previous segment with SelfLift first." | |
| ) | |
| resume_task_index: int | None = None | |
| if run_second_pass and selected_entries: | |
| first_selected_index = selected_entries[0][0] | |
| if has_h3_first_pass_checkpoint( | |
| safe_project_name, | |
| first_selected_index, | |
| folder_paths.get_output_directory(), | |
| ): | |
| resume_task_index = first_selected_index | |
| log_node_info( | |
| node_name, | |
| f"Resuming segment {first_selected_index} from its first-pass checkpoint", | |
| ) | |
| if project_save == "override" and segment_count == -1: | |
| clear_h3_project_segments_from( | |
| safe_project_name, | |
| ( | |
| resume_task_index + 1 | |
| if resume_task_index is not None | |
| else segment_start_index | |
| ), | |
| folder_paths.get_output_directory(), | |
| ) | |
| report_step(19) | |
| if first_pass_only and has_second_pass: | |
| selected_entries = selected_entries[:1] | |
| log_node_info( | |
| node_name, | |
| f"Found {len(all_entries)} segments; processing {len(selected_entries)}", | |
| ) | |
| report_step(20) | |
| first_pass_seed = int(_first_input(kwargs.get("seed"), 42)) | |
| second_pass_seed = first_pass_seed | |
| selected_upscale_model = str( | |
| _first_input( | |
| sampling_config.get("upscale_model"), | |
| _first_input(kwargs.get("upscale_model"), "None"), | |
| ) | |
| ) | |
| if not uses_linked_prepare and audio_vae is None: | |
| raise ValueError( | |
| "model_loader must include audio_vae to decode Moxie audio." | |
| ) | |
| report_step(25) | |
| graph = GraphBuilder() | |
| report_step(27) | |
| preset_name = str(_first_input(kwargs.get("sampling_plan"), "medium")) | |
| has_context_second_pass = run_second_pass and any( | |
| task_index > 0 and isinstance(entry.get("task"), dict) | |
| and isinstance(entry["task"].get("content"), dict) | |
| and str(entry["task"]["content"].get("continuity_mode", "shot")).lower() | |
| in H3_CONTEXT_CONTINUITY_MODES for task_index, entry in selected_entries | |
| ) | |
| if uses_linked_prepare: | |
| def raw_sampling_input(name: str) -> Any: | |
| value = sampling_config.get(name) | |
| return _raw_project_input(value if value is not None else kwargs.get(name)) | |
| tracks_info_link = _hidden_project_input_link( | |
| hidden_inputs, | |
| "tracks_info", | |
| ) | |
| project_media_static = graph.node( | |
| "easy h3ProjectStaticPrepare", | |
| id="project_media_prepare", | |
| tracks_info=tracks_info_link or info, | |
| ) | |
| model_inputs = { | |
| "model_loader": selected_model_loader, | |
| "sampling_plan": preset_name, "sampling_mode": sampling_mode, | |
| "run_second_pass": run_second_pass, | |
| "has_context_second_pass": has_context_second_pass, | |
| "turbo_hint": first_is_turbo, | |
| } | |
| for name, value in ( | |
| ("model_loader_2nd", second_model_loader), | |
| ("sampler", raw_sampling_input("sampler")), | |
| ("sigmas", raw_sampling_input("sigmas")), | |
| ("sampler_2nd", raw_sampling_input("sampler_2nd")), | |
| ("sigmas_2nd", raw_sampling_input("sigmas_2nd")), | |
| ): | |
| if value is not None: | |
| model_inputs[name] = value | |
| project_model_static = graph.node( | |
| "easy h3ProjectStaticPrepare", | |
| id="project_model_prepare", | |
| **model_inputs, | |
| ) | |
| model, second_model = project_model_static.out(2), project_model_static.out(3) | |
| clip, vae = project_model_static.out(4), project_model_static.out(5) | |
| audio_vae, preview_vae = project_model_static.out(6), project_model_static.out(7) | |
| full_locked_audio = project_media_static.out(8) | |
| first_pass_sampler, first_pass_sigmas = project_model_static.out(9), project_model_static.out(10) | |
| second_pass_sampler, second_pass_sigmas = project_model_static.out(11), project_model_static.out(12) | |
| context_second_pass_sigmas = project_model_static.out(13) | |
| task_tracks_info_base = shared_images = shared_audio = shared_video = None | |
| else: | |
| (task_tracks_info_base, shared_images, shared_audio, shared_video, | |
| full_locked_audio) = prepare_multitrack_project_media(info) | |
| first_pass_sampler = first_pass_sigmas = None | |
| if not is_passthrough and any(task_index != resume_task_index for task_index, _ in selected_entries): | |
| if is_selflift: | |
| first_pass_sigmas = _h3_resolve_selflift_sigmas( | |
| graph, sigmas=_first_input(kwargs.get("sigmas")), | |
| preset_name=preset_name, is_turbo=first_is_turbo) | |
| else: | |
| first_pass_sampler, first_pass_sigmas = _h3_resolve_pass_sampling( | |
| graph, pass_name="first_pass", sampler=_first_input(kwargs.get("sampler")), | |
| sigmas=_first_input(kwargs.get("sigmas")), preset_name=preset_name, | |
| has_second_pass=has_second_pass, is_turbo=first_is_turbo) | |
| second_pass_sampler = second_pass_sigmas = context_second_pass_sigmas = None | |
| if run_second_pass: | |
| configured_sampler = _first_input(sampling_config.get("sampler_2nd"), _first_input(kwargs.get("sampler_2nd"))) | |
| configured_sigmas = _first_input(sampling_config.get("sigmas_2nd"), _first_input(kwargs.get("sigmas_2nd"))) | |
| custom_second = configured_sampler is not None or configured_sigmas is not None | |
| second_pass_sampler, second_pass_sigmas = _h3_resolve_pass_sampling( | |
| graph, pass_name="second_pass", sampler=configured_sampler, | |
| sigmas=configured_sigmas, preset_name=preset_name, | |
| has_second_pass=True, is_turbo=second_is_turbo) | |
| if has_context_second_pass: | |
| context_second_pass_sigmas = _h3_resolve_context_second_pass_sigmas( | |
| graph, preset_name=preset_name, is_turbo=second_is_turbo, | |
| has_custom_second_pass_sampling=custom_second) | |
| report_step(31) | |
| report_step(33) | |
| previous_hires_context_latent: Any | None = None | |
| previous_low_context_latent: Any | None = None | |
| previous_artifact: Any | None = None | |
| last_project_output: Any | None = None | |
| report_step(35) | |
| segment_total = len(selected_entries) | |
| segment_nodes: dict[str, int] = {} | |
| for segment_position, (task_index, entry) in enumerate(selected_entries): | |
| previous_graph_nodes = set(graph.nodes) | |
| def report_segment_step( | |
| phase: float, | |
| *, | |
| current_position: int = segment_position, | |
| ) -> None: | |
| target = 35 + 60 * (current_position + phase) / segment_total | |
| report_step(target) | |
| task_type = h3_task_type(entry, info) | |
| generation_mode = h3_generation_mode(task_type) | |
| task = entry.get("task", {}) | |
| content = task.get("content", {}) if isinstance(task, dict) else {} | |
| continuity_mode = ( | |
| str(content.get("continuity_mode", "shot")).lower() | |
| if isinstance(content, dict) | |
| else "shot" | |
| ) | |
| if continuity_mode == "context_test": | |
| continuity_mode = "context" | |
| uses_context = continuity_mode in H3_CONTEXT_CONTINUITY_MODES | |
| uses_swap = continuity_mode == "context_swap" | |
| locked_audio_track = h3_locked_audio_track(entry, info) | |
| locked_video_track = h3_locked_video_track(entry, info) | |
| has_task_locked_audio = locked_audio_track is not None | |
| preserve_source_timing = not audio_only and ( | |
| has_task_locked_audio or locked_video_track is not None | |
| ) | |
| fit_locked_video_timing = ( | |
| preserve_source_timing | |
| and locked_video_track is not None | |
| ) | |
| ref_image_size = ( | |
| str(content.get("ref_image_size", "match")).lower() | |
| if isinstance(content, dict) | |
| else "match" | |
| ) | |
| report_segment_step(0.0) | |
| report_segment_step(0.04) | |
| task_start_frame = max(0, int(entry.get("start_frame", 0))) | |
| task_end_frame = max( | |
| task_start_frame, | |
| int(entry.get("end_frame", task_start_frame)), | |
| ) | |
| task_duration_frames = max(1, task_end_frame - task_start_frame) | |
| if uses_linked_prepare: | |
| segment_static = graph.node( | |
| "easy h3ProjectStaticPrepare", id=f"segment_static_prepare_{task_index}", | |
| project_static=project_media_static.out(0), task_start_frame=task_start_frame, | |
| task_index=task_index, | |
| task_duration_frames=task_duration_frames, fps=fps, | |
| generation_mode="reference" if is_passthrough else generation_mode, | |
| **( | |
| {"previous": previous_artifact} | |
| if previous_artifact is not None | |
| else {} | |
| ), | |
| ) | |
| task_tracks_info, task_locked_audio = segment_static.out(1), segment_static.out(8) | |
| task_output = graph.node( | |
| "easy multiTrackTaskOutput", | |
| id=f"task_{task_index}", | |
| tracks_info=task_tracks_info, | |
| task_index=task_index, | |
| prompt_format="default", | |
| ) | |
| base_task_length = task_output.out(3) | |
| if preserve_source_timing: | |
| base_task_length = task_duration_frames | |
| else: | |
| task_shared_audio, task_shared_video, task_locked_audio = crop_multitrack_project_media( | |
| shared_audio, shared_video, full_locked_audio, task_start_frame, | |
| task_duration_frames, fps, | |
| ) | |
| task_tracks_info = prepare_multitrack_project_task_info( | |
| task_tracks_info_base, shared_images, | |
| task_shared_audio if generation_mode == "reference" or is_passthrough else [], | |
| task_shared_video if generation_mode == "reference" or is_passthrough else [], | |
| task_entry=entry, | |
| ) | |
| task_output = graph.node( | |
| "easy multiTrackTaskOutput", id=f"task_{task_index}", | |
| tracks_info=task_tracks_info, | |
| **({"previous": previous_artifact} if previous_artifact is not None else {}), | |
| task_index=task_index, prompt_format="default", | |
| ) | |
| base_task_length = task_output.out(3) | |
| if preserve_source_timing: | |
| base_task_length = task_duration_frames | |
| if is_passthrough: | |
| if audio_only: | |
| raise ValueError("Passthrough requires a video project, not audio-only mode") | |
| passthrough = graph.node( | |
| "easy h3PassthroughVideo", | |
| id=f"passthrough_video_{task_index}", | |
| videos=task_output.out(6), | |
| frame_count=task_duration_frames, | |
| fps=fps, | |
| ) | |
| output_images, output_audio = passthrough.out(0), passthrough.out(1) | |
| context_latent = _h3_encode_context_media( | |
| graph, output_images, output_audio, vae, audio_vae, | |
| f"passthrough_context_{task_index}", | |
| ) | |
| runtime_context_latent = graph.node( | |
| "easy h3MotionContextLatentTrim", | |
| id=f"trim_hires_context_latent_{task_index}", | |
| latent=context_latent, | |
| context_length=str(H3_CONTEXT_SOURCE_FRAMES), | |
| ).out(0) | |
| saved_video = graph.node( | |
| "easy saveVideo", | |
| id=f"save_video_{task_index}", | |
| input_mode="images+audio", | |
| **{ | |
| "input_mode.images": output_images, | |
| "input_mode.audio": output_audio, | |
| "input_mode.fps": fps, | |
| "output_mode": "hide&save", | |
| }, | |
| filename_prefix=( | |
| f"easy_media/projects/{safe_project_name}/" | |
| f".staging_video_{task_index}" | |
| ), | |
| ) | |
| saved_video_end = graph.node( | |
| "easy h3SegmentSaveEnd", | |
| id=f"save_end_{task_index}", | |
| video_path=saved_video.out(1), | |
| project_name=safe_project_name, | |
| segment_index=task_index, | |
| ) | |
| artifact_inputs = { | |
| "project_name": safe_project_name, | |
| "project_save": project_save, | |
| "segment_index": task_index, | |
| "context_latent": runtime_context_latent, | |
| "video_path": saved_video_end.out(0), | |
| "tracks_info": output_info, | |
| "continuity_mode": continuity_mode, | |
| "seed": first_pass_seed, | |
| "sampling_pass": "single", | |
| } | |
| if previous_artifact is not None: | |
| artifact_inputs["previous"] = previous_artifact | |
| artifact = graph.node( | |
| "easy h3ProjectArtifact", | |
| id=f"artifact_{task_index}", | |
| **artifact_inputs, | |
| ) | |
| previous_artifact = last_project_output = artifact.out(0) | |
| previous_hires_context_latent = runtime_context_latent | |
| previous_low_context_latent = runtime_context_latent | |
| segment_nodes.update({ | |
| node_id: task_index | |
| for node_id in graph.nodes.keys() - previous_graph_nodes | |
| }) | |
| report_segment_step(1.0) | |
| continue | |
| aligned_task_length: Any = ( | |
| minimax_frame_count(base_task_length, round_up=True) | |
| if preserve_source_timing | |
| else base_task_length | |
| ) | |
| task_length: Any = aligned_task_length | |
| will_have_context_continuity = ( | |
| uses_context | |
| and (previous_hires_context_latent is not None or task_index > 0) | |
| ) | |
| context_source_frames = H3_CONTEXT_SOURCE_FRAMES | |
| context_generation_frames = 34 | |
| if will_have_context_continuity: | |
| task_length = graph.node( | |
| "ComfyMathExpression", | |
| id=f"context_length_{task_index}", | |
| expression=f"a + {context_generation_frames}", | |
| **{"values.a": task_length}, | |
| ).out(1) | |
| report_segment_step(0.10) | |
| conditioning_inputs = { | |
| "clip": clip, "vae": vae, "audio_vae": audio_vae, | |
| "images": task_output.out(4), "prompt": task_output.out(1), | |
| "mode": generation_mode, | |
| "width": target_width if is_selflift else first_pass_width, | |
| "height": target_height if is_selflift else first_pass_height, | |
| "length": task_length, "ref_image_size": ref_image_size, | |
| } | |
| if generation_mode == "reference": | |
| conditioning_inputs.update({ | |
| "audios": task_output.out(5), | |
| "videos": task_output.out(6), | |
| }) | |
| if fit_locked_video_timing: | |
| conditioning_inputs["locked_video_timing_frames"] = ( | |
| aligned_task_length | |
| ) | |
| encoded_conditioning = graph.node( | |
| "easy minimaxH3ToVideo", | |
| id=f"conditioning_{task_index}", | |
| **conditioning_inputs, | |
| ) | |
| conditioning = graph.node( | |
| "easy h3ConditioningCache", | |
| id=f"conditioning_cache_{task_index}", | |
| project_name=safe_project_name, | |
| segment_index=task_index, | |
| tracks_info=task_tracks_info, | |
| task_output_ready=task_output.out(1), | |
| model=model, | |
| clip=clip, | |
| vae=vae, | |
| audio_vae=audio_vae, | |
| conditioning=encoded_conditioning.out(0), | |
| latent=encoded_conditioning.out(1), | |
| ) | |
| base_positive = second_pass_positive = conditioning.out(0) | |
| # Reference conditioning has no canvas-sized keyframes. Its text and | |
| # media embeddings can be shared by both sampling resolutions. | |
| if run_second_pass and generation_mode != "reference" and ( | |
| first_pass_width, | |
| first_pass_height, | |
| ) != (target_width, target_height): | |
| second_inputs = { | |
| **conditioning_inputs, | |
| "width": target_width, | |
| "height": target_height, | |
| } | |
| second_pass_positive = graph.node( | |
| "easy minimaxH3ToVideo", | |
| id=f"second_pass_conditioning_{task_index}", | |
| **second_inputs, | |
| ).out(0) | |
| initial_latent = conditioning.out(1) | |
| positive = base_positive | |
| if ( | |
| uses_context | |
| and previous_hires_context_latent is None | |
| and task_index > 0 | |
| ): | |
| report_segment_step(0.14) | |
| loaded_hires_context = graph.node( | |
| "easy h3ProjectContextLatentLoad", | |
| id=f"load_hires_context_{task_index}", | |
| project_name=safe_project_name, | |
| segment_index=task_index - 1, | |
| resolution="high", | |
| ) | |
| previous_hires_context_latent = loaded_hires_context.out(0) | |
| if has_second_pass or is_selflift: | |
| loaded_low_context = graph.node( | |
| "easy h3ProjectContextLatentLoad", | |
| id=f"load_low_context_{task_index}", | |
| project_name=safe_project_name, | |
| segment_index=task_index - 1, | |
| resolution="low", | |
| ) | |
| previous_low_context_latent = loaded_low_context.out(0) | |
| else: | |
| previous_low_context_latent = previous_hires_context_latent | |
| report_segment_step(0.18) | |
| context_trim_frames: Any | None = None | |
| first_pass_context_trim_frames: Any | None = None | |
| first_pass_context_latent = ( | |
| previous_low_context_latent | |
| if has_second_pass | |
| else previous_hires_context_latent | |
| ) | |
| has_context_continuity = ( | |
| uses_context | |
| and first_pass_context_latent is not None | |
| ) | |
| # Lock task audio after the context source is known so its timeline | |
| # can be shifted behind the copied source prefix. The extra 12 | |
| # generated frames required by H3's temporal grid are removed from | |
| # the tail after decoding, not from the task's opening frames. | |
| if has_task_locked_audio: | |
| report_segment_step(0.20) | |
| initial_latent = graph.node( | |
| "easy minimaxH3AudioLock", | |
| id=f"audio_lock_{task_index}", | |
| latent=initial_latent, | |
| audio_vae=audio_vae, | |
| audio=task_locked_audio, | |
| remix_strength=1.0, | |
| short_audio_mode="silence", | |
| prepend_frames=( | |
| context_source_frames if has_context_continuity else 0 | |
| ), | |
| frame_rate=fps, | |
| ).out(0) | |
| first_pass_sampling_model = model | |
| if has_context_continuity: | |
| report_segment_step(0.22) | |
| if uses_swap: | |
| context_swap = graph.node( | |
| "easy MiniMaxH3ContextSwap", | |
| id=f"first_pass_context_swap_noise_{task_index}", | |
| model=model, | |
| latent=initial_latent, | |
| context_latent=first_pass_context_latent, | |
| sigmas=first_pass_sigmas, | |
| context_length=str(context_source_frames), | |
| seed=(first_pass_seed + task_index) & 0xFFFFFFFFFFFFFFFF, | |
| continue_audio=True, | |
| freeze_audio=False, | |
| ) | |
| first_pass_sampling_model = context_swap.out(0) | |
| initial_latent = context_swap.out(1) | |
| first_pass_context_trim_frames = context_swap.out(2) | |
| context_trim_frames = first_pass_context_trim_frames | |
| else: | |
| motion_context = graph.node( | |
| "easy MiniMaxH3MotionContextHard", | |
| id=f"hard_motion_context_{task_index}", | |
| conditioning=positive, | |
| vae=vae, | |
| latent=initial_latent, | |
| context_latent=first_pass_context_latent, | |
| context_length=str(context_source_frames), | |
| video_transition_steps=4, | |
| audio_transition_steps=4, | |
| video_anchor_only=not audio_only, | |
| ) | |
| positive = motion_context.out(0) | |
| first_pass_context_trim_frames = motion_context.out(1) | |
| context_trim_frames = first_pass_context_trim_frames | |
| initial_latent = motion_context.out(2) | |
| else: | |
| report_segment_step(0.22) | |
| report_segment_step(0.28) | |
| selflift_low_latent: Any | None = None | |
| if is_selflift: | |
| report_segment_step(0.38) | |
| selflift_inputs: dict[str, Any] = { | |
| "model": first_pass_sampling_model, | |
| "highres_model": ( | |
| second_model | |
| if second_model_loader is not None | |
| else first_pass_sampling_model | |
| ), | |
| "positive": positive, | |
| "vae": vae, | |
| "latent_image": initial_latent, | |
| "sigmas": first_pass_sigmas, | |
| "seed": first_pass_seed, | |
| "transition_ratio": transition_ratio, | |
| "lowres_scale": lowres_scale, | |
| "rho": 0.1 if has_context_continuity else 0.0, | |
| "w_max": 0.7, | |
| "w_min": 0.25, | |
| "upscaler_model": selected_upscale_model, | |
| "enabled_tiling": tiling_enabled, | |
| "tile_count": tile_count, | |
| } | |
| if has_sampling_preview: | |
| selflift_inputs.update({ | |
| "preview_vae": preview_vae, | |
| "preview_node_id": preview_node_id, | |
| "generated_frame_count": task_length, | |
| "preview_fps": fps, | |
| "segment_index": task_index, | |
| "sampling_pass": "selflift", | |
| }) | |
| if ( | |
| has_context_continuity | |
| and previous_low_context_latent is not None | |
| ): | |
| selflift_inputs["low_context_latent"] = previous_low_context_latent | |
| if previous_artifact is not None: | |
| selflift_inputs["previous"] = previous_artifact | |
| selflift_sample = graph.node( | |
| "easy minimaxH3SelfLiftSampler", | |
| id=f"selflift_sample_{task_index}", | |
| **selflift_inputs, | |
| ) | |
| first_pass_latent = selflift_sample.out(0) | |
| selflift_low_latent = selflift_sample.out(1) | |
| else: | |
| first_pass_guider = graph.node( | |
| "BasicGuider", | |
| id=f"first_pass_guider_{task_index}", | |
| model=first_pass_sampling_model, | |
| conditioning=positive, | |
| ) | |
| if task_index == resume_task_index: | |
| report_segment_step(0.38) | |
| first_pass_latent = graph.node( | |
| "easy h3ProjectContextLatentLoad", | |
| id=f"resume_first_pass_{task_index}", | |
| project_name=safe_project_name, | |
| segment_index=task_index, | |
| ).out(0) | |
| else: | |
| report_segment_step(0.32) | |
| first_pass_noise = graph.node( | |
| "RandomNoise", | |
| id=f"first_pass_noise_{task_index}", | |
| noise_seed=first_pass_seed, | |
| ) | |
| report_segment_step(0.38) | |
| sampling_inputs: dict[str, Any] = { | |
| "noise": first_pass_noise.out(0), | |
| "guider": first_pass_guider.out(0), | |
| "sampler": first_pass_sampler, | |
| "sigmas": first_pass_sigmas, | |
| "latent_image": initial_latent, | |
| "project_name": safe_project_name, | |
| "segment_index": task_index, | |
| } | |
| if previous_artifact is not None: | |
| sampling_inputs["previous"] = previous_artifact | |
| sampling_start = graph.node( | |
| "easy h3SegmentSamplingStart", | |
| id=f"sampling_start_{task_index}", | |
| sampling_pass="first", | |
| **sampling_inputs, | |
| ) | |
| preview_inputs = ( | |
| { | |
| "preview_vae": preview_vae, | |
| "preview_node_id": preview_node_id, | |
| "generated_frame_count": task_length, | |
| "preview_fps": fps, | |
| "segment_index": task_index, | |
| "sampling_pass": "first", | |
| } | |
| if has_sampling_preview | |
| else {} | |
| ) | |
| first_pass_sample = graph.node( | |
| "easy h3SamplingPreviewSampler" | |
| if has_sampling_preview | |
| else "SamplerCustomAdvanced", | |
| id=f"first_pass_sample_{task_index}", | |
| noise=sampling_start.out(0), | |
| guider=sampling_start.out(1), | |
| sampler=sampling_start.out(2), | |
| sigmas=sampling_start.out(3), | |
| latent_image=sampling_start.out(4), | |
| **preview_inputs, | |
| ) | |
| first_pass_latent = first_pass_sample.out(1) | |
| final_latent = first_pass_latent | |
| report_segment_step(0.42) | |
| if run_second_pass: | |
| segment_second_pass_sigmas = second_pass_sigmas | |
| if ( | |
| uses_context | |
| and previous_hires_context_latent is not None | |
| and context_second_pass_sigmas is not None | |
| ): | |
| segment_second_pass_sigmas = context_second_pass_sigmas | |
| if audio_only or upscale_by <= 1: | |
| report_segment_step(0.46) | |
| upscaled_latent = final_latent | |
| else: | |
| report_segment_step(0.45) | |
| separated = graph.node( | |
| "LTXVSeparateAVLatent", | |
| id=f"separate_first_pass_{task_index}", | |
| av_latent=final_latent, | |
| ) | |
| if selected_upscale_model != "None": | |
| report_segment_step(0.50) | |
| upscaled_video = graph.node( | |
| "easy minimaxH3LatentUpscaler", | |
| id=f"latent_upscale_{task_index}", | |
| **_h3_latent_upscale_inputs( | |
| separated.out(0), | |
| selected_upscale_model, | |
| target_width, | |
| target_height | |
| ), | |
| ) | |
| video_latent = upscaled_video.out(0) | |
| else: | |
| report_segment_step(0.48) | |
| first_pass_images = graph.node( | |
| "VAEDecode", | |
| id=f"first_pass_decode_{task_index}", | |
| samples=separated.out(0), | |
| vae=vae, | |
| ) | |
| report_segment_step(0.51) | |
| resized = graph.node( | |
| "ImageResizeKJv2", | |
| id=f"first_pass_resize_{task_index}", | |
| **_h3_image_resize_inputs( | |
| first_pass_images.out(0), | |
| target_width, | |
| target_height, | |
| ), | |
| ) | |
| report_segment_step(0.54) | |
| encoded_video = graph.node( | |
| "VAEEncode", | |
| id=f"first_pass_reencode_{task_index}", | |
| pixels=resized.out(0), | |
| vae=vae, | |
| ) | |
| video_latent = encoded_video.out(0) | |
| report_segment_step(0.57) | |
| upscaled_latent = graph.node( | |
| "LTXVConcatAVLatent", | |
| id=f"first_pass_recombine_{task_index}", | |
| video_latent=video_latent, | |
| audio_latent=separated.out(1), | |
| ).out(0) | |
| second_pass_sampling_model = second_model | |
| if ( | |
| uses_context | |
| and previous_hires_context_latent is not None | |
| ): | |
| report_segment_step(0.59) | |
| # Second pass is deliberately kept as an ordinary hi-res refine path. | |
| # No context noise, no split-prior, and no Drift-Control patching here. | |
| hires_continuity = graph.node( | |
| "easy MiniMaxH3HiResContinuity", | |
| id=f"hires_continuity_{task_index}", | |
| current_hires_latent=upscaled_latent, | |
| previous_hires_latent=previous_hires_context_latent, | |
| context_length=str(H3_CONTEXT_SOURCE_FRAMES), | |
| video_transition_steps=4, | |
| video_anchor_only=not audio_only, | |
| ) | |
| upscaled_latent = hires_continuity.out(0) | |
| context_trim_frames = hires_continuity.out(1) | |
| report_segment_step(0.62) | |
| second_pass_noise = graph.node( | |
| "DisableNoise" if disable_2nd_noise else "RandomNoise", | |
| id=f"second_pass_noise_{task_index}", | |
| **({} if disable_2nd_noise else {"noise_seed": second_pass_seed}), | |
| ) | |
| report_segment_step(0.66) | |
| second_pass_guider = graph.node( | |
| "BasicGuider", | |
| id=f"second_pass_guider_{task_index}", | |
| model=second_pass_sampling_model, | |
| conditioning=second_pass_positive, | |
| ) | |
| report_segment_step(0.71) | |
| second_sampling_start = graph.node( | |
| "easy h3SegmentSamplingStart", | |
| id=f"second_sampling_start_{task_index}", | |
| noise=second_pass_noise.out(0), | |
| guider=second_pass_guider.out(0), | |
| sampler=second_pass_sampler, | |
| sigmas=segment_second_pass_sigmas, | |
| latent_image=upscaled_latent, | |
| project_name=safe_project_name, | |
| segment_index=task_index, | |
| sampling_pass="second", | |
| ) | |
| second_preview_inputs = ( | |
| { | |
| "preview_vae": preview_vae, | |
| "preview_node_id": preview_node_id, | |
| "generated_frame_count": task_length, | |
| "preview_fps": fps, | |
| "segment_index": task_index, | |
| "sampling_pass": "second", | |
| } | |
| if has_sampling_preview | |
| else {} | |
| ) | |
| if tiling_enabled: | |
| second_preview_inputs.update({ | |
| "enabled_tiling": True, | |
| "tile_count": tile_count, | |
| }) | |
| second_pass_sample = graph.node( | |
| "easy h3SamplingPreviewSampler" | |
| if has_sampling_preview or tiling_enabled | |
| else "SamplerCustomAdvanced", | |
| id=f"second_pass_sample_{task_index}", | |
| noise=second_sampling_start.out(0), | |
| guider=second_sampling_start.out(1), | |
| sampler=second_sampling_start.out(2), | |
| sigmas=second_sampling_start.out(3), | |
| latent_image=second_sampling_start.out(4), | |
| **second_preview_inputs, | |
| ) | |
| final_latent = second_pass_sample.out(1) | |
| else: | |
| report_segment_step(0.71) | |
| low_stage_context_latent = ( | |
| selflift_low_latent | |
| if is_selflift and selflift_low_latent is not None | |
| else first_pass_latent | |
| if has_second_pass | |
| else final_latent | |
| ) | |
| hires_context_reencoded = False | |
| low_context_reencoded = False | |
| if audio_only: | |
| output_audio = graph.node( | |
| "VAEDecodeAudio", | |
| id=f"decode_audio_{task_index}", | |
| samples=final_latent, | |
| vae=audio_vae, | |
| ).out(0) | |
| project_hires_context_latent = final_latent | |
| project_low_context_latent = low_stage_context_latent | |
| if context_trim_frames is not None: | |
| output_audio, project_hires_context_latent = _h3_encode_audio_context( | |
| graph, output_audio, audio_vae, context_trim_frames, | |
| base_task_length, fps, not has_task_locked_audio, | |
| f"hires_audio_context_{task_index}", | |
| ) | |
| if (has_second_pass and run_second_pass) or ( | |
| is_selflift and selflift_low_latent is not None | |
| ): | |
| low_audio = graph.node( | |
| "VAEDecodeAudio", id=f"low_audio_context_decode_{task_index}", | |
| samples=low_stage_context_latent, vae=audio_vae, | |
| ).out(0) | |
| _, project_low_context_latent = _h3_encode_audio_context( | |
| graph, low_audio, audio_vae, first_pass_context_trim_frames, | |
| base_task_length, fps, not has_task_locked_audio, | |
| f"low_audio_context_{task_index}", | |
| ) | |
| else: | |
| project_low_context_latent = project_hires_context_latent | |
| report_segment_step(0.89) | |
| saved_audio = output_audio | |
| if has_task_locked_audio: | |
| saved_audio = graph.node( | |
| "easy h3LockedAudioSelect", | |
| id=f"locked_audio_select_{task_index}", | |
| generated_audio=output_audio, | |
| locked_audio=task_locked_audio, | |
| ).out(0) | |
| saved_media_inputs = { | |
| "audio": saved_audio, | |
| } | |
| else: | |
| report_segment_step(0.76) | |
| decoded_images = graph.node( | |
| "VAEDecode", | |
| id=f"decode_video_{task_index}", | |
| samples=final_latent, | |
| vae=vae, | |
| ) | |
| report_segment_step(0.80) | |
| decoded_audio = graph.node( | |
| "VAEDecodeAudio", | |
| id=f"decode_audio_{task_index}", | |
| samples=final_latent, | |
| vae=audio_vae, | |
| ) | |
| output_images = decoded_images.out(0) | |
| output_audio = decoded_audio.out(0) | |
| if context_trim_frames is not None or preserve_source_timing: | |
| report_segment_step(0.84) | |
| trimmed = graph.node( | |
| "easy h3ContextMediaTrim", | |
| id=f"motion_context_trim_{task_index}", | |
| images=output_images, | |
| audio=output_audio, | |
| trim_frames=( | |
| context_trim_frames if context_trim_frames is not None else 0 | |
| ), | |
| output_frames=base_task_length, | |
| pad_audio=not has_task_locked_audio, | |
| **( | |
| {"fit_video_duration": True} | |
| if fit_locked_video_timing | |
| else {} | |
| ), | |
| fps=fps, | |
| ) | |
| output_images = trimmed.out(0) | |
| output_audio = trimmed.out(1) | |
| else: | |
| report_segment_step(0.84) | |
| if has_task_locked_audio: | |
| locked_audio_align = graph.node( | |
| "easy h3LockedAudioDurationAlign", | |
| id=f"locked_audio_duration_align_{task_index}", | |
| images=output_images, | |
| audio=output_audio, | |
| fps=fps, | |
| ) | |
| output_audio = locked_audio_align.out(0) | |
| saved_audio = output_audio | |
| if has_task_locked_audio: | |
| saved_audio = graph.node( | |
| "easy h3LockedAudioSelect", | |
| id=f"locked_audio_select_{task_index}", | |
| generated_audio=output_audio, | |
| locked_audio=task_locked_audio, | |
| ).out(0) | |
| project_hires_context_latent = final_latent | |
| project_low_context_latent = low_stage_context_latent | |
| low_delivered_images: Any | None = None | |
| if context_trim_frames is not None or preserve_source_timing: | |
| # Rebuild continuity from the delivered span after removing | |
| # the optional context head and temporal-grid tail. | |
| project_hires_context_latent = _h3_encode_context_media( | |
| graph, | |
| output_images, | |
| output_audio, | |
| vae, | |
| audio_vae, | |
| f"hires_context_{task_index}", | |
| ) | |
| hires_context_reencoded = True | |
| if (has_second_pass and run_second_pass) or ( | |
| is_selflift and selflift_low_latent is not None | |
| ): | |
| low_context_images = graph.node( | |
| "VAEDecode", | |
| id=f"low_context_video_decode_{task_index}", | |
| samples=low_stage_context_latent, | |
| vae=vae, | |
| ) | |
| low_context_audio = graph.node( | |
| "VAEDecodeAudio", | |
| id=f"low_context_audio_decode_{task_index}", | |
| samples=low_stage_context_latent, | |
| vae=audio_vae, | |
| ) | |
| low_context_media = graph.node( | |
| "easy h3ContextMediaTrim", | |
| id=f"low_context_trim_{task_index}", | |
| images=low_context_images.out(0), | |
| audio=low_context_audio.out(0), | |
| trim_frames=( | |
| first_pass_context_trim_frames | |
| if first_pass_context_trim_frames is not None | |
| else 0 | |
| ), | |
| output_frames=base_task_length, | |
| **( | |
| {"fit_video_duration": True} | |
| if fit_locked_video_timing | |
| else {} | |
| ), | |
| fps=fps, | |
| ) | |
| low_delivered_images = low_context_media.out(0) | |
| project_low_context_latent = _h3_encode_context_media( | |
| graph, | |
| low_context_media.out(0), | |
| low_context_media.out(1), | |
| vae, | |
| audio_vae, | |
| f"low_context_{task_index}", | |
| ) | |
| low_context_reencoded = True | |
| else: | |
| project_low_context_latent = project_hires_context_latent | |
| low_context_reencoded = hires_context_reencoded | |
| report_segment_step(0.89) | |
| # Keep decoded audio for latent continuity, but deliver the original | |
| # task audio in the video so the project needs no separate WAV. | |
| saved_video = graph.node( | |
| "easy saveVideo", | |
| id=f"save_video_{task_index}", | |
| input_mode="images+audio", | |
| **{ | |
| "input_mode.images": output_images, | |
| "input_mode.audio": saved_audio, | |
| "input_mode.fps": fps, | |
| "output_mode": "hide&save", | |
| }, | |
| filename_prefix=( | |
| f"easy_media/projects/{safe_project_name}/" | |
| f".staging_video_{task_index}" | |
| ), | |
| ) | |
| saved_video_end = graph.node( | |
| "easy h3SegmentSaveEnd", | |
| id=f"save_end_{task_index}", | |
| video_path=saved_video.out(1), | |
| project_name=safe_project_name, | |
| segment_index=task_index, | |
| ) | |
| saved_media_inputs = {"video_path": saved_video_end.out(0)} | |
| saved_hires_context_latent = project_hires_context_latent | |
| saved_low_context_latent = project_low_context_latent | |
| hires_trim_inputs: dict[str, Any] = { | |
| "latent": saved_hires_context_latent, | |
| "context_length": str(context_source_frames), | |
| } | |
| if not audio_only and not hires_context_reencoded: | |
| hires_trim_inputs.update({"anchor_images": output_images, "vae": vae}) | |
| runtime_hires_context_latent = graph.node( | |
| "easy h3MotionContextLatentTrim", | |
| id=f"trim_hires_context_latent_{task_index}", | |
| **hires_trim_inputs, | |
| ).out(0) | |
| if has_second_pass or is_selflift: | |
| low_trim_inputs: dict[str, Any] = { | |
| "latent": saved_low_context_latent, | |
| "context_length": str(context_source_frames), | |
| } | |
| if not audio_only and not low_context_reencoded: | |
| low_anchor_images = low_delivered_images | |
| if low_anchor_images is None: | |
| if (has_second_pass and run_second_pass) or ( | |
| is_selflift and selflift_low_latent is not None | |
| ): | |
| low_anchor_images = graph.node( | |
| "VAEDecode", | |
| id=f"low_anchor_decode_video_{task_index}", | |
| samples=low_stage_context_latent, | |
| vae=vae, | |
| ).out(0) | |
| else: | |
| low_anchor_images = output_images | |
| low_trim_inputs.update( | |
| {"anchor_images": low_anchor_images, "vae": vae} | |
| ) | |
| runtime_low_context_latent = graph.node( | |
| "easy h3MotionContextLatentTrim", | |
| id=f"trim_low_context_latent_{task_index}", | |
| **low_trim_inputs, | |
| ).out(0) | |
| else: | |
| runtime_low_context_latent = runtime_hires_context_latent | |
| completed_sampling_pass = ( | |
| "first" | |
| if first_pass_only and has_second_pass | |
| else "second" | |
| if has_second_pass | |
| else "single" | |
| ) | |
| # A pass-two resume needs the full sampled span, while continuity | |
| # uses the re-encoded delivered span (also saved as low context). | |
| checkpoint_latent = ( | |
| first_pass_latent if preserve_source_timing else saved_hires_context_latent | |
| ) | |
| artifact_inputs: dict[str, Any] = { | |
| "project_name": safe_project_name, | |
| "project_save": project_save, | |
| "segment_index": task_index, | |
| "context_latent": ( | |
| checkpoint_latent | |
| if completed_sampling_pass == "first" | |
| else runtime_hires_context_latent | |
| ), | |
| **saved_media_inputs, | |
| "tracks_info": output_info, | |
| "continuity_mode": continuity_mode, | |
| "seed": first_pass_seed, | |
| "sampling_pass": completed_sampling_pass, | |
| } | |
| if has_second_pass or is_selflift: | |
| artifact_inputs["context_latent_low"] = ( | |
| saved_low_context_latent | |
| if completed_sampling_pass == "first" | |
| else runtime_low_context_latent | |
| ) | |
| if previous_artifact is not None: | |
| artifact_inputs["previous"] = previous_artifact | |
| report_segment_step(0.95) | |
| artifact = graph.node( | |
| "easy h3ProjectArtifact", | |
| id=f"artifact_{task_index}", | |
| **artifact_inputs, | |
| ) | |
| previous_artifact = artifact.out(0) | |
| last_project_output = artifact.out(0) | |
| previous_hires_context_latent = runtime_hires_context_latent | |
| previous_low_context_latent = runtime_low_context_latent | |
| segment_nodes.update({ | |
| node_id: task_index | |
| for node_id in graph.nodes.keys() - previous_graph_nodes | |
| }) | |
| report_segment_step(1.0) | |
| if last_project_output is None: | |
| log_node_info(node_name, "Project graph produced no task output") | |
| raise RuntimeError("H3 project graph produced no task output") | |
| report_step(100) | |
| return io.NodeOutput( | |
| last_project_output, | |
| full_locked_audio, | |
| expand=_timed_h3_project_graph(graph, safe_project_name, segment_nodes), | |
| ) | |
| class EasyMultiTrackProjectVideoCombine(io.ComfyNode): | |
| """Combine a project timeline configured by the React project widget.""" | |
| def define_schema(cls) -> io.Schema: | |
| return io.Schema( | |
| node_id="easy multitrackProjectVideoCombine", | |
| display_name="MultiTrack Project Video Combine", | |
| category="EasyUse/MultiTrackEditor", | |
| description=( | |
| "Preview and combine the active videos from a MultiTrack project." | |
| ), | |
| inputs=[ | |
| io.String.Input("project_name", force_input=True), | |
| TYPE_PROJECT_DATA.Input("project_data"), | |
| ], | |
| hidden=[io.Hidden.unique_id], | |
| outputs=[ | |
| io.Video.Output("VIDEO"), | |
| io.String.Output("FILENAME_PREFIX"), | |
| ], | |
| not_idempotent=True, | |
| ) | |
| def execute(cls, project_name: str, project_data: Any) -> io.NodeOutput: | |
| data = project_data | |
| if isinstance(data, str): | |
| try: | |
| data = json.loads(data) | |
| except json.JSONDecodeError as error: | |
| raise ValueError(f"project_data is not valid JSON: {error}") from error | |
| if not isinstance(data, dict): | |
| raise TypeError("project_data must contain a dictionary or JSON object") | |
| auto_combine = data.get("auto_combine", True) is not False | |
| safe_name = safe_h3_project_name(project_name) | |
| if safe_h3_project_name(data.get("project_name")) != safe_name: | |
| data = {"project_name": safe_name, "clips": []} | |
| try: | |
| from server import PromptServer | |
| PromptServer.instance.send_sync( | |
| "easy-media.project.selected", | |
| { | |
| "node_id": str( | |
| getattr(getattr(cls, "hidden", None), "unique_id", "") | |
| ), | |
| "project_name": safe_name, | |
| }, | |
| ) | |
| except (AttributeError, ImportError, RuntimeError) as error: | |
| print( # noqa: T201 - rendering must not fail when UI notifications are unavailable | |
| f"[Moxie][Project] Unable to notify the frontend: {error}" | |
| ) | |
| if not auto_combine: | |
| blocker = ExecutionBlocker(None) | |
| return io.NodeOutput(blocker, blocker) | |
| composed_path = compose_h3_project_video(safe_name, data) | |
| # Hidden half-res sampling contract: the timeline is composed at the | |
| # segments' own (sampling) size, so restore the manifest resolution | |
| # here - once - before any output leaves this node. Videos already at | |
| # the project resolution pass through untouched. | |
| video = restore_project_video_resolution( | |
| InputImpl.VideoFromFile(str(composed_path)), | |
| safe_name, | |
| source_path=str(composed_path), | |
| ) | |
| return io.NodeOutput( | |
| video, | |
| h3_project_filename_prefix(safe_name), | |
| ) | |