Moxie-Multimedia / nodes /project.py
turtle89431's picture
Upload folder using huggingface_hub (part 2)
3afd6d6 verified
Raw History Blame Contribute Delete
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."""
@classmethod
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,
)
@classmethod
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."""
@classmethod
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"),
],
)
@classmethod
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):
@classmethod
def _sampling_plan_options(cls) -> list[str]:
"""Return sorted sampling plan keys: user presets first, then 'custom'."""
return get_h3_preset_keys()
@classmethod
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"),
],
)
@classmethod
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."""
@classmethod
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,
)
@classmethod
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),
)