")
+
+ event_handler_function(ui_components, demo)
+
+ try:
+ import mcp_tools as mcp
+ if hasattr(mcp, "register_high_level_mcp_apis"):
+ mcp.register_high_level_mcp_apis(demo)
+ mcp.cleanup_dependencies_api_names(demo)
+ elif hasattr(mcp, 'MCP_FUNCTIONS') and isinstance(mcp.MCP_FUNCTIONS, list):
+ for func in mcp.MCP_FUNCTIONS:
+ gr.api(func)
+ print(f"â
Registered MCP API endpoint: '{func.__name__}'")
+ except Exception as e:
+ print(f"â ī¸ Warning registering MCP functions: {e}")
+
+ # Disable API exposure for all atomic UI event handlers
+ high_level_names = getattr(mcp, "HIGH_LEVEL_MCP_API_NAMES", set())
+ for fn in demo.fns.values():
+ if getattr(fn, "api_name", None) not in high_level_names:
+ fn.show_api = False
+ fn.api_name = False
+
+ return demo
\ No newline at end of file
diff --git a/ui/shared/h3_fl2va_logic.py b/ui/shared/h3_fl2va_logic.py
new file mode 100644
index 0000000000000000000000000000000000000000..ea30a86e13aa58cff4c569fb78b8a8cff0d22a45
--- /dev/null
+++ b/ui/shared/h3_fl2va_logic.py
@@ -0,0 +1,138 @@
+import os
+import math
+import gradio as gr
+from core.workflow_assembler import WorkflowAssembler
+from utils.app_utils import (
+ handle_seed,
+ save_temp_image,
+ get_filename_prefix,
+ process_lora_inputs,
+ process_h3_controlnet_inputs,
+ process_h3_guide_inputs
+)
+
+WORKFLOW_RECIPE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "h3_fl2va_recipe.yaml")
+
+RESOLUTION_PRESETS = {
+ "1080p": {
+ "16:9 (Landscape)": (1920, 1088),
+ "9:16 (Portrait)": (1088, 1920),
+ "1:1 (Square)": (1440, 1440),
+ "4:3 (Classic TV)": (1664, 1248),
+ "3:4 (Classic Portrait)": (1248, 1664),
+ "3:2 (Landscape)": (1760, 1184),
+ "2:3 (Portrait)": (1184, 1760),
+ },
+ "768p": {
+ "16:9 (Landscape)": (1344, 768),
+ "9:16 (Portrait)": (768, 1344),
+ "1:1 (Square)": (1024, 1024),
+ "4:3 (Classic TV)": (1152, 864),
+ "3:4 (Classic Portrait)": (864, 1152),
+ "3:2 (Landscape)": (1248, 832),
+ "2:3 (Portrait)": (832, 1248),
+ },
+ "544p": {
+ "16:9 (Landscape)": (960, 544),
+ "9:16 (Portrait)": (544, 960),
+ "1:1 (Square)": (736, 736),
+ "4:3 (Classic TV)": (832, 640),
+ "3:4 (Classic Portrait)": (640, 832),
+ "3:2 (Landscape)": (864, 576),
+ "2:3 (Portrait)": (576, 864),
+ },
+ "720p": {
+ "16:9 (Landscape)": (1344, 768),
+ "9:16 (Portrait)": (768, 1344),
+ "1:1 (Square)": (1024, 1024),
+ "4:3 (Classic TV)": (1152, 864),
+ "3:4 (Classic Portrait)": (864, 1152),
+ "3:2 (Landscape)": (1248, 832),
+ "2:3 (Portrait)": (832, 1248),
+ },
+ "480p": {
+ "16:9 (Landscape)": (960, 544),
+ "9:16 (Portrait)": (544, 960),
+ "1:1 (Square)": (736, 736),
+ "4:3 (Classic TV)": (832, 640),
+ "3:4 (Classic Portrait)": (640, 832),
+ "3:2 (Landscape)": (864, 576),
+ "2:3 (Portrait)": (576, 864),
+ }
+}
+
+ASPECT_RATIO_PRESETS = RESOLUTION_PRESETS["544p"]
+
+def calculate_h3_frame_length(duration_seconds: float) -> int:
+ """
+ Converts duration (seconds) at 24fps to a valid frame length
+ snapped up to the model's 17-frame-per-block (17k+5) grid.
+ Grid sequence: 5, 22, 39, 56, 73, 90, 107, 124, 141...
+ """
+ raw_frames = int(round(duration_seconds * 24))
+ if raw_frames <= 5:
+ return 5
+ return 5 + 17 * math.ceil((raw_frames - 5) / 17)
+
+def process_inputs(ui_values, seed_override=None, progress=None):
+ local_ui_values = ui_values.copy()
+
+ width = int(local_ui_values.get('width') or 0)
+ height = int(local_ui_values.get('height') or 0)
+
+ if width <= 0 or height <= 0:
+ resolution = local_ui_values.get('resolution', '544p')
+ selected_ratio = local_ui_values.get('aspect_ratio', "16:9 (Landscape)")
+ width, height = RESOLUTION_PRESETS.get(resolution, {}).get(selected_ratio, (960, 544))
+
+ local_ui_values['width'] = width
+ local_ui_values['height'] = height
+ local_ui_values['sampler_name'] = local_ui_values.get('sampler_name') or local_ui_values.get('sampler') or 'res_multistep'
+ local_ui_values['scheduler'] = local_ui_values.get('scheduler') or 'simple'
+ local_ui_values['steps'] = int(local_ui_values.get('steps') or 20)
+
+ duration = float(local_ui_values.get('duration', 3.0))
+ local_ui_values['length'] = calculate_h3_frame_length(duration)
+
+ seed = seed_override if seed_override is not None else int(local_ui_values.get('seed', -1))
+ local_ui_values['seed'] = handle_seed(seed)
+
+ filename_prefix = get_filename_prefix()
+ local_ui_values['filename_prefix'] = f"video/{filename_prefix}"
+
+ local_ui_values['loras'] = process_lora_inputs(ui_values, 'h3_fl2va', progress=progress)
+ local_ui_values['h3_controlnets'] = process_h3_controlnet_inputs(ui_values, 'h3_fl2va', progress=progress)
+ local_ui_values['h3_guides'] = process_h3_guide_inputs(ui_values, 'h3_fl2va', progress=progress)
+
+ assembler = WorkflowAssembler(WORKFLOW_RECIPE_PATH)
+ final_workflow = assembler.assemble(local_ui_values)
+
+ first_frame_img = local_ui_values.get('first_frame')
+ if first_frame_img is not None:
+ saved_first_img = save_temp_image(first_frame_img)
+ if saved_first_img:
+ ff_loader_id = assembler.add_node("LoadImage", {"image": saved_first_img}, title="Load Image")
+ ff_scale_id = assembler.add_node("ImageScale", {
+ "image": [ff_loader_id, 0],
+ "width": width,
+ "height": height,
+ "upscale_method": "nearest-exact",
+ "crop": "center"
+ }, title="Upscale Image")
+ assembler.connect(ff_scale_id, 0, "minimax_h3", "first_frame")
+
+ last_frame_img = local_ui_values.get('last_frame')
+ if last_frame_img is not None:
+ saved_last_img = save_temp_image(last_frame_img)
+ if saved_last_img:
+ lf_loader_id = assembler.add_node("LoadImage", {"image": saved_last_img}, title="Load Image")
+ lf_scale_id = assembler.add_node("ImageScale", {
+ "image": [lf_loader_id, 0],
+ "width": width,
+ "height": height,
+ "upscale_method": "nearest-exact",
+ "crop": "center"
+ }, title="Upscale Image")
+ assembler.connect(lf_scale_id, 0, "minimax_h3", "last_frame")
+
+ return final_workflow, None
diff --git a/ui/shared/h3_fl2va_recipe.yaml b/ui/shared/h3_fl2va_recipe.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..0076a34d28217edc145e237c6eebb570b154d36c
--- /dev/null
+++ b/ui/shared/h3_fl2va_recipe.yaml
@@ -0,0 +1,157 @@
+nodes:
+ unet_loader:
+ class_type: UNETLoader
+ title: "Load Diffusion Model"
+ params:
+ unet_name: "minimax_h3_fl2va_pruned_nvfp4.safetensors"
+ weight_dtype: "default"
+ clip_loader:
+ class_type: CLIPLoader
+ title: "Load CLIP"
+ params:
+ clip_name: "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors"
+ type: "minimax"
+ device: "default"
+ video_vae_loader:
+ class_type: VAELoader
+ title: "Load Video VAE"
+ params:
+ vae_name: "minimax_h3_video_vae_fp16.safetensors"
+ audio_vae_loader:
+ class_type: VAELoader
+ title: "Load Audio VAE"
+ params:
+ vae_name: "minimax_h3_audio_vae_fp32.safetensors"
+ minimax_h3:
+ class_type: MiniMaxH3ImageToVideo
+ title: "MiniMax H3 Image to Video"
+ params:
+ width: 1344
+ height: 768
+ length: 73
+ basic_guider:
+ class_type: BasicGuider
+ title: "Basic Guider"
+ ksampler_select:
+ class_type: KSamplerSelect
+ title: "KSamplerSelect"
+ params:
+ sampler_name: "res_multistep"
+ basic_scheduler:
+ class_type: BasicScheduler
+ title: "BasicScheduler"
+ params:
+ scheduler: "simple"
+ steps: 20
+ denoise: 1.0
+ random_noise:
+ class_type: RandomNoise
+ title: "RandomNoise"
+ params:
+ noise_seed: 1
+ sampler_custom_advanced:
+ class_type: SamplerCustomAdvanced
+ title: "SamplerCustomAdvanced"
+ vae_decode:
+ class_type: VAEDecode
+ title: "VAE Decode"
+ vae_decode_audio:
+ class_type: VAEDecodeAudio
+ title: "VAE Decode Audio"
+ create_video:
+ class_type: CreateVideo
+ title: "Create Video"
+ params:
+ fps: 24
+ bit_depth: 8
+ save_video:
+ class_type: SaveVideo
+ title: "Save Video"
+ params:
+ filename_prefix: "video/MiniMax_H3"
+ format: "mp4"
+ codec:
+ codec: "h264"
+
+dynamic_lora_chains:
+ loras:
+ template: LoraLoader
+ output_map:
+ "unet_loader:0": model
+ "clip_loader:0": clip
+ input_map:
+ model: model
+ clip: clip
+ template_output_map:
+ "0": model
+ "1": clip
+ end_input_map:
+ model:
+ - "basic_guider:model"
+ - "basic_scheduler:model"
+ clip:
+ - "minimax_h3:clip"
+
+dynamic_h3_controlnet_chains:
+ h3_controlnets:
+ template: MiniMaxH3FunControlNetApply
+ vae_source: "video_vae_loader:0"
+ target_nodes:
+ - "basic_guider:model"
+ - "basic_scheduler:model"
+
+dynamic_h3_guide_chains:
+ h3_guides:
+ template: MiniMaxH3AddGuide
+ source_node: "minimax_h3"
+ vae_source: "video_vae_loader:0"
+ audio_vae_source: "audio_vae_loader:0"
+ target_nodes:
+ - "basic_guider:conditioning"
+
+connections:
+ - from: "clip_loader:0"
+ to: "minimax_h3:clip"
+ - from: "video_vae_loader:0"
+ to: "minimax_h3:vae"
+ - from: "unet_loader:0"
+ to: "basic_guider:model"
+ - from: "minimax_h3:0"
+ to: "basic_guider:conditioning"
+ - from: "unet_loader:0"
+ to: "basic_scheduler:model"
+ - from: "random_noise:0"
+ to: "sampler_custom_advanced:noise"
+ - from: "basic_guider:0"
+ to: "sampler_custom_advanced:guider"
+ - from: "ksampler_select:0"
+ to: "sampler_custom_advanced:sampler"
+ - from: "basic_scheduler:0"
+ to: "sampler_custom_advanced:sigmas"
+ - from: "minimax_h3:1"
+ to: "sampler_custom_advanced:latent_image"
+ - from: "sampler_custom_advanced:0"
+ to: "vae_decode:samples"
+ - from: "video_vae_loader:0"
+ to: "vae_decode:vae"
+ - from: "sampler_custom_advanced:0"
+ to: "vae_decode_audio:samples"
+ - from: "audio_vae_loader:0"
+ to: "vae_decode_audio:vae"
+ - from: "vae_decode:0"
+ to: "create_video:images"
+ - from: "vae_decode_audio:0"
+ to: "create_video:audio"
+ - from: "create_video:0"
+ to: "save_video:video"
+
+ui_map:
+ prompt: "minimax_h3:prompt"
+ width: "minimax_h3:width"
+ height: "minimax_h3:height"
+ length: "minimax_h3:length"
+ seed: "random_noise:noise_seed"
+ filename_prefix: "save_video:filename_prefix"
+ sampler_name: "ksampler_select:sampler_name"
+ scheduler: "basic_scheduler:scheduler"
+ steps: "basic_scheduler:steps"
diff --git a/ui/shared/h3_fl2va_ui.py b/ui/shared/h3_fl2va_ui.py
new file mode 100644
index 0000000000000000000000000000000000000000..5d5c7a69839841ccbf8c93a83d5a3cdabb9395da
--- /dev/null
+++ b/ui/shared/h3_fl2va_ui.py
@@ -0,0 +1,235 @@
+import gradio as gr
+from .h3_fl2va_logic import process_inputs, RESOLUTION_PRESETS
+from .ui_components import create_lora_settings_ui, create_h3_controlnet_ui, create_h3_guide_ui
+from comfy_integration.nodes import SAMPLER_CHOICES, SCHEDULER_CHOICES
+from core.generation_logic import generate_h3_video_wrapper
+
+UI_INFO = {
+ "workflow_recipe": "h3_fl2va_recipe.yaml",
+ "main_tab": "VideoGen",
+ "sub_tab": "H3 FL2VA",
+ "run_button_text": "đŦ Generate H3 Video"
+}
+
+def create_ui():
+ components = {}
+ with gr.Column():
+ gr.Markdown("## MiniMax H3 Video Generation")
+ gr.Markdown("đĄ **Tip:** No image for T2VA; upload First Frame for I2VA; upload First Frame & Last Frame for FL2VA. Under the default ZeroGPU Duration (s) of 120s, tasks with 544p resolution, 20 steps, and 5s duration pass successfully.")
+
+ with gr.Row():
+ components['first_frame'] = gr.Image(type="pil", label="First Frame (Optional)", height=220)
+ components['last_frame'] = gr.Image(type="pil", label="Last Frame (Optional)", height=220)
+
+ components['prompt'] = gr.Textbox(label="Prompt", lines=5)
+
+ with gr.Row():
+ with gr.Column(scale=1):
+ with gr.Row():
+ components['resolution'] = gr.Radio(
+ label="Resolution",
+ choices=["544p", "768p", "1080p"],
+ value="544p",
+ interactive=True
+ )
+
+ with gr.Row():
+ components['aspect_ratio'] = gr.Dropdown(
+ label="Aspect Ratio",
+ choices=list(RESOLUTION_PRESETS["544p"].keys()),
+ value="16:9 (Landscape)",
+ interactive=True
+ )
+
+ with gr.Row():
+ components['width'] = gr.Number(label="Width", value=960, precision=0)
+ components['height'] = gr.Number(label="Height", value=544, precision=0)
+
+ with gr.Row(visible=False):
+ components['sampler'] = gr.Dropdown(
+ label="Sampler",
+ choices=SAMPLER_CHOICES,
+ value="res_multistep" if "res_multistep" in SAMPLER_CHOICES else (SAMPLER_CHOICES[0] if SAMPLER_CHOICES else "res_multistep"),
+ interactive=True,
+ visible=False
+ )
+ components['scheduler'] = gr.Dropdown(
+ label="Scheduler",
+ choices=SCHEDULER_CHOICES,
+ value="simple" if "simple" in SCHEDULER_CHOICES else (SCHEDULER_CHOICES[0] if SCHEDULER_CHOICES else "simple"),
+ interactive=True,
+ visible=False
+ )
+
+ with gr.Row(visible=False):
+ components['cfg_scale'] = gr.Slider(
+ label="CFG Scale",
+ minimum=1.0,
+ maximum=20.0,
+ step=0.1,
+ value=1.0,
+ interactive=True,
+ visible=False
+ )
+
+ with gr.Row():
+ components['steps'] = gr.Slider(
+ label="Steps",
+ minimum=1,
+ maximum=50,
+ step=1,
+ value=20,
+ interactive=True
+ )
+ components['duration'] = gr.Slider(
+ label="Duration (s)",
+ minimum=0.2,
+ maximum=15.0,
+ step=0.1,
+ value=5.0,
+ interactive=True
+ )
+
+ with gr.Row():
+ components['seed'] = gr.Number(label="Seed (-1 for random)", value=-1, precision=0)
+ components['zero_gpu_duration'] = gr.Number(
+ label="ZeroGPU Duration (s)",
+ value=120,
+ precision=0
+ )
+
+ with gr.Column(scale=1):
+ components['output_video'] = gr.Video(
+ label="Result Video",
+ interactive=False,
+ height=492
+ )
+
+ components.update(create_lora_settings_ui("h3_fl2va"))
+ components.update(create_h3_guide_ui("h3_fl2va"))
+ components.update(create_h3_controlnet_ui("h3_fl2va"))
+
+ components['run_button'] = gr.Button(UI_INFO["run_button_text"], variant="primary", elem_classes=["run-shortcut"])
+
+ return components
+
+def create_event_handlers(components: dict, demo: gr.Blocks):
+ def update_dimensions(resolution, aspect_ratio):
+ w, h = RESOLUTION_PRESETS.get(resolution, {}).get(aspect_ratio, (960, 544))
+ return w, h
+
+ components['resolution'].change(
+ fn=update_dimensions,
+ inputs=[components['resolution'], components['aspect_ratio']],
+ outputs=[components['width'], components['height']],
+ api_name=False
+ )
+
+ components['aspect_ratio'].change(
+ fn=update_dimensions,
+ inputs=[components['resolution'], components['aspect_ratio']],
+ outputs=[components['width'], components['height']],
+ api_name=False
+ )
+
+ lora_sources = components.get('lora_sources_h3_fl2va', [])
+ lora_ids = components.get('lora_ids_h3_fl2va', [])
+ lora_scales = components.get('lora_scales_h3_fl2va', [])
+
+ cn_videos = components.get('h3_controlnet_videos_h3_fl2va', [])
+ cn_types = components.get('h3_controlnet_types_h3_fl2va', [])
+ cn_series = components.get('h3_controlnet_series_h3_fl2va', [])
+ cn_strengths = components.get('h3_controlnet_strengths_h3_fl2va', [])
+ cn_start_percents = components.get('h3_controlnet_start_percents_h3_fl2va', [])
+ cn_end_percents = components.get('h3_controlnet_end_percents_h3_fl2va', [])
+ cn_filepaths = components.get('h3_controlnet_filepaths_h3_fl2va', [])
+
+ guide_images = components.get('h3_guide_images_h3_fl2va', [])
+ guide_videos = components.get('h3_guide_videos_h3_fl2va', [])
+ guide_audios = components.get('h3_guide_audios_h3_fl2va', [])
+ guide_times = components.get('h3_guide_times_h3_fl2va', [])
+ guide_frames = components.get('h3_guide_frames_h3_fl2va', [])
+
+ inputs_list = [
+ components['first_frame'],
+ components['last_frame'],
+ components['prompt'],
+ components['resolution'],
+ components['aspect_ratio'],
+ components['width'],
+ components['height'],
+ components['duration'],
+ components['sampler'],
+ components['scheduler'],
+ components['steps'],
+ components['cfg_scale'],
+ components['seed'],
+ components['zero_gpu_duration']
+ ] + lora_sources + lora_ids + lora_scales + cn_videos + cn_types + cn_series + cn_strengths + cn_start_percents + cn_end_percents + cn_filepaths + guide_images + guide_videos + guide_audios + guide_times + guide_frames
+
+ def on_run_click(*args, progress=gr.Progress(track_tqdm=True)):
+ first_frame, last_frame, prompt, resolution, aspect_ratio, width, height, duration, sampler, scheduler, steps, cfg_scale, seed, zero_gpu_duration = args[:14]
+ rest = args[14:]
+ num_loras = len(lora_sources)
+ l_sources = rest[:num_loras]
+ l_ids = rest[num_loras:2*num_loras]
+ l_scales = rest[2*num_loras:3*num_loras]
+
+ rest_cn = rest[3*num_loras:]
+ num_cns = len(cn_videos)
+ c_vids = rest_cn[:num_cns]
+ c_types = rest_cn[num_cns:2*num_cns]
+ c_series = rest_cn[2*num_cns:3*num_cns]
+ c_strengths = rest_cn[3*num_cns:4*num_cns]
+ c_starts = rest_cn[4*num_cns:5*num_cns]
+ c_ends = rest_cn[5*num_cns:6*num_cns]
+ c_fps = rest_cn[6*num_cns:7*num_cns]
+
+ rest_guide = rest_cn[7*num_cns:]
+ num_guides = len(guide_images)
+ g_imgs = rest_guide[:num_guides]
+ g_vids = rest_guide[num_guides:2*num_guides]
+ g_auds = rest_guide[2*num_guides:3*num_guides]
+ g_times = rest_guide[3*num_guides:4*num_guides]
+ g_frames = rest_guide[4*num_guides:5*num_guides] if guide_frames else []
+
+ ui_values = {
+ 'first_frame': first_frame,
+ 'last_frame': last_frame,
+ 'prompt': prompt,
+ 'resolution': resolution,
+ 'aspect_ratio': aspect_ratio,
+ 'width': width,
+ 'height': height,
+ 'duration': duration,
+ 'sampler_name': sampler,
+ 'scheduler': scheduler,
+ 'steps': steps,
+ 'cfg_scale': cfg_scale,
+ 'seed': seed,
+ 'zero_gpu_duration': zero_gpu_duration,
+ 'batch_count': 1,
+ 'lora_sources_h3_fl2va': list(l_sources),
+ 'lora_ids_h3_fl2va': list(l_ids),
+ 'lora_scales_h3_fl2va': list(l_scales),
+ 'h3_controlnet_videos_h3_fl2va': list(c_vids),
+ 'h3_controlnet_types_h3_fl2va': list(c_types),
+ 'h3_controlnet_series_h3_fl2va': list(c_series),
+ 'h3_controlnet_strengths_h3_fl2va': list(c_strengths),
+ 'h3_controlnet_start_percents_h3_fl2va': list(c_starts),
+ 'h3_controlnet_end_percents_h3_fl2va': list(c_ends),
+ 'h3_controlnet_filepaths_h3_fl2va': list(c_fps),
+ 'h3_guide_images_h3_fl2va': list(g_imgs),
+ 'h3_guide_videos_h3_fl2va': list(g_vids),
+ 'h3_guide_audios_h3_fl2va': list(g_auds),
+ 'h3_guide_times_h3_fl2va': list(g_times),
+ 'h3_guide_frames_h3_fl2va': list(g_frames),
+ }
+ return generate_h3_video_wrapper(process_inputs, ui_values, progress=progress)
+
+ components['run_button'].click(
+ fn=on_run_click,
+ inputs=inputs_list,
+ outputs=[components['output_video']],
+ api_name=False
+ )
diff --git a/ui/shared/h3_ref2va_logic.py b/ui/shared/h3_ref2va_logic.py
new file mode 100644
index 0000000000000000000000000000000000000000..d33f834e1954073935ba4dc32fe950eef3d31053
--- /dev/null
+++ b/ui/shared/h3_ref2va_logic.py
@@ -0,0 +1,137 @@
+import os
+import math
+import gradio as gr
+from core.workflow_assembler import WorkflowAssembler
+from utils.app_utils import (
+ handle_seed, save_temp_image, save_temp_video, save_temp_audio,
+ get_filename_prefix, process_lora_inputs, process_h3_controlnet_inputs,
+ process_h3_guide_inputs
+)
+
+WORKFLOW_RECIPE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "h3_ref2va_recipe.yaml")
+
+RESOLUTION_PRESETS = {
+ "1080p": {
+ "16:9 (Landscape)": (1920, 1088),
+ "9:16 (Portrait)": (1088, 1920),
+ "1:1 (Square)": (1440, 1440),
+ "4:3 (Classic TV)": (1664, 1248),
+ "3:4 (Classic Portrait)": (1248, 1664),
+ "3:2 (Landscape)": (1760, 1184),
+ "2:3 (Portrait)": (1184, 1760),
+ },
+ "768p": {
+ "16:9 (Landscape)": (1344, 768),
+ "9:16 (Portrait)": (768, 1344),
+ "1:1 (Square)": (1024, 1024),
+ "4:3 (Classic TV)": (1152, 864),
+ "3:4 (Classic Portrait)": (864, 1152),
+ "3:2 (Landscape)": (1248, 832),
+ "2:3 (Portrait)": (832, 1248),
+ },
+ "544p": {
+ "16:9 (Landscape)": (960, 544),
+ "9:16 (Portrait)": (544, 960),
+ "1:1 (Square)": (736, 736),
+ "4:3 (Classic TV)": (832, 640),
+ "3:4 (Classic Portrait)": (640, 832),
+ "3:2 (Landscape)": (864, 576),
+ "2:3 (Portrait)": (576, 864),
+ },
+ "720p": {
+ "16:9 (Landscape)": (1344, 768),
+ "9:16 (Portrait)": (768, 1344),
+ "1:1 (Square)": (1024, 1024),
+ "4:3 (Classic TV)": (1152, 864),
+ "3:4 (Classic Portrait)": (864, 1152),
+ "3:2 (Landscape)": (1248, 832),
+ "2:3 (Portrait)": (832, 1248),
+ },
+ "480p": {
+ "16:9 (Landscape)": (960, 544),
+ "9:16 (Portrait)": (544, 960),
+ "1:1 (Square)": (736, 736),
+ "4:3 (Classic TV)": (832, 640),
+ "3:4 (Classic Portrait)": (640, 832),
+ "3:2 (Landscape)": (864, 576),
+ "2:3 (Portrait)": (576, 864),
+ }
+}
+
+ASPECT_RATIO_PRESETS = RESOLUTION_PRESETS["544p"]
+
+def calculate_h3_frame_length(duration_seconds: float) -> int:
+ """
+ Converts duration (seconds) at 24fps to a valid frame length
+ snapped up to the model's 17-frame-per-block (17k+5) grid.
+ Grid sequence: 5, 22, 39, 56, 73, 90, 107, 124, 141...
+ """
+ raw_frames = int(round(duration_seconds * 24))
+ if raw_frames <= 5:
+ return 5
+ return 5 + 17 * math.ceil((raw_frames - 5) / 17)
+
+def process_inputs(ui_values, seed_override=None, progress=None):
+ local_ui_values = ui_values.copy()
+
+ width = int(local_ui_values.get('width') or 0)
+ height = int(local_ui_values.get('height') or 0)
+
+ if width <= 0 or height <= 0:
+ resolution = local_ui_values.get('resolution', '544p')
+ selected_ratio = local_ui_values.get('aspect_ratio', "16:9 (Landscape)")
+ width, height = RESOLUTION_PRESETS.get(resolution, {}).get(selected_ratio, (960, 544))
+
+ local_ui_values['width'] = width
+ local_ui_values['height'] = height
+ local_ui_values['sampler_name'] = local_ui_values.get('sampler_name') or local_ui_values.get('sampler') or 'res_multistep'
+ local_ui_values['scheduler'] = local_ui_values.get('scheduler') or 'simple'
+ local_ui_values['steps'] = int(local_ui_values.get('steps') or 20)
+
+ ref_images_input = local_ui_values.get('ref_images', [])
+ saved_ref_images = []
+ if isinstance(ref_images_input, list):
+ for img in ref_images_input:
+ if img is not None:
+ saved = save_temp_image(img)
+ if saved:
+ saved_ref_images.append(saved)
+ local_ui_values['ref_images'] = saved_ref_images
+
+ ref_videos_input = local_ui_values.get('ref_videos', [])
+ saved_ref_videos = []
+ if isinstance(ref_videos_input, list):
+ for video_path in ref_videos_input:
+ if video_path:
+ saved_vid = save_temp_video(video_path)
+ if saved_vid:
+ saved_ref_videos.append(saved_vid)
+ local_ui_values['ref_videos'] = saved_ref_videos
+
+ ref_audios_input = local_ui_values.get('ref_audios', [])
+ saved_ref_audios = []
+ if isinstance(ref_audios_input, list):
+ for audio_path in ref_audios_input:
+ if audio_path:
+ saved_aud = save_temp_audio(audio_path)
+ if saved_aud:
+ saved_ref_audios.append(saved_aud)
+ local_ui_values['ref_audios'] = saved_ref_audios
+
+ duration = float(local_ui_values.get('duration', 3.0))
+ local_ui_values['length'] = calculate_h3_frame_length(duration)
+
+ seed = seed_override if seed_override is not None else int(local_ui_values.get('seed', -1))
+ local_ui_values['seed'] = handle_seed(seed)
+
+ filename_prefix = get_filename_prefix()
+ local_ui_values['filename_prefix'] = f"video/{filename_prefix}"
+
+ local_ui_values['loras'] = process_lora_inputs(ui_values, 'h3_ref2va', progress=progress)
+ local_ui_values['h3_controlnets'] = process_h3_controlnet_inputs(ui_values, 'h3_ref2va', progress=progress)
+ local_ui_values['h3_guides'] = process_h3_guide_inputs(ui_values, 'h3_ref2va', progress=progress)
+
+ assembler = WorkflowAssembler(WORKFLOW_RECIPE_PATH)
+ final_workflow = assembler.assemble(local_ui_values)
+
+ return final_workflow, None
diff --git a/ui/shared/h3_ref2va_recipe.yaml b/ui/shared/h3_ref2va_recipe.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..39ddbc344e9d73ed05e25a9f113466dd98081357
--- /dev/null
+++ b/ui/shared/h3_ref2va_recipe.yaml
@@ -0,0 +1,172 @@
+nodes:
+ unet_loader:
+ class_type: UNETLoader
+ title: "Load Diffusion Model"
+ params:
+ unet_name: "minimax_h3_ref2va_pruned_nvfp4.safetensors"
+ weight_dtype: "default"
+ clip_loader:
+ class_type: CLIPLoader
+ title: "Load CLIP"
+ params:
+ clip_name: "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors"
+ type: "minimax"
+ device: "default"
+ video_vae_loader:
+ class_type: VAELoader
+ title: "Load Video VAE"
+ params:
+ vae_name: "minimax_h3_video_vae_fp16.safetensors"
+ audio_vae_loader:
+ class_type: VAELoader
+ title: "Load Audio VAE"
+ params:
+ vae_name: "minimax_h3_audio_vae_fp32.safetensors"
+ minimax_h3:
+ class_type: MiniMaxH3ReferenceToVideo
+ title: "MiniMax H3 Reference to Video"
+ params:
+ width: 1344
+ height: 768
+ length: 73
+ ref_image_size: "match"
+ basic_guider:
+ class_type: BasicGuider
+ title: "Basic Guider"
+ ksampler_select:
+ class_type: KSamplerSelect
+ title: "KSamplerSelect"
+ params:
+ sampler_name: "res_multistep"
+ basic_scheduler:
+ class_type: BasicScheduler
+ title: "BasicScheduler"
+ params:
+ scheduler: "simple"
+ steps: 20
+ denoise: 1.0
+ random_noise:
+ class_type: RandomNoise
+ title: "RandomNoise"
+ params:
+ noise_seed: 1
+ sampler_custom_advanced:
+ class_type: SamplerCustomAdvanced
+ title: "SamplerCustomAdvanced"
+ vae_decode:
+ class_type: VAEDecode
+ title: "VAE Decode"
+ vae_decode_audio:
+ class_type: VAEDecodeAudio
+ title: "VAE Decode Audio"
+ create_video:
+ class_type: CreateVideo
+ title: "Create Video"
+ params:
+ fps: 24
+ bit_depth: 8
+ save_video:
+ class_type: SaveVideo
+ title: "Save Video"
+ params:
+ filename_prefix: "video/MiniMax_H3"
+ format: "mp4"
+ codec:
+ codec: "h264"
+
+dynamic_lora_chains:
+ loras:
+ template: LoraLoader
+ output_map:
+ "unet_loader:0": model
+ "clip_loader:0": clip
+ input_map:
+ model: model
+ clip: clip
+ template_output_map:
+ "0": model
+ "1": clip
+ end_input_map:
+ model:
+ - "basic_guider:model"
+ - "basic_scheduler:model"
+ clip:
+ - "minimax_h3:clip"
+
+dynamic_h3_ref_img_chains:
+ ref_images:
+ target_node: "minimax_h3"
+
+dynamic_h3_ref_video_chains:
+ ref_videos:
+ target_node: "minimax_h3"
+
+dynamic_h3_ref_audio_chains:
+ ref_audios:
+ target_node: "minimax_h3"
+
+dynamic_h3_controlnet_chains:
+ h3_controlnets:
+ template: MiniMaxH3FunControlNetApply
+ vae_source: "video_vae_loader:0"
+ target_nodes:
+ - "basic_guider:model"
+ - "basic_scheduler:model"
+
+dynamic_h3_guide_chains:
+ h3_guides:
+ template: MiniMaxH3AddGuide
+ source_node: "minimax_h3"
+ vae_source: "video_vae_loader:0"
+ audio_vae_source: "audio_vae_loader:0"
+ target_nodes:
+ - "basic_guider:conditioning"
+
+connections:
+ - from: "clip_loader:0"
+ to: "minimax_h3:clip"
+ - from: "video_vae_loader:0"
+ to: "minimax_h3:vae"
+ - from: "audio_vae_loader:0"
+ to: "minimax_h3:audio_vae"
+ - from: "unet_loader:0"
+ to: "basic_guider:model"
+ - from: "minimax_h3:0"
+ to: "basic_guider:conditioning"
+ - from: "unet_loader:0"
+ to: "basic_scheduler:model"
+ - from: "random_noise:0"
+ to: "sampler_custom_advanced:noise"
+ - from: "basic_guider:0"
+ to: "sampler_custom_advanced:guider"
+ - from: "ksampler_select:0"
+ to: "sampler_custom_advanced:sampler"
+ - from: "basic_scheduler:0"
+ to: "sampler_custom_advanced:sigmas"
+ - from: "minimax_h3:1"
+ to: "sampler_custom_advanced:latent_image"
+ - from: "sampler_custom_advanced:0"
+ to: "vae_decode:samples"
+ - from: "video_vae_loader:0"
+ to: "vae_decode:vae"
+ - from: "sampler_custom_advanced:0"
+ to: "vae_decode_audio:samples"
+ - from: "audio_vae_loader:0"
+ to: "vae_decode_audio:vae"
+ - from: "vae_decode:0"
+ to: "create_video:images"
+ - from: "vae_decode_audio:0"
+ to: "create_video:audio"
+ - from: "create_video:0"
+ to: "save_video:video"
+
+ui_map:
+ prompt: "minimax_h3:prompt"
+ width: "minimax_h3:width"
+ height: "minimax_h3:height"
+ length: "minimax_h3:length"
+ seed: "random_noise:noise_seed"
+ filename_prefix: "save_video:filename_prefix"
+ sampler_name: "ksampler_select:sampler_name"
+ scheduler: "basic_scheduler:scheduler"
+ steps: "basic_scheduler:steps"
diff --git a/ui/shared/h3_ref2va_ui.py b/ui/shared/h3_ref2va_ui.py
new file mode 100644
index 0000000000000000000000000000000000000000..5695c2859cf9719f709927dcbbdbc4025007907f
--- /dev/null
+++ b/ui/shared/h3_ref2va_ui.py
@@ -0,0 +1,423 @@
+import gradio as gr
+from .h3_ref2va_logic import process_inputs, RESOLUTION_PRESETS
+from .ui_components import create_lora_settings_ui, create_h3_controlnet_ui, create_h3_guide_ui
+from comfy_integration.nodes import SAMPLER_CHOICES, SCHEDULER_CHOICES
+from core.generation_logic import generate_h3_video_wrapper
+
+UI_INFO = {
+ "workflow_recipe": "h3_ref2va_recipe.yaml",
+ "main_tab": "VideoGen",
+ "sub_tab": "H3 REF2VA",
+ "run_button_text": "đŦ Generate H3 Video"
+}
+
+MAX_REF_IMAGES = 9
+MAX_REF_VIDEOS = 3
+MAX_REF_AUDIOS = 3
+
+def create_ui():
+ components = {}
+ with gr.Column():
+ gr.Markdown("## MiniMax H3 Reference Video Generation")
+ gr.Markdown("đĄ **Tip:** Supports uploading up to 9 reference images, 3 reference videos with audio, and 3 reference audio files. Under the default ZeroGPU Duration (s) of 120s, tasks with 544p resolution, 20 steps, 5s duration, and 3 reference images pass successfully.")
+
+ components['prompt'] = gr.Textbox(label="Prompt", lines=5)
+
+ with gr.Row():
+ with gr.Column(scale=1):
+ with gr.Row():
+ components['resolution'] = gr.Radio(
+ label="Resolution",
+ choices=["544p", "768p", "1080p"],
+ value="544p",
+ interactive=True
+ )
+
+ with gr.Row():
+ components['aspect_ratio'] = gr.Dropdown(
+ label="Aspect Ratio",
+ choices=list(RESOLUTION_PRESETS["544p"].keys()),
+ value="16:9 (Landscape)",
+ interactive=True
+ )
+
+ with gr.Row():
+ components['width'] = gr.Number(label="Width", value=960, precision=0)
+ components['height'] = gr.Number(label="Height", value=544, precision=0)
+
+ with gr.Row(visible=False):
+ components['sampler'] = gr.Dropdown(
+ label="Sampler",
+ choices=SAMPLER_CHOICES,
+ value="res_multistep" if "res_multistep" in SAMPLER_CHOICES else (SAMPLER_CHOICES[0] if SAMPLER_CHOICES else "res_multistep"),
+ interactive=True,
+ visible=False
+ )
+ components['scheduler'] = gr.Dropdown(
+ label="Scheduler",
+ choices=SCHEDULER_CHOICES,
+ value="simple" if "simple" in SCHEDULER_CHOICES else (SCHEDULER_CHOICES[0] if SCHEDULER_CHOICES else "simple"),
+ interactive=True,
+ visible=False
+ )
+
+ with gr.Row(visible=False):
+ components['cfg_scale'] = gr.Slider(
+ label="CFG Scale",
+ minimum=1.0,
+ maximum=20.0,
+ step=0.1,
+ value=1.0,
+ interactive=True,
+ visible=False
+ )
+
+ with gr.Row():
+ components['steps'] = gr.Slider(
+ label="Steps",
+ minimum=1,
+ maximum=50,
+ step=1,
+ value=20,
+ interactive=True
+ )
+ components['duration'] = gr.Slider(
+ label="Duration (s)",
+ minimum=0.2,
+ maximum=15.0,
+ step=0.1,
+ value=5.0,
+ interactive=True
+ )
+
+ with gr.Row():
+ components['seed'] = gr.Number(label="Seed (-1 for random)", value=-1, precision=0)
+ components['zero_gpu_duration'] = gr.Number(
+ label="ZeroGPU Duration (s)",
+ value=120,
+ precision=0
+ )
+
+ with gr.Column(scale=1):
+ components['output_video'] = gr.Video(
+ label="Result Video",
+ interactive=False,
+ height=492
+ )
+
+ components.update(create_lora_settings_ui("h3_ref2va"))
+
+ with gr.Accordion("Reference Image Settings", open=False):
+ ref_image_groups = []
+ ref_image_inputs = []
+ with gr.Row():
+ for i in range(MAX_REF_IMAGES):
+ with gr.Column(visible=(i < 1), min_width=160) as img_col:
+ img_comp = gr.Image(
+ type="pil",
+ label=f"Ref Image {i+1}",
+ sources=["upload"],
+ height=160
+ )
+ ref_image_groups.append(img_col)
+ ref_image_inputs.append(img_comp)
+ components['ref_image_groups'] = ref_image_groups
+ components['ref_image_inputs'] = ref_image_inputs
+ components['ref_images'] = ref_image_inputs
+
+ with gr.Row():
+ components['add_ref_button'] = gr.Button("â Add Reference Image")
+ components['delete_ref_button'] = gr.Button("â Delete Reference Image", visible=True)
+ components['ref_count_state'] = gr.State(1)
+
+ with gr.Accordion("Reference Video Settings", open=False):
+ ref_video_groups = []
+ ref_video_inputs = []
+ with gr.Row():
+ for i in range(MAX_REF_VIDEOS):
+ with gr.Column(visible=(i < 1), min_width=200) as vid_col:
+ vid_comp = gr.Video(
+ label=f"Ref Video {i+1}",
+ sources=["upload"],
+ height=200
+ )
+ ref_video_groups.append(vid_col)
+ ref_video_inputs.append(vid_comp)
+ components['ref_video_groups'] = ref_video_groups
+ components['ref_video_inputs'] = ref_video_inputs
+ components['ref_videos'] = ref_video_inputs
+
+ with gr.Row():
+ components['add_ref_video_button'] = gr.Button("â Add Reference Video")
+ components['delete_ref_video_button'] = gr.Button("â Delete Reference Video", visible=True)
+ components['ref_video_count_state'] = gr.State(1)
+
+ with gr.Accordion("Reference Audio Settings", open=False):
+ ref_audio_groups = []
+ ref_audio_inputs = []
+ with gr.Row():
+ for i in range(MAX_REF_AUDIOS):
+ with gr.Column(visible=(i < 1), min_width=200) as aud_col:
+ aud_comp = gr.Audio(
+ label=f"Ref Audio {i+1}",
+ sources=["upload"],
+ type="filepath"
+ )
+ ref_audio_groups.append(aud_col)
+ ref_audio_inputs.append(aud_comp)
+ components['ref_audio_groups'] = ref_audio_groups
+ components['ref_audio_inputs'] = ref_audio_inputs
+ components['ref_audios'] = ref_audio_inputs
+
+ with gr.Row():
+ components['add_ref_audio_button'] = gr.Button("â Add Reference Audio")
+ components['delete_ref_audio_button'] = gr.Button("â Delete Reference Audio", visible=True)
+ components['ref_audio_count_state'] = gr.State(1)
+
+ components.update(create_h3_guide_ui("h3_ref2va"))
+ components.update(create_h3_controlnet_ui("h3_ref2va"))
+
+ components['run_button'] = gr.Button(UI_INFO["run_button_text"], variant="primary", elem_classes=["run-shortcut"])
+
+ return components
+
+def create_event_handlers(components: dict, demo: gr.Blocks):
+ def update_dimensions(resolution, aspect_ratio):
+ w, h = RESOLUTION_PRESETS.get(resolution, {}).get(aspect_ratio, (960, 544))
+ return w, h
+
+ components['resolution'].change(
+ fn=update_dimensions,
+ inputs=[components['resolution'], components['aspect_ratio']],
+ outputs=[components['width'], components['height']],
+ api_name=False
+ )
+
+ components['aspect_ratio'].change(
+ fn=update_dimensions,
+ inputs=[components['resolution'], components['aspect_ratio']],
+ outputs=[components['width'], components['height']],
+ api_name=False
+ )
+
+ ref_count_state = components['ref_count_state']
+ add_ref_btn = components['add_ref_button']
+ del_ref_btn = components['delete_ref_button']
+ ref_image_groups = components['ref_image_groups']
+ ref_image_inputs = components['ref_image_inputs']
+
+ def add_ref_row(count):
+ count += 1
+ visibility_updates = tuple(gr.update(visible=i < count) for i in range(MAX_REF_IMAGES))
+ return (count, gr.update(visible=count < MAX_REF_IMAGES), gr.update(visible=count > 0)) + visibility_updates
+
+ def delete_ref_row(count):
+ count -= 1
+ image_clear_updates = [gr.update()] * MAX_REF_IMAGES
+ if count >= 0:
+ image_clear_updates[count] = None
+ visibility_updates = tuple(gr.update(visible=i < count) for i in range(MAX_REF_IMAGES))
+ return (count, gr.update(visible=count < MAX_REF_IMAGES), gr.update(visible=count > 0)) + visibility_updates + tuple(image_clear_updates)
+
+ add_ref_outputs = [ref_count_state, add_ref_btn, del_ref_btn] + ref_image_groups
+ del_ref_outputs = [ref_count_state, add_ref_btn, del_ref_btn] + ref_image_groups + ref_image_inputs
+
+ add_ref_btn.click(
+ fn=add_ref_row,
+ inputs=[ref_count_state],
+ outputs=add_ref_outputs,
+ show_progress=False,
+ api_name=False
+ )
+
+ del_ref_btn.click(
+ fn=delete_ref_row,
+ inputs=[ref_count_state],
+ outputs=del_ref_outputs,
+ show_progress=False,
+ api_name=False
+ )
+
+ ref_video_count_state = components['ref_video_count_state']
+ add_ref_vid_btn = components['add_ref_video_button']
+ del_ref_vid_btn = components['delete_ref_video_button']
+ ref_video_groups = components['ref_video_groups']
+ ref_video_inputs = components['ref_video_inputs']
+
+ def add_ref_video_row(count):
+ count += 1
+ visibility_updates = tuple(gr.update(visible=i < count) for i in range(MAX_REF_VIDEOS))
+ return (count, gr.update(visible=count < MAX_REF_VIDEOS), gr.update(visible=count > 0)) + visibility_updates
+
+ def delete_ref_video_row(count):
+ count -= 1
+ video_clear_updates = [gr.update()] * MAX_REF_VIDEOS
+ if count >= 0:
+ video_clear_updates[count] = None
+ visibility_updates = tuple(gr.update(visible=i < count) for i in range(MAX_REF_VIDEOS))
+ return (count, gr.update(visible=count < MAX_REF_VIDEOS), gr.update(visible=count > 0)) + visibility_updates + tuple(video_clear_updates)
+
+ add_ref_vid_outputs = [ref_video_count_state, add_ref_vid_btn, del_ref_vid_btn] + ref_video_groups
+ del_ref_vid_outputs = [ref_video_count_state, add_ref_vid_btn, del_ref_vid_btn] + ref_video_groups + ref_video_inputs
+
+ add_ref_vid_btn.click(
+ fn=add_ref_video_row,
+ inputs=[ref_video_count_state],
+ outputs=add_ref_vid_outputs,
+ show_progress=False,
+ api_name=False
+ )
+
+ del_ref_vid_btn.click(
+ fn=delete_ref_video_row,
+ inputs=[ref_video_count_state],
+ outputs=del_ref_vid_outputs,
+ show_progress=False,
+ api_name=False
+ )
+
+ ref_audio_count_state = components['ref_audio_count_state']
+ add_ref_aud_btn = components['add_ref_audio_button']
+ del_ref_aud_btn = components['delete_ref_audio_button']
+ ref_audio_groups = components['ref_audio_groups']
+ ref_audio_inputs = components['ref_audio_inputs']
+
+ def add_ref_audio_row(count):
+ count += 1
+ visibility_updates = tuple(gr.update(visible=i < count) for i in range(MAX_REF_AUDIOS))
+ return (count, gr.update(visible=count < MAX_REF_AUDIOS), gr.update(visible=count > 0)) + visibility_updates
+
+ def delete_ref_audio_row(count):
+ count -= 1
+ audio_clear_updates = [gr.update()] * MAX_REF_AUDIOS
+ if count >= 0:
+ audio_clear_updates[count] = None
+ visibility_updates = tuple(gr.update(visible=i < count) for i in range(MAX_REF_AUDIOS))
+ return (count, gr.update(visible=count < MAX_REF_AUDIOS), gr.update(visible=count > 0)) + visibility_updates + tuple(audio_clear_updates)
+
+ add_ref_aud_outputs = [ref_audio_count_state, add_ref_aud_btn, del_ref_aud_btn] + ref_audio_groups
+ del_ref_aud_outputs = [ref_audio_count_state, add_ref_aud_btn, del_ref_aud_btn] + ref_audio_groups + ref_audio_inputs
+
+ add_ref_aud_btn.click(
+ fn=add_ref_audio_row,
+ inputs=[ref_audio_count_state],
+ outputs=add_ref_aud_outputs,
+ show_progress=False,
+ api_name=False
+ )
+
+ del_ref_aud_btn.click(
+ fn=delete_ref_audio_row,
+ inputs=[ref_audio_count_state],
+ outputs=del_ref_aud_outputs,
+ show_progress=False,
+ api_name=False
+ )
+
+ lora_sources = components.get('lora_sources_h3_ref2va', [])
+ lora_ids = components.get('lora_ids_h3_ref2va', [])
+ lora_scales = components.get('lora_scales_h3_ref2va', [])
+
+ cn_videos = components.get('h3_controlnet_videos_h3_ref2va', [])
+ cn_types = components.get('h3_controlnet_types_h3_ref2va', [])
+ cn_series = components.get('h3_controlnet_series_h3_ref2va', [])
+ cn_strengths = components.get('h3_controlnet_strengths_h3_ref2va', [])
+ cn_start_percents = components.get('h3_controlnet_start_percents_h3_ref2va', [])
+ cn_end_percents = components.get('h3_controlnet_end_percents_h3_ref2va', [])
+ cn_filepaths = components.get('h3_controlnet_filepaths_h3_ref2va', [])
+
+ guide_images = components.get('h3_guide_images_h3_ref2va', [])
+ guide_videos = components.get('h3_guide_videos_h3_ref2va', [])
+ guide_audios = components.get('h3_guide_audios_h3_ref2va', [])
+ guide_times = components.get('h3_guide_times_h3_ref2va', [])
+ guide_frames = components.get('h3_guide_frames_h3_ref2va', [])
+
+ inputs_list = [
+ components['prompt'],
+ components['resolution'],
+ components['aspect_ratio'],
+ components['width'],
+ components['height'],
+ components['duration'],
+ components['sampler'],
+ components['scheduler'],
+ components['steps'],
+ components['cfg_scale'],
+ components['seed'],
+ components['zero_gpu_duration']
+ ] + ref_image_inputs + ref_video_inputs + ref_audio_inputs + lora_sources + lora_ids + lora_scales + cn_videos + cn_types + cn_series + cn_strengths + cn_start_percents + cn_end_percents + cn_filepaths + guide_images + guide_videos + guide_audios + guide_times + guide_frames
+
+ def on_run_click(*args, progress=gr.Progress(track_tqdm=True)):
+ prompt, resolution, aspect_ratio, width, height, duration, sampler, scheduler, steps, cfg_scale, seed, zero_gpu_duration = args[:12]
+ idx = 12
+ imgs = list(args[idx : idx + len(ref_image_inputs)])
+ idx += len(ref_image_inputs)
+ vids = list(args[idx : idx + len(ref_video_inputs)])
+ idx += len(ref_video_inputs)
+ auds = list(args[idx : idx + len(ref_audio_inputs)])
+ idx += len(ref_audio_inputs)
+
+ num_loras = len(lora_sources)
+ l_sources = args[idx : idx + num_loras]
+ l_ids = args[idx + num_loras : idx + 2*num_loras]
+ l_scales = args[idx + 2*num_loras : idx + 3*num_loras]
+ idx += 3 * num_loras
+
+ num_cns = len(cn_videos)
+ c_vids = args[idx : idx + num_cns]
+ c_types = args[idx + num_cns : idx + 2*num_cns]
+ c_series = args[idx + 2*num_cns : idx + 3*num_cns]
+ c_strengths = args[idx + 3*num_cns : idx + 4*num_cns]
+ c_starts = args[idx + 4*num_cns : idx + 5*num_cns]
+ c_ends = args[idx + 5*num_cns : idx + 6*num_cns]
+ c_fps = args[idx + 6*num_cns : idx + 7*num_cns]
+ idx += 7 * num_cns
+
+ num_guides = len(guide_images)
+ g_imgs = args[idx : idx + num_guides]
+ g_vids = args[idx + num_guides : idx + 2*num_guides]
+ g_auds = args[idx + 2*num_guides : idx + 3*num_guides]
+ g_times = args[idx + 3*num_guides : idx + 4*num_guides]
+ g_frames = args[idx + 4*num_guides : idx + 5*num_guides] if guide_frames else []
+
+ ui_values = {
+ 'prompt': prompt,
+ 'resolution': resolution,
+ 'aspect_ratio': aspect_ratio,
+ 'width': width,
+ 'height': height,
+ 'duration': duration,
+ 'sampler_name': sampler,
+ 'scheduler': scheduler,
+ 'steps': steps,
+ 'cfg_scale': cfg_scale,
+ 'seed': seed,
+ 'zero_gpu_duration': zero_gpu_duration,
+ 'batch_count': 1,
+ 'ref_images': imgs,
+ 'ref_videos': vids,
+ 'ref_audios': auds,
+ 'lora_sources_h3_ref2va': list(l_sources),
+ 'lora_ids_h3_ref2va': list(l_ids),
+ 'lora_scales_h3_ref2va': list(l_scales),
+ 'h3_controlnet_videos_h3_ref2va': list(c_vids),
+ 'h3_controlnet_types_h3_ref2va': list(c_types),
+ 'h3_controlnet_series_h3_ref2va': list(c_series),
+ 'h3_controlnet_strengths_h3_ref2va': list(c_strengths),
+ 'h3_controlnet_start_percents_h3_ref2va': list(c_starts),
+ 'h3_controlnet_end_percents_h3_ref2va': list(c_ends),
+ 'h3_controlnet_filepaths_h3_ref2va': list(c_fps),
+ 'h3_guide_images_h3_ref2va': list(g_imgs),
+ 'h3_guide_videos_h3_ref2va': list(g_vids),
+ 'h3_guide_audios_h3_ref2va': list(g_auds),
+ 'h3_guide_times_h3_ref2va': list(g_times),
+ 'h3_guide_frames_h3_ref2va': list(g_frames),
+ }
+ return generate_h3_video_wrapper(process_inputs, ui_values, progress=progress)
+
+ components['run_button'].click(
+ fn=on_run_click,
+ inputs=inputs_list,
+ outputs=[components['output_video']],
+ api_name=False
+ )
diff --git a/ui/shared/ui_components.py b/ui/shared/ui_components.py
new file mode 100644
index 0000000000000000000000000000000000000000..3d55dc24fbf036a5c91fb101db5162f5fa4d3ff2
--- /dev/null
+++ b/ui/shared/ui_components.py
@@ -0,0 +1,203 @@
+import gradio as gr
+from comfy_integration.nodes import SAMPLER_CHOICES, SCHEDULER_CHOICES
+from core.settings import (
+ MAX_LORAS, MAX_CONTROLNETS, MAX_H3_GUIDES, LORA_SOURCE_CHOICES, RESOLUTION_MAP, MODEL_DEFAULTS_CONFIG
+)
+from utils.app_utils import get_h3_cn_defaults
+
+default_vals = MODEL_DEFAULTS_CONFIG.get('Default', {})
+DEFAULT_STEPS = default_vals.get('steps', 20)
+DEFAULT_CFG = default_vals.get('cfg', 5.0)
+DEFAULT_SAMPLER = default_vals.get('sampler_name', 'euler')
+DEFAULT_SCHEDULER = default_vals.get('scheduler', 'simple')
+DEFAULT_POS_PROMPT = default_vals.get('positive_prompt', '')
+DEFAULT_NEG_PROMPT = default_vals.get('negative_prompt', '')
+
+
+def create_base_parameter_ui(prefix, defaults=None):
+ if defaults is None:
+ defaults = {}
+
+ components = {}
+
+ with gr.Row():
+ components[f'aspect_ratio_{prefix}'] = gr.Dropdown(
+ label="Aspect Ratio",
+ choices=list(RESOLUTION_MAP.get('sdxl', {}).keys()),
+ value="1:1 (Square)",
+ interactive=True,
+ allow_custom_value=True
+ )
+ with gr.Row():
+ components[f'width_{prefix}'] = gr.Number(label="Width", value=defaults.get('w', 1024), interactive=True)
+ components[f'height_{prefix}'] = gr.Number(label="Height", value=defaults.get('h', 1024), interactive=True)
+ with gr.Row():
+ components[f'sampler_{prefix}'] = gr.Dropdown(
+ label="Sampler",
+ choices=SAMPLER_CHOICES,
+ value=DEFAULT_SAMPLER if DEFAULT_SAMPLER in SAMPLER_CHOICES else (SAMPLER_CHOICES[0] if SAMPLER_CHOICES else 'euler')
+ )
+ components[f'scheduler_{prefix}'] = gr.Dropdown(
+ label="Scheduler",
+ choices=SCHEDULER_CHOICES,
+ value=DEFAULT_SCHEDULER if DEFAULT_SCHEDULER in SCHEDULER_CHOICES else (SCHEDULER_CHOICES[0] if SCHEDULER_CHOICES else 'simple')
+ )
+ with gr.Row():
+ components[f'steps_{prefix}'] = gr.Slider(label="Steps", minimum=1, maximum=100, step=1, value=DEFAULT_STEPS)
+ components[f'cfg_{prefix}'] = gr.Slider(label="CFG Scale", minimum=1.0, maximum=20.0, step=0.1, value=DEFAULT_CFG)
+ with gr.Row():
+ components[f'seed_{prefix}'] = gr.Number(label="Seed (-1 for random)", value=-1, precision=0)
+ components[f'batch_size_{prefix}'] = gr.Slider(label="Batch Size", minimum=1, maximum=16, step=1, value=1)
+ with gr.Row():
+ components[f'guidance_{prefix}'] = gr.Slider(label="Guidance (FLUX)", minimum=1.0, maximum=10.0, step=0.1, value=3.5, visible=False, interactive=True)
+ components[f'zero_gpu_{prefix}'] = gr.Number(label="ZeroGPU Duration (s)", value=None, placeholder="Default: 60s, Max: 120s", info="Optional: Set how long to reserve the GPU.")
+
+ return components
+
+
+def create_lora_settings_ui(prefix: str):
+ components = {}
+
+ lora_rows, lora_sources, lora_ids, lora_scales, lora_uploads = [], [], [], [], []
+
+ with gr.Accordion("LoRA Settings", open=False, visible=True) as lora_accordion:
+ components[f'lora_accordion_{prefix}'] = lora_accordion
+ gr.Markdown("đĄ **Tip:** When downloading from Civitai, please use the **Version ID**, not the Model ID. You can find the Version ID in the URL (e.g., `civitai.com/models/123?modelVersionId=456`) or under the model's download button. When downloading from Hugging Face, please use the format: `repo_id/filename.extension` or `repo_id/folder_path/filename.extension` (e.g., `Comfy-Org/MiniMax-H3/loras/minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors` or `Comfy-Org/MiniMax-H3/loras/minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors`).")
+ components[f'lora_count_state_{prefix}'] = gr.State(1)
+
+ for i in range(MAX_LORAS):
+ with gr.Row(visible=i==0) as row:
+ source = gr.Dropdown(label=f"LoRA Source {i+1}", choices=LORA_SOURCE_CHOICES, value=LORA_SOURCE_CHOICES[0], scale=1)
+ lora_id = gr.Textbox(label="Civitai Version ID / HF file / Upload File", scale=2, type="text")
+ scale = gr.Slider(label=f"Scale", minimum=0.0, maximum=2.0, step=0.05, value=1.0, scale=1)
+ upload = gr.UploadButton(label="Upload", file_types=[".safetensors"], scale=1)
+
+ lora_rows.append(row)
+ lora_sources.append(source)
+ lora_ids.append(lora_id)
+ lora_scales.append(scale)
+ lora_uploads.append(upload)
+
+ with gr.Row():
+ components[f'add_lora_button_{prefix}'] = gr.Button("Add LoRA", variant="secondary")
+ components[f'delete_lora_button_{prefix}'] = gr.Button("Remove LoRA", variant="secondary", visible=False)
+
+ components[f'lora_rows_{prefix}'] = lora_rows
+ components[f'lora_sources_{prefix}'] = lora_sources
+ components[f'lora_ids_{prefix}'] = lora_ids
+ components[f'lora_scales_{prefix}'] = lora_scales
+ components[f'lora_uploads_{prefix}'] = lora_uploads
+
+ all_lora_components_flat = []
+ for i in range(MAX_LORAS):
+ all_lora_components_flat.extend([lora_sources[i], lora_ids[i], lora_scales[i], lora_uploads[i]])
+ components[f'all_lora_components_flat_{prefix}'] = all_lora_components_flat
+
+ return components
+
+
+def create_h3_guide_ui(prefix: str, max_units=MAX_H3_GUIDES):
+ components = {}
+ key = lambda name: f"{name}_{prefix}"
+
+ with gr.Accordion("Keyframe Guide Settings", open=False, visible=True) as accordion:
+ components[key('h3_guide_accordion')] = accordion
+ gr.Markdown(
+ "đĄ **Tip:** You can anchor keyframe guides (image, video clip, or audio) "
+ "at arbitrary positions along the continuous timeline (not limited to first/last frames). "
+ "Specify the position via **Time (seconds)**."
+ )
+
+ guide_rows = []
+ images = []
+ videos = []
+ audios = []
+ times = []
+ frames = []
+
+ components.update({
+ key('h3_guide_rows'): guide_rows,
+ key('h3_guide_images'): images,
+ key('h3_guide_videos'): videos,
+ key('h3_guide_audios'): audios,
+ key('h3_guide_times'): times,
+ key('h3_guide_frames'): frames,
+ })
+
+ for i in range(max_units):
+ with gr.Row(visible=(i < 1)) as row:
+ with gr.Column(scale=1):
+ images.append(gr.Image(label=f"Guide Image {i+1}", type="pil", sources=["upload"], height=200))
+ with gr.Column(scale=1):
+ videos.append(gr.Video(label=f"Guide Video (Opt) {i+1}", sources=["upload"], height=200))
+ with gr.Column(scale=1):
+ audios.append(gr.Audio(label=f"Guide Audio (Opt) {i+1}", sources=["upload"], type="filepath"))
+ times.append(gr.Slider(label=f"Time (s) {i+1}", minimum=0.0, maximum=15.0, step=0.5, value=float(i * 1.5), interactive=True))
+ guide_rows.append(row)
+
+ with gr.Row():
+ components[key('add_h3_guide_button')] = gr.Button("â Add Guide", variant="secondary")
+ components[key('delete_h3_guide_button')] = gr.Button("â Delete Guide", variant="secondary", visible=False)
+ components[key('h3_guide_count_state')] = gr.State(1)
+
+ all_guide_components_flat = []
+ for i in range(max_units):
+ all_guide_components_flat.extend([
+ images[i], videos[i], audios[i], times[i]
+ ])
+ components[key('all_h3_guide_components_flat')] = all_guide_components_flat
+
+ return components
+
+
+def create_h3_controlnet_ui(prefix: str, max_units=MAX_CONTROLNETS):
+ components = {}
+ key = lambda name: f"{name}_{prefix}"
+
+ default_types, default_type, default_series, default_serie, default_fp = get_h3_cn_defaults()
+
+ with gr.Accordion("H3 ControlNet Settings", open=False, visible=True) as accordion:
+ components[key('h3_controlnet_accordion')] = accordion
+ gr.Markdown(
+ "đĄ **Tip:** This feature does not perform preprocessing on uploaded videos. "
+ "Please upload the corresponding preprocessed control video (e.g., Canny edges, Depth, Pose) rather than raw/unprocessed video footage."
+ )
+
+ cn_rows, videos, series, types, strengths, start_percents, end_percents, filepaths = [], [], [], [], [], [], [], []
+ components.update({
+ key('h3_controlnet_rows'): cn_rows,
+ key('h3_controlnet_videos'): videos,
+ key('h3_controlnet_series'): series,
+ key('h3_controlnet_types'): types,
+ key('h3_controlnet_strengths'): strengths,
+ key('h3_controlnet_start_percents'): start_percents,
+ key('h3_controlnet_end_percents'): end_percents,
+ key('h3_controlnet_filepaths'): filepaths,
+ })
+
+ for i in range(max_units):
+ with gr.Row(visible=(i < 1)) as row:
+ with gr.Column(scale=1):
+ videos.append(gr.Video(label=f"Control Video {i+1}", sources=["upload"], height=220))
+ with gr.Column(scale=2):
+ types.append(gr.Dropdown(label="Type", choices=default_types, value=default_type, interactive=True, allow_custom_value=True))
+ series.append(gr.Dropdown(label="Series", choices=default_series, value=default_serie, interactive=True, allow_custom_value=True))
+ strengths.append(gr.Slider(label="Strength", minimum=0.0, maximum=2.0, step=0.05, value=1.0, interactive=True))
+ start_percents.append(gr.State(0.0))
+ end_percents.append(gr.State(1.0))
+ filepaths.append(gr.State(default_fp))
+ cn_rows.append(row)
+
+ with gr.Row():
+ components[key('add_h3_controlnet_button')] = gr.Button("â Add H3 ControlNet", variant="secondary")
+ components[key('delete_h3_controlnet_button')] = gr.Button("â Delete H3 ControlNet", variant="secondary", visible=False)
+ components[key('h3_controlnet_count_state')] = gr.State(1)
+
+ all_cn_components_flat = []
+ for i in range(max_units):
+ all_cn_components_flat.extend([
+ videos[i], types[i], series[i], strengths[i], start_percents[i], end_percents[i], filepaths[i]
+ ])
+ components[key('all_h3_controlnet_components_flat')] = all_cn_components_flat
+
+ return components
\ No newline at end of file
diff --git a/utils/__init__.py b/utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/utils/app_utils.py b/utils/app_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..1c367fd1e8f0545cdf389622922026ac1548018e
--- /dev/null
+++ b/utils/app_utils.py
@@ -0,0 +1,805 @@
+import os
+import random
+import requests
+import hashlib
+import re
+from typing import Sequence, Mapping, Any, Union, Set
+from pathlib import Path
+import shutil
+
+import gradio as gr
+from huggingface_hub import hf_hub_download, constants as hf_constants
+import torch
+import numpy as np
+from PIL import Image, ImageChops
+import yaml
+
+from core.settings import *
+
+MODELS_ROOT_DIR = "ComfyUI/models"
+
+
+class UniqueKeyLoader(yaml.SafeLoader):
+ """
+ A custom YAML loader that handles duplicate keys by grouping their values into a list.
+ """
+ def construct_mapping(self, node, deep=False):
+ mapping = []
+ for key_node, value_node in node.value:
+ key = self.construct_object(key_node, deep=deep)
+ value = self.construct_object(value_node, deep=deep)
+ mapping.append((key, value))
+
+ result = {}
+ for k, v in mapping:
+ if k in result:
+ if isinstance(result[k], list):
+ result[k].append(v)
+ else:
+ result[k] = [result[k], v]
+ else:
+ result[k] = v
+ return result
+
+UniqueKeyLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, UniqueKeyLoader.construct_mapping)
+
+def save_uploaded_file_with_hash(file_obj: gr.File, target_dir: str) -> str:
+ if not file_obj:
+ return ""
+
+ temp_path = file_obj.name
+
+ sha256 = hashlib.sha256()
+ with open(temp_path, 'rb') as f:
+ for block in iter(lambda: f.read(65536), b''):
+ sha256.update(block)
+
+ file_hash = sha256.hexdigest()
+ _, extension = os.path.splitext(temp_path)
+ hashed_filename = f"{file_hash}{extension.lower()}"
+
+ dest_path = os.path.join(target_dir, hashed_filename)
+
+ os.makedirs(target_dir, exist_ok=True)
+ if not os.path.exists(dest_path):
+ shutil.copy(temp_path, dest_path)
+ print(f"â
Saved uploaded file as: {dest_path}")
+ else:
+ print(f"âšī¸ File already exists (deduplicated): {dest_path}")
+
+ return hashed_filename
+
+def bytes_to_gb(byte_size: int) -> float:
+ if byte_size is None or byte_size == 0:
+ return 0.0
+ return round(byte_size / (1024 ** 3), 2)
+
+def get_directory_size(path: str) -> int:
+ total_size = 0
+ if not os.path.exists(path):
+ return 0
+ try:
+ for dirpath, _, filenames in os.walk(path):
+ for f in filenames:
+ fp = os.path.join(dirpath, f)
+ if os.path.isfile(fp) and not os.path.islink(fp):
+ total_size += os.path.getsize(fp)
+ except OSError as e:
+ print(f"Warning: Could not access {path} to calculate size: {e}")
+ return total_size
+
+def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
+ try:
+ return obj[index]
+ except (KeyError, IndexError):
+ try:
+ return obj["result"][index]
+ except (KeyError, IndexError):
+ return None
+
+def sanitize_prompt(prompt: str) -> str:
+ if not isinstance(prompt, str):
+ return ""
+ return "".join(char for char in prompt if char.isprintable() or char in ('\n', '\t'))
+
+def sanitize_id(input_id: str) -> str:
+ if not isinstance(input_id, str):
+ return ""
+ input_id = input_id.strip()
+ if "civitai" in input_id.lower():
+ version_match = re.search(r'modelVersionId=(\d+)', input_id)
+ if version_match:
+ return version_match.group(1)
+ model_match = re.search(r'/models/(\d+)', input_id)
+ if model_match:
+ return model_match.group(1)
+ return re.sub(r'[^0-9]', '', input_id)
+
+def sanitize_url(url: str) -> str:
+ if not isinstance(url, str):
+ raise ValueError("URL must be a string.")
+ url = url.strip()
+ if not re.match(r'^https?://[^\s/$.?#].[^\s]*$', url):
+ raise ValueError("Invalid URL format or scheme. Only HTTP and HTTPS are allowed.")
+ return url
+
+def sanitize_filename(filename: str) -> str:
+ if not isinstance(filename, str):
+ return ""
+ sanitized = filename.replace('..', '')
+ sanitized = re.sub(r'[^\w\.\-]', '_', sanitized)
+ return sanitized.lstrip('/\\')
+
+def get_civitai_file_info(version_id: str) -> dict | None:
+ api_url = f"https://civitai.com/api/v1/model-versions/{version_id}"
+ try:
+ response = requests.get(api_url, timeout=10)
+ response.raise_for_status()
+ data = response.json()
+
+ model_type = data.get('model', {}).get('type')
+
+ result_file = None
+ for file_data in data.get('files', []):
+ if file_data.get('type') == 'Model' and file_data['name'].endswith(('.safetensors', '.pt', '.bin')):
+ result_file = file_data.copy()
+ break
+
+ if not result_file and data.get('files'):
+ result_file = data['files'][0].copy()
+
+ if result_file:
+ result_file['model_type'] = model_type
+ return result_file
+ except Exception:
+ return None
+
+def download_file(url: str, save_path: str, api_key: str = None, progress=None, desc: str = "") -> str:
+ if os.path.exists(save_path):
+ return f"File already exists: {os.path.basename(save_path)}"
+
+ headers = {'Authorization': f'Bearer {api_key}'} if api_key and api_key.strip() else {}
+ try:
+ if progress:
+ progress(0, desc=desc)
+
+ response = requests.get(url, stream=True, headers=headers, timeout=15)
+ response.raise_for_status()
+ total_size = int(response.headers.get('content-length', 0))
+
+ with open(save_path, "wb") as f:
+ downloaded = 0
+ for chunk in response.iter_content(chunk_size=8192):
+ f.write(chunk)
+ if progress and total_size > 0:
+ downloaded += len(chunk)
+ progress(downloaded / total_size, desc=desc)
+ return f"Successfully downloaded: {os.path.basename(save_path)}"
+ except Exception as e:
+ if os.path.exists(save_path):
+ os.remove(save_path)
+ return f"Download failed for {os.path.basename(save_path)}: {e}"
+
+def get_lora_path(source: str, id_or_url: str, civitai_key: str, progress) -> tuple[str | None, str]:
+ if not id_or_url or not id_or_url.strip():
+ return None, "No ID/URL provided."
+
+ try:
+ if source == "Civitai":
+ version_id = sanitize_id(id_or_url)
+ if not version_id:
+ return None, "Invalid Civitai ID provided. Must be numeric."
+
+ file_info = get_civitai_file_info(version_id)
+ if file_info:
+ model_type = file_info.get('model_type')
+ if model_type and model_type.lower() == 'checkpoint':
+ return None, f"Invalid Civitai model type '{model_type}' for LoRA. Checkpoint models are not allowed."
+
+ filename = sanitize_filename(f"civitai_{version_id}.safetensors")
+ local_path = os.path.join(LORA_DIR, filename)
+ api_key_to_use = civitai_key
+ source_name = f"Civitai ID {version_id}"
+ elif source == "Hugging Face":
+ parts = id_or_url.strip().split('/')
+ if len(parts) < 3:
+ return None, "Invalid Hugging Face path. Format: repo_owner/repo_name/filename"
+ repo_id = f"{parts[0]}/{parts[1]}"
+ repo_file_path = "/".join(parts[2:])
+ unique_name = id_or_url.strip().replace('/', '_')
+ filename = sanitize_filename(unique_name)
+ local_path = os.path.join(LORA_DIR, filename)
+ source_name = f"HF {repo_file_path}"
+ else:
+ return None, "Invalid source."
+
+ except ValueError as e:
+ return None, f"Input validation failed: {e}"
+
+ if os.path.lexists(local_path):
+ if not os.path.exists(local_path):
+ os.remove(local_path)
+ else:
+ return local_path, "File already exists."
+
+ if source == "Civitai":
+ if not file_info or not file_info.get('downloadUrl'):
+ return None, f"Could not get download link for {source_name}."
+
+ status = download_file(file_info['downloadUrl'], local_path, api_key_to_use, progress=progress, desc=f"Downloading {source_name}")
+ return (local_path, status) if "Successfully" in status else (None, status)
+ elif source == "Hugging Face":
+ try:
+ if progress and callable(progress): progress(0, desc=f"Downloading {source_name}")
+ cached_path = hf_hub_download(repo_id=repo_id, filename=repo_file_path, token=os.environ.get("HF_TOKEN"))
+ os.makedirs(LORA_DIR, exist_ok=True)
+ if os.path.lexists(local_path):
+ if not os.path.exists(local_path):
+ try:
+ os.remove(local_path)
+ except OSError:
+ pass
+ if not os.path.exists(local_path):
+ try:
+ os.symlink(cached_path, local_path)
+ except (OSError, NotImplementedError):
+ shutil.copyfile(cached_path, local_path)
+ if progress and callable(progress): progress(1.0, desc=f"Downloaded {source_name}")
+ return local_path, f"Successfully downloaded: {filename}"
+ except Exception as e:
+ return None, f"Hugging Face download failed: {e}"
+
+
+def _ensure_model_downloaded(display_name: str, progress=gr.Progress()):
+ if display_name not in ALL_MODEL_MAP:
+ for cat_dir in CATEGORY_TO_DIR_MAP.values():
+ check_path = os.path.join(cat_dir, display_name)
+ if os.path.exists(check_path):
+ return display_name
+ raise ValueError(f"Model '{display_name}' not found in configuration.")
+
+ model_info = ALL_MODEL_MAP[display_name]
+ repo_filename = model_info[1]
+ base_filename = os.path.basename(repo_filename)
+
+ download_info = ALL_FILE_DOWNLOAD_MAP.get(base_filename)
+ if not download_info:
+ raise gr.Error(f"Model '{base_filename}' not found in file_list.yaml. Cannot download.")
+
+ category = download_info.get("category")
+ dest_dir = CATEGORY_TO_DIR_MAP.get(category)
+
+ if not dest_dir:
+ raise ValueError(f"Unknown YAML category '{category}' for '{base_filename}'.")
+
+ dest_path = os.path.join(dest_dir, base_filename)
+
+ if os.path.lexists(dest_path):
+ if not os.path.exists(dest_path):
+ print(f"â ī¸ Found and removed broken symlink: {dest_path}")
+ os.remove(dest_path)
+ else:
+ return base_filename
+
+ source = download_info.get("source")
+ try:
+ progress(0, desc=f"Downloading: {base_filename}")
+
+ if source == "hf":
+ repo_id = download_info.get("repo_id")
+ hf_filename = download_info.get("repository_file_path", base_filename)
+ if not repo_id:
+ raise ValueError(f"repo_id is missing for HF model '{base_filename}'")
+
+ cached_path = hf_hub_download(repo_id=repo_id, filename=hf_filename, token=os.environ.get("HF_TOKEN"))
+ os.makedirs(dest_dir, exist_ok=True)
+ os.symlink(cached_path, dest_path)
+ print(f"â
Symlinked '{cached_path}' to '{dest_path}'")
+
+ elif source == "civitai":
+ model_version_id = download_info.get("model_version_id")
+ if not model_version_id:
+ raise ValueError(f"model_version_id is missing for Civitai model '{base_filename}'")
+
+ file_info = get_civitai_file_info(model_version_id)
+ if not file_info or not file_info.get('downloadUrl'):
+ raise ConnectionError(f"Could not get download URL for Civitai model version ID {model_version_id}")
+
+ status = download_file(
+ file_info['downloadUrl'], dest_path, api_key=os.environ.get("CIVITAI_API_KEY", ""), progress=progress, desc=f"Downloading: {base_filename}"
+ )
+ if "Failed" in status:
+ raise ConnectionError(status)
+ else:
+ raise NotImplementedError(f"Download source '{source}' is not implemented for '{base_filename}'")
+
+ progress(1.0, desc=f"Downloaded: {base_filename}")
+
+ except Exception as e:
+ if os.path.lexists(dest_path):
+ try:
+ os.remove(dest_path)
+ except OSError: pass
+ raise gr.Error(f"Failed to download and link '{display_name}': {e}")
+
+ return base_filename
+
+
+def ensure_file_downloaded(filename: str, progress=None):
+ if not filename or filename == "None":
+ return
+
+ download_info = ALL_FILE_DOWNLOAD_MAP.get(filename)
+ if not download_info:
+ print(f"â ī¸ Warning: File '{filename}' not found in configuration (file_list.yaml). Cannot download.")
+ return
+
+ category = download_info.get("category", "loras")
+ dest_dir = CATEGORY_TO_DIR_MAP.get(category, LORA_DIR)
+ dest_path = os.path.join(dest_dir, filename)
+
+ if os.path.lexists(dest_path):
+ if not os.path.exists(dest_path):
+ print(f"â ī¸ Found and removed broken symlink: {dest_path}")
+ os.remove(dest_path)
+ else:
+ return
+
+ source = download_info.get("source")
+ try:
+ if source == "hf":
+ repo_id = download_info.get("repo_id")
+ repo_filename = download_info.get("repository_file_path", filename)
+ if not repo_id:
+ raise ValueError("repo_id is missing for Hugging Face download.")
+
+ if progress and callable(progress):
+ progress(0, desc=f"Downloading: {filename}")
+ cached_path = hf_hub_download(repo_id=repo_id, filename=repo_filename, token=os.environ.get("HF_TOKEN"))
+ os.makedirs(dest_dir, exist_ok=True)
+ os.symlink(cached_path, dest_path)
+ print(f"â
Symlinked '{cached_path}' to '{dest_path}'")
+ if progress and callable(progress):
+ progress(1.0, desc=f"Downloaded: {filename}")
+
+ elif source == "civitai":
+ model_version_id = download_info.get("model_version_id")
+ if not model_version_id:
+ raise ValueError("model_version_id is missing for Civitai download.")
+
+ file_info = get_civitai_file_info(model_version_id)
+ if not file_info or not file_info.get('downloadUrl'):
+ raise ConnectionError(f"Could not get download URL for Civitai model version ID {model_version_id}")
+
+ status = download_file(
+ file_info['downloadUrl'],
+ dest_path,
+ api_key=os.environ.get("CIVITAI_API_KEY", ""),
+ progress=progress,
+ desc=f"Downloading: {filename}"
+ )
+ if "Failed" in status:
+ raise ConnectionError(status)
+ else:
+ raise NotImplementedError(f"Download source '{source}' is not implemented for '{filename}'.")
+
+ except Exception as e:
+ if os.path.lexists(dest_path):
+ try:
+ os.remove(dest_path)
+ except OSError:
+ pass
+ raise gr.Error(f"Failed to download file '{filename}': {e}")
+
+
+def get_model_generation_defaults(model_display_name: str, model_type: str, defaults_config: dict):
+ final_defaults = {
+ 'steps': 25, 'cfg': 7.0, 'sampler_name': 'euler', 'scheduler': 'simple',
+ 'positive_prompt': '', 'negative_prompt': ''
+ }
+
+ if 'Default' in defaults_config:
+ final_defaults.update(defaults_config['Default'])
+
+ model_type_key = next((key for key in defaults_config if key.lower().replace(" ", "-").replace(".", "") == model_type.lower()), None)
+ if model_type_key:
+ model_type_config = defaults_config[model_type_key]
+ if '_defaults' in model_type_config:
+ final_defaults.update(model_type_config['_defaults'])
+
+ if model_display_name in model_type_config:
+ final_defaults.update(model_type_config[model_display_name])
+
+ return final_defaults
+
+def get_filename_prefix() -> str:
+ import time
+ return f"H3_{int(time.time())}"
+
+def save_temp_image(img):
+ if img is None:
+ return None
+ _PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ input_dir = os.path.join(_PROJECT_ROOT, "input")
+ os.makedirs(input_dir, exist_ok=True)
+ if isinstance(img, Image.Image):
+ filename = f"temp_image_{random.randint(10000, 99999)}.png"
+ filepath = os.path.join(input_dir, filename)
+ img.save(filepath, "PNG")
+ return os.path.basename(filepath)
+ elif isinstance(img, str):
+ if not img:
+ return None
+ if os.path.exists(img):
+ ext = os.path.splitext(img)[1] or ".png"
+ filename = f"temp_image_{random.randint(10000, 99999)}{ext}"
+ save_path = os.path.join(input_dir, filename)
+ shutil.copy(img, save_path)
+ return os.path.basename(save_path)
+ if os.path.exists(os.path.join(input_dir, img)):
+ return img
+ return os.path.basename(img)
+ return None
+
+def save_temp_audio(audio_path):
+ if not audio_path:
+ return None
+ _PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ input_dir = os.path.join(_PROJECT_ROOT, "input")
+ os.makedirs(input_dir, exist_ok=True)
+ if os.path.exists(audio_path):
+ ext = os.path.splitext(audio_path)[1] or ".wav"
+ filename = f"temp_audio_{random.randint(10000, 99999)}{ext}"
+ save_path = os.path.join(input_dir, filename)
+ shutil.copy(audio_path, save_path)
+ return os.path.basename(filename)
+ if os.path.exists(os.path.join(input_dir, audio_path)):
+ return audio_path
+ return os.path.basename(audio_path)
+
+def save_temp_video(video_path):
+ if not video_path:
+ return None
+ _PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ input_dir = os.path.join(_PROJECT_ROOT, "input")
+ os.makedirs(input_dir, exist_ok=True)
+ if os.path.exists(video_path):
+ ext = os.path.splitext(video_path)[1] or ".mp4"
+ filename = f"temp_video_{random.randint(10000, 99999)}{ext}"
+ save_path = os.path.join(input_dir, filename)
+ shutil.copy(video_path, save_path)
+ return os.path.basename(filename)
+ if os.path.exists(os.path.join(input_dir, video_path)):
+ return video_path
+ return os.path.basename(video_path)
+
+def handle_seed(seed_value: int, max_val: int = 2**32 - 1) -> int:
+ if seed_value == -1 or seed_value is None:
+ return random.randint(0, max_val)
+ return int(seed_value)
+
+def process_lora_inputs(ui_values: dict, prefix: str = "", progress=None) -> list:
+ active_loras_for_gpu = []
+
+ # 1. Check direct prefix format (e.g. lora_sources_h3_fl2va)
+ lora_sources = ui_values.get(f'lora_sources_{prefix}', []) if prefix else []
+ lora_ids = ui_values.get(f'lora_ids_{prefix}', []) if prefix else []
+ lora_scales = ui_values.get(f'lora_scales_{prefix}', []) if prefix else []
+
+ if isinstance(lora_sources, list) and isinstance(lora_ids, list):
+ for source, val, scale in zip(lora_sources, lora_ids, lora_scales):
+ scale_val = float(scale) if scale is not None else 1.0
+ if scale_val > 0 and val and str(val).strip():
+ lora_id = str(val).strip()
+ lora_filename = None
+ if source == "File":
+ lora_filename = sanitize_filename(lora_id)
+ local_path = os.path.join(LORA_DIR, lora_filename)
+ if not os.path.exists(local_path):
+ raise gr.Error(f"Uploaded LoRA file '{lora_id}' no longer exists on server. Please re-upload it.")
+ elif source in ("Civitai", "Hugging Face"):
+ local_path, status = get_lora_path(source, lora_id, os.environ.get("CIVITAI_API_KEY", ""), progress)
+ if local_path:
+ lora_filename = os.path.basename(local_path)
+ else:
+ raise gr.Error(f"Failed to prepare LoRA {lora_id}: {status}")
+
+ if lora_filename:
+ active_loras_for_gpu.append({
+ "lora_name": lora_filename,
+ "strength_model": scale_val,
+ "strength_clip": scale_val
+ })
+
+ # 2. Check lora_data flat list format (e.g. [source1, id1, scale1, upload1, ...])
+ lora_data = ui_values.get('lora_data', [])
+ if lora_data and not active_loras_for_gpu:
+ sources, ids, scales, files = lora_data[0::4], lora_data[1::4], lora_data[2::4], lora_data[3::4]
+ for source, lora_id, scale, _ in zip(sources, ids, scales, files):
+ scale_val = float(scale) if scale is not None else 1.0
+ if scale_val > 0 and lora_id and str(lora_id).strip():
+ lora_id_str = str(lora_id).strip()
+ lora_filename = None
+ if source == "File":
+ lora_filename = sanitize_filename(lora_id_str)
+ local_path = os.path.join(LORA_DIR, lora_filename)
+ if not os.path.exists(local_path):
+ raise gr.Error(f"Uploaded LoRA file '{lora_id_str}' no longer exists on server. Please re-upload it.")
+ elif source in ("Civitai", "Hugging Face"):
+ local_path, status = get_lora_path(source, lora_id_str, os.environ.get("CIVITAI_API_KEY", ""), progress)
+ if local_path:
+ lora_filename = os.path.basename(local_path)
+ else:
+ raise gr.Error(f"Failed to prepare LoRA {lora_id_str}: {status}")
+
+ if lora_filename:
+ active_loras_for_gpu.append({
+ "lora_name": lora_filename,
+ "strength_model": scale_val,
+ "strength_clip": scale_val
+ })
+
+ # 3. Check direct 'loras' list of dicts (from MCP or custom payload)
+ raw_loras = ui_values.get('loras', [])
+ if raw_loras and not active_loras_for_gpu and isinstance(raw_loras, list):
+ for item in raw_loras:
+ if isinstance(item, dict):
+ if "lora_name" in item:
+ active_loras_for_gpu.append(item)
+ else:
+ src = item.get("source", "Hugging Face")
+ val = item.get("lora_value") or item.get("id_or_url") or item.get("lora_id")
+ scale = item.get("scale", 1.0)
+ scale_val = float(scale) if scale is not None else 1.0
+ if scale_val > 0 and val and str(val).strip():
+ lora_id_str = str(val).strip()
+ lora_filename = None
+ if src == "File":
+ lora_filename = sanitize_filename(lora_id_str)
+ local_path = os.path.join(LORA_DIR, lora_filename)
+ if not os.path.exists(local_path):
+ raise gr.Error(f"Uploaded LoRA file '{lora_id_str}' no longer exists on server. Please re-upload it.")
+ elif src in ("Civitai", "Hugging Face"):
+ local_path, status = get_lora_path(src, lora_id_str, os.environ.get("CIVITAI_API_KEY", ""), progress)
+ if local_path:
+ lora_filename = os.path.basename(local_path)
+ else:
+ raise gr.Error(f"Failed to prepare LoRA {lora_id_str}: {status}")
+ if lora_filename:
+ active_loras_for_gpu.append({
+ "lora_name": lora_filename,
+ "strength_model": scale_val,
+ "strength_clip": scale_val
+ })
+
+ return active_loras_for_gpu
+
+
+def load_h3_controlnet_config():
+ _PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ _CN_MODEL_LIST_PATH = os.path.join(_PROJECT_ROOT, 'yaml', 'h3_controlnet_models.yaml')
+ try:
+ with open(_CN_MODEL_LIST_PATH, 'r', encoding='utf-8') as f:
+ config = yaml.safe_load(f)
+ return config.get("H3_ControlNet", {}) if isinstance(config, dict) else {}
+ except Exception as e:
+ print(f"Error loading h3_controlnet_models.yaml: {e}")
+ return {}
+
+
+def get_h3_cn_defaults(arch_val="MiniMax-H3"):
+ cn_full_config = load_h3_controlnet_config()
+ cn_config = cn_full_config.get(arch_val, [])
+ if not cn_config and cn_full_config:
+ cn_config = next(iter(cn_full_config.values()), [])
+
+ if not cn_config:
+ return ["Canny", "Depth", "HED", "MLSD", "Pose"], "Canny", ["alibaba-pai/MiniMax-H3-Fun-Controlnet-Union"], "alibaba-pai/MiniMax-H3-Fun-Controlnet-Union", "minimax_h3_fun_controlnet_union_pruned_int8_convrot.safetensors"
+
+ all_types = []
+ for model in cn_config:
+ for t in model.get("Type", []):
+ if t not in all_types:
+ all_types.append(t)
+ default_type = all_types[0] if all_types else "Canny"
+
+ series_choices = []
+ if default_type:
+ for model in cn_config:
+ if default_type in model.get("Type", []):
+ s = model.get("Series", "Default")
+ if s not in series_choices:
+ series_choices.append(s)
+ default_series = series_choices[0] if series_choices else ""
+
+ filepath = ""
+ if default_series and default_type:
+ for model in cn_config:
+ if model.get("Series") == default_series and default_type in model.get("Type", []):
+ filepath = model.get("Filepath", "")
+ break
+
+ return all_types, default_type, series_choices, default_series, filepath
+
+
+def process_h3_controlnet_inputs(ui_values: dict, prefix: str = "", progress=None) -> list:
+ active_cns = []
+
+ # 1. Check direct prefix format (e.g. h3_controlnet_videos_h3_fl2va)
+ videos = ui_values.get(f'h3_controlnet_videos_{prefix}', []) if prefix else []
+ types = ui_values.get(f'h3_controlnet_types_{prefix}', []) if prefix else []
+ series = ui_values.get(f'h3_controlnet_series_{prefix}', []) if prefix else []
+ strengths = ui_values.get(f'h3_controlnet_strengths_{prefix}', []) if prefix else []
+ start_percents = ui_values.get(f'h3_controlnet_start_percents_{prefix}', []) if prefix else []
+ end_percents = ui_values.get(f'h3_controlnet_end_percents_{prefix}', []) if prefix else []
+ filepaths = ui_values.get(f'h3_controlnet_filepaths_{prefix}', []) if prefix else []
+
+ if isinstance(videos, list) and isinstance(types, list):
+ for idx, vid in enumerate(videos):
+ if vid:
+ saved_vid_name = save_temp_video(vid) if isinstance(vid, str) and os.path.exists(vid) else vid
+ if not saved_vid_name:
+ continue
+
+ fp = filepaths[idx] if idx < len(filepaths) and filepaths[idx] and filepaths[idx] != "None" else None
+ if not fp:
+ fp = "minimax_h3_fun_controlnet_union_pruned_int8_convrot.safetensors"
+
+ ensure_file_downloaded(fp, progress=progress)
+
+ st = float(strengths[idx]) if idx < len(strengths) and strengths[idx] is not None else 1.0
+ sp = float(start_percents[idx]) if idx < len(start_percents) and start_percents[idx] is not None else 0.0
+ ep = float(end_percents[idx]) if idx < len(end_percents) and end_percents[idx] is not None else 1.0
+
+ active_cns.append({
+ "video": saved_vid_name,
+ "control_net_name": fp,
+ "strength": st,
+ "start_percent": sp,
+ "end_percent": ep
+ })
+
+ # 2. Check flat component list (e.g. [video1, type1, series1, strength1, start1, end1, filepath1, ...])
+ cn_data = ui_values.get(f'h3_controlnet_data_{prefix}', []) or ui_values.get('h3_controlnet_data', [])
+ if cn_data and not active_cns and isinstance(cn_data, list):
+ stride = 7
+ for i in range(0, len(cn_data), stride):
+ chunk = cn_data[i:i+stride]
+ if len(chunk) >= 1 and chunk[0]:
+ vid = chunk[0]
+ saved_vid_name = save_temp_video(vid) if isinstance(vid, str) and os.path.exists(vid) else vid
+ if not saved_vid_name:
+ continue
+ fp = chunk[6] if len(chunk) > 6 and chunk[6] and chunk[6] != "None" else "minimax_h3_fun_controlnet_union_pruned_int8_convrot.safetensors"
+ ensure_file_downloaded(fp, progress=progress)
+ st = float(chunk[3]) if len(chunk) > 3 and chunk[3] is not None else 1.0
+ sp = float(chunk[4]) if len(chunk) > 4 and chunk[4] is not None else 0.0
+ ep = float(chunk[5]) if len(chunk) > 5 and chunk[5] is not None else 1.0
+ active_cns.append({
+ "video": saved_vid_name,
+ "control_net_name": fp,
+ "strength": st,
+ "start_percent": sp,
+ "end_percent": ep
+ })
+
+ # 3. Check direct 'h3_controlnets' list of dicts (from MCP or custom payload)
+ raw_cns = ui_values.get('h3_controlnets', [])
+ if raw_cns and not active_cns and isinstance(raw_cns, list):
+ for item in raw_cns:
+ if isinstance(item, dict):
+ vid = item.get('video') or item.get('control_video') or item.get('file')
+ if vid:
+ saved_vid_name = save_temp_video(vid) if isinstance(vid, str) and os.path.exists(vid) else vid
+ if not saved_vid_name:
+ continue
+ fp = item.get('control_net_name') or item.get('filepath') or item.get('name') or "minimax_h3_fun_controlnet_union_pruned_int8_convrot.safetensors"
+ ensure_file_downloaded(fp, progress=progress)
+ active_cns.append({
+ "video": saved_vid_name,
+ "control_net_name": fp,
+ "strength": float(item.get('strength', 1.0)),
+ "start_percent": float(item.get('start_percent', 0.0)),
+ "end_percent": float(item.get('end_percent', 1.0))
+ })
+
+ return active_cns
+
+
+def process_h3_guide_inputs(ui_values: dict, prefix: str = "", progress=None) -> list:
+ """
+ Parses and prepares MiniMax H3 keyframe guide inputs.
+ Supports:
+ 1. Direct prefix format (e.g. h3_guide_images_h3_ref2va, h3_guide_times_h3_ref2va, etc.)
+ 2. Direct 'h3_guides' or 'guides' list of dictionaries (from MCP or custom payload)
+ Returns:
+ List of guide dicts: [{'frame_idx': int, 'image': str, 'video': str, 'audio': str}, ...]
+ """
+ active_guides = []
+
+ # 1. Direct prefix format from Gradio UI
+ images = ui_values.get(f'h3_guide_images_{prefix}', []) if prefix else []
+ videos = ui_values.get(f'h3_guide_videos_{prefix}', []) if prefix else []
+ audios = ui_values.get(f'h3_guide_audios_{prefix}', []) if prefix else []
+ times = ui_values.get(f'h3_guide_times_{prefix}', []) if prefix else []
+ frames = ui_values.get(f'h3_guide_frames_{prefix}', []) if prefix else []
+
+ if images or videos or audios:
+ max_len = max(len(images), len(videos), len(audios), len(times), len(frames))
+ for i in range(max_len):
+ img = images[i] if i < len(images) else None
+ vid = videos[i] if i < len(videos) else None
+ aud = audios[i] if i < len(audios) else None
+ t_val = times[i] if i < len(times) else None
+ f_val = frames[i] if i < len(frames) else None
+
+ saved_img = save_temp_image(img) if img is not None else None
+ saved_vid = save_temp_video(vid) if vid else None
+ saved_aud = save_temp_audio(aud) if aud else None
+
+ if not (saved_img or saved_vid or saved_aud):
+ continue
+
+ if f_val is not None and str(f_val).strip() != "":
+ try:
+ frame_idx = int(round(float(f_val)))
+ except (ValueError, TypeError):
+ frame_idx = 0
+ elif t_val is not None and str(t_val).strip() != "":
+ try:
+ frame_idx = int(round(float(t_val) * 24))
+ except (ValueError, TypeError):
+ frame_idx = 0
+ else:
+ frame_idx = 0
+
+ guide_dict = {"frame_idx": max(0, frame_idx)}
+ if saved_img:
+ guide_dict["image"] = saved_img
+ if saved_vid:
+ guide_dict["video"] = saved_vid
+ if saved_aud:
+ guide_dict["audio"] = saved_aud
+ active_guides.append(guide_dict)
+
+ # 2. Check direct 'h3_guides' or 'guides' list of dicts (from MCP or custom payload)
+ raw_guides = ui_values.get(f'h3_guides_{prefix}') or ui_values.get('h3_guides') or ui_values.get('guides', [])
+ if raw_guides and not active_guides and isinstance(raw_guides, list):
+ for item in raw_guides:
+ if isinstance(item, dict):
+ img = item.get('image')
+ vid = item.get('video')
+ aud = item.get('audio')
+
+ saved_img = save_temp_image(img) if img is not None else None
+ saved_vid = save_temp_video(vid) if vid else None
+ saved_aud = save_temp_audio(aud) if aud else None
+
+ if not (saved_img or saved_vid or saved_aud):
+ continue
+
+ if item.get('frame_idx') is not None:
+ try:
+ frame_idx = int(round(float(item['frame_idx'])))
+ except (ValueError, TypeError):
+ frame_idx = 0
+ elif item.get('time_seconds') is not None or item.get('time') is not None:
+ try:
+ raw_t = item.get('time_seconds') if item.get('time_seconds') is not None else item.get('time')
+ frame_idx = int(round(float(raw_t) * 24))
+ except (ValueError, TypeError):
+ frame_idx = 0
+ else:
+ frame_idx = 0
+
+ guide_dict = {"frame_idx": max(0, frame_idx)}
+ if saved_img:
+ guide_dict["image"] = saved_img
+ if saved_vid:
+ guide_dict["video"] = saved_vid
+ if saved_aud:
+ guide_dict["audio"] = saved_aud
+ active_guides.append(guide_dict)
+
+ active_guides.sort(key=lambda x: x['frame_idx'])
+ return active_guides
\ No newline at end of file
diff --git a/workflow_recipes/h3_fl2va_recipe.yaml b/workflow_recipes/h3_fl2va_recipe.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..6f2a929681927b4e6aa95cdcc8efb80ce189d0b9
--- /dev/null
+++ b/workflow_recipes/h3_fl2va_recipe.yaml
@@ -0,0 +1,194 @@
+nodes:
+ unet_loader:
+ class_type: UNETLoader
+ title: "Load Diffusion Model"
+ params:
+ unet_name: "minimax_h3_fl2va_pruned_nvfp4.safetensors"
+ weight_dtype: "default"
+ clip_loader:
+ class_type: CLIPLoader
+ title: "Load CLIP"
+ params:
+ clip_name: "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors"
+ type: "minimax"
+ device: "default"
+ video_vae_loader:
+ class_type: VAELoader
+ title: "Load Video VAE"
+ params:
+ vae_name: "minimax_h3_video_vae_fp16.safetensors"
+ audio_vae_loader:
+ class_type: VAELoader
+ title: "Load Audio VAE"
+ params:
+ vae_name: "minimax_h3_audio_vae_fp32.safetensors"
+ minimax_h3:
+ class_type: MiniMaxH3ImageToVideo
+ title: "MiniMax H3 Image to Video"
+ params:
+ width: 1344
+ height: 768
+ length: 73
+ basic_guider:
+ class_type: BasicGuider
+ title: "Basic Guider"
+ ksampler_select:
+ class_type: KSamplerSelect
+ title: "KSamplerSelect"
+ params:
+ sampler_name: "res_multistep"
+ basic_scheduler:
+ class_type: BasicScheduler
+ title: "BasicScheduler"
+ params:
+ scheduler: "simple"
+ steps: 20
+ denoise: 1.0
+ random_noise:
+ class_type: RandomNoise
+ title: "RandomNoise"
+ params:
+ noise_seed: 1
+ sampler_custom_advanced:
+ class_type: SamplerCustomAdvanced
+ title: "SamplerCustomAdvanced"
+ vae_decode:
+ class_type: VAEDecode
+ title: "VAE Decode"
+ vae_decode_audio:
+ class_type: VAEDecodeAudio
+ title: "VAE Decode Audio"
+ create_video:
+ class_type: CreateVideo
+ title: "Create Video"
+ params:
+ fps: 24
+ bit_depth: 8
+ save_video:
+ class_type: SaveVideo
+ title: "Save Video"
+ params:
+ filename_prefix: "video/MiniMax_H3"
+ format: "mp4"
+ codec:
+ codec: "h264"
+
+ first_frame_loader:
+ class_type: "{{ first_frame_loader_class }}"
+ title: "Load Image"
+ first_frame_scale:
+ class_type: "{{ first_frame_scale_class }}"
+ title: "Upscale Image"
+ params:
+ upscale_method: "nearest-exact"
+ crop: "center"
+
+ last_frame_loader:
+ class_type: "{{ last_frame_loader_class }}"
+ title: "Load Image"
+ last_frame_scale:
+ class_type: "{{ last_frame_scale_class }}"
+ title: "Upscale Image"
+ params:
+ upscale_method: "nearest-exact"
+ crop: "center"
+
+dynamic_lora_chains:
+ loras:
+ template: LoraLoader
+ output_map:
+ "unet_loader:0": model
+ "clip_loader:0": clip
+ input_map:
+ model: model
+ clip: clip
+ template_output_map:
+ "0": model
+ "1": clip
+ end_input_map:
+ model:
+ - "basic_guider:model"
+ - "basic_scheduler:model"
+ clip:
+ - "minimax_h3:clip"
+
+dynamic_h3_controlnet_chains:
+ h3_controlnets:
+ template: MiniMaxH3FunControlNetApply
+ vae_source: "video_vae_loader:0"
+ target_nodes:
+ - "basic_guider:model"
+ - "basic_scheduler:model"
+
+dynamic_h3_guide_chains:
+ h3_guides:
+ template: MiniMaxH3AddGuide
+ source_node: "minimax_h3"
+ source_output: 0
+ vae_source: "video_vae_loader:0"
+ audio_vae_source: "audio_vae_loader:0"
+ target_nodes:
+ - "basic_guider:conditioning"
+
+connections:
+ - from: "clip_loader:0"
+ to: "minimax_h3:clip"
+ - from: "video_vae_loader:0"
+ to: "minimax_h3:vae"
+ - from: "unet_loader:0"
+ to: "basic_guider:model"
+ - from: "minimax_h3:0"
+ to: "basic_guider:conditioning"
+ - from: "unet_loader:0"
+ to: "basic_scheduler:model"
+ - from: "random_noise:0"
+ to: "sampler_custom_advanced:noise"
+ - from: "basic_guider:0"
+ to: "sampler_custom_advanced:guider"
+ - from: "ksampler_select:0"
+ to: "sampler_custom_advanced:sampler"
+ - from: "basic_scheduler:0"
+ to: "sampler_custom_advanced:sigmas"
+ - from: "minimax_h3:1"
+ to: "sampler_custom_advanced:latent_image"
+ - from: "sampler_custom_advanced:0"
+ to: "vae_decode:samples"
+ - from: "video_vae_loader:0"
+ to: "vae_decode:vae"
+ - from: "sampler_custom_advanced:0"
+ to: "vae_decode_audio:samples"
+ - from: "audio_vae_loader:0"
+ to: "vae_decode_audio:vae"
+ - from: "vae_decode:0"
+ to: "create_video:images"
+ - from: "vae_decode_audio:0"
+ to: "create_video:audio"
+ - from: "create_video:0"
+ to: "save_video:video"
+
+ - from: "first_frame_loader:0"
+ to: "first_frame_scale:image"
+ - from: "first_frame_scale:0"
+ to: "minimax_h3:first_frame"
+
+ - from: "last_frame_loader:0"
+ to: "last_frame_scale:image"
+ - from: "last_frame_scale:0"
+ to: "minimax_h3:last_frame"
+
+ui_map:
+ prompt: "minimax_h3:prompt"
+ width:
+ - "minimax_h3:width"
+ - "first_frame_scale:width"
+ - "last_frame_scale:width"
+ height:
+ - "minimax_h3:height"
+ - "first_frame_scale:height"
+ - "last_frame_scale:height"
+ length: "minimax_h3:length"
+ seed: "random_noise:noise_seed"
+ filename_prefix: "save_video:filename_prefix"
+ first_frame_image: "first_frame_loader:image"
+ last_frame_image: "last_frame_loader:image"
+ steps: "basic_scheduler:steps"
diff --git a/workflow_recipes/h3_ref2va_recipe.yaml b/workflow_recipes/h3_ref2va_recipe.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..baefc3da2cb6e3dd36a94b2dbcd086c388befa6d
--- /dev/null
+++ b/workflow_recipes/h3_ref2va_recipe.yaml
@@ -0,0 +1,170 @@
+nodes:
+ unet_loader:
+ class_type: UNETLoader
+ title: "Load Diffusion Model"
+ params:
+ unet_name: "minimax_h3_ref2va_pruned_nvfp4.safetensors"
+ weight_dtype: "default"
+ clip_loader:
+ class_type: CLIPLoader
+ title: "Load CLIP"
+ params:
+ clip_name: "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors"
+ type: "minimax"
+ device: "default"
+ video_vae_loader:
+ class_type: VAELoader
+ title: "Load Video VAE"
+ params:
+ vae_name: "minimax_h3_video_vae_fp16.safetensors"
+ audio_vae_loader:
+ class_type: VAELoader
+ title: "Load Audio VAE"
+ params:
+ vae_name: "minimax_h3_audio_vae_fp32.safetensors"
+ minimax_h3:
+ class_type: MiniMaxH3ReferenceToVideo
+ title: "MiniMax H3 Reference to Video"
+ params:
+ width: 1344
+ height: 768
+ length: 73
+ ref_image_size: "match"
+ basic_guider:
+ class_type: BasicGuider
+ title: "Basic Guider"
+ ksampler_select:
+ class_type: KSamplerSelect
+ title: "KSamplerSelect"
+ params:
+ sampler_name: "res_multistep"
+ basic_scheduler:
+ class_type: BasicScheduler
+ title: "BasicScheduler"
+ params:
+ scheduler: "simple"
+ steps: 20
+ denoise: 1.0
+ random_noise:
+ class_type: RandomNoise
+ title: "RandomNoise"
+ params:
+ noise_seed: 1
+ sampler_custom_advanced:
+ class_type: SamplerCustomAdvanced
+ title: "SamplerCustomAdvanced"
+ vae_decode:
+ class_type: VAEDecode
+ title: "VAE Decode"
+ vae_decode_audio:
+ class_type: VAEDecodeAudio
+ title: "VAE Decode Audio"
+ create_video:
+ class_type: CreateVideo
+ title: "Create Video"
+ params:
+ fps: 24
+ bit_depth: 8
+ save_video:
+ class_type: SaveVideo
+ title: "Save Video"
+ params:
+ filename_prefix: "video/MiniMax_H3"
+ format: "mp4"
+ codec:
+ codec: "h264"
+
+dynamic_lora_chains:
+ loras:
+ template: LoraLoader
+ output_map:
+ "unet_loader:0": model
+ "clip_loader:0": clip
+ input_map:
+ model: model
+ clip: clip
+ template_output_map:
+ "0": model
+ "1": clip
+ end_input_map:
+ model:
+ - "basic_guider:model"
+ - "basic_scheduler:model"
+ clip:
+ - "minimax_h3:clip"
+
+dynamic_h3_ref_img_chains:
+ ref_images:
+ target_node: "minimax_h3"
+
+dynamic_h3_ref_video_chains:
+ ref_videos:
+ target_node: "minimax_h3"
+
+dynamic_h3_ref_audio_chains:
+ ref_audios:
+ target_node: "minimax_h3"
+
+dynamic_h3_controlnet_chains:
+ h3_controlnets:
+ template: MiniMaxH3FunControlNetApply
+ vae_source: "video_vae_loader:0"
+ target_nodes:
+ - "basic_guider:model"
+ - "basic_scheduler:model"
+
+dynamic_h3_guide_chains:
+ h3_guides:
+ template: MiniMaxH3AddGuide
+ source_node: "minimax_h3"
+ vae_source: "video_vae_loader:0"
+ audio_vae_source: "audio_vae_loader:0"
+ target_nodes:
+ - "basic_guider:conditioning"
+
+connections:
+ - from: "clip_loader:0"
+ to: "minimax_h3:clip"
+ - from: "video_vae_loader:0"
+ to: "minimax_h3:vae"
+ - from: "audio_vae_loader:0"
+ to: "minimax_h3:audio_vae"
+ - from: "unet_loader:0"
+ to: "basic_guider:model"
+ - from: "minimax_h3:0"
+ to: "basic_guider:conditioning"
+ - from: "unet_loader:0"
+ to: "basic_scheduler:model"
+ - from: "random_noise:0"
+ to: "sampler_custom_advanced:noise"
+ - from: "basic_guider:0"
+ to: "sampler_custom_advanced:guider"
+ - from: "ksampler_select:0"
+ to: "sampler_custom_advanced:sampler"
+ - from: "basic_scheduler:0"
+ to: "sampler_custom_advanced:sigmas"
+ - from: "minimax_h3:1"
+ to: "sampler_custom_advanced:latent_image"
+ - from: "sampler_custom_advanced:0"
+ to: "vae_decode:samples"
+ - from: "video_vae_loader:0"
+ to: "vae_decode:vae"
+ - from: "sampler_custom_advanced:0"
+ to: "vae_decode_audio:samples"
+ - from: "audio_vae_loader:0"
+ to: "vae_decode_audio:vae"
+ - from: "vae_decode:0"
+ to: "create_video:images"
+ - from: "vae_decode_audio:0"
+ to: "create_video:audio"
+ - from: "create_video:0"
+ to: "save_video:video"
+
+ui_map:
+ prompt: "minimax_h3:prompt"
+ width: "minimax_h3:width"
+ height: "minimax_h3:height"
+ length: "minimax_h3:length"
+ seed: "random_noise:noise_seed"
+ filename_prefix: "save_video:filename_prefix"
+ steps: "basic_scheduler:steps"
diff --git a/yaml/chain_features.yaml b/yaml/chain_features.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..b2f1aa47432be69a7d955316c48b8c02a9b6daf1
--- /dev/null
+++ b/yaml/chain_features.yaml
@@ -0,0 +1,108 @@
+# Complete Feature & Chain Definitions Configuration for MCP Tools
+# Every chain injector in chain_injectors/ corresponds 1-to-1 with an entry here.
+
+lora:
+ chains: lora
+ display_name: "LoRA Fine-tuning Injector"
+ description: "Injects LoRA weights into UNet/DiT model and CLIP text encoder for custom style, character, or domain adaptation. Note: For Civitai, specify the Version ID (modelVersionId) instead of the main Model ID."
+ supported_tasks:
+ - txt2img
+ - img2img
+ - inpaint
+ - outpaint
+ - hires_fix
+ max_count: 5
+ usage_guideline: "Specify source ('Hugging Face' or 'Civitai'). For Hugging Face, provide HF repo file path. For Civitai, provide lora_value as the Version ID (modelVersionId). Scale (0.0~2.0) controls weight strength."
+ parameters_schema:
+ type: object
+ properties:
+ source:
+ type: string
+ enum: ["Hugging Face", "Civitai"]
+ description: "Download source for the LoRA model. Use 'Hugging Face' to download by repo file path, or 'Civitai' to download by Version ID (modelVersionId)."
+ lora_value:
+ type: string
+ description: "For Civitai: the Version ID (modelVersionId). For Hugging Face: repo_id/filename.extension or repo_id/folder_path/filename.extension."
+ scale:
+ type: number
+ default: 1.0
+ minimum: 0.0
+ maximum: 2.0
+ description: "Unified strength applied to both the UNet/DiT model and CLIP text encoder (0.0 to 2.0)."
+ required:
+ - source
+ - lora_value
+
+h3_controlnet:
+ chains: h3_controlnet
+ display_name: "H3 ControlNet"
+ description: "ControlNet conditioning injector for MiniMax-H3 models using video guidance."
+ supported_tasks:
+ - txt2video
+ - img2video
+ max_count: 5
+ usage_guideline: "Supply ControlNet type (e.g., 'Pose', 'Canny'), series (e.g., 'minimax_h3_fun_controlnet_union'), control video, and optional strength/start_percent/end_percent."
+ parameters_schema:
+ type: object
+ properties:
+ type:
+ type: string
+ description: "ControlNet conditioning type (e.g. Pose, Canny, HED, Depth, MLSD)."
+ series:
+ type: string
+ description: "ControlNet model series name."
+ video:
+ type: string
+ description: "Control video file path, URL, or Base64 Data URI."
+ strength:
+ type: number
+ default: 1.0
+ minimum: 0.0
+ maximum: 2.0
+ description: "Control strength applied to the diffusion model (0.0 to 2.0)."
+ start_percent:
+ type: number
+ default: 0.0
+ minimum: 0.0
+ maximum: 1.0
+ description: "Step percentage to start applying ControlNet (0.0 to 1.0)."
+ end_percent:
+ type: number
+ default: 1.0
+ minimum: 0.0
+ maximum: 1.0
+ description: "Step percentage to stop applying ControlNet (0.0 to 1.0)."
+ required:
+ - video
+
+h3_guide:
+ chains: h3_guide
+ display_name: "MiniMax H3 Keyframe Guide"
+ description: "Keyframe guide conditioning injector for MiniMax-H3 models using timeline keyframes (Add Guide for MiniMax H3)."
+ supported_tasks:
+ - ref2va
+ - t2va
+ - i2va
+ - fl2va
+ max_count: 5
+ usage_guideline: "Supply guide media (image, video, or audio) and keyframe position (frame_idx or time_seconds)."
+ parameters_schema:
+ type: object
+ properties:
+ image:
+ type: string
+ description: "Keyframe guide image file path, URL, or Base64 Data URI."
+ video:
+ type: string
+ description: "Keyframe guide video file path, URL, or Base64 Data URI."
+ audio:
+ type: string
+ description: "Keyframe guide audio file path, URL, or Base64 Data URI."
+ frame_idx:
+ type: integer
+ minimum: 0
+ description: "Target frame index on the output timeline."
+ time_seconds:
+ type: number
+ minimum: 0.0
+ description: "Target timestamp in seconds on the output timeline."
diff --git a/yaml/file_list.yaml b/yaml/file_list.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..71f3d526f52a9a7b2d67c656c7d1da3075d04067
--- /dev/null
+++ b/yaml/file_list.yaml
@@ -0,0 +1,33 @@
+file:
+ diffusion_models:
+ - filename: "minimax_h3_fl2va_pruned_nvfp4.safetensors"
+ source: "hf"
+ repo_id: "lilcheaty/MiniMax-H3-NVFP4"
+ repository_file_path: "minimax_h3_fl2va_pruned_nvfp4.safetensors"
+ - filename: "minimax_h3_ref2va_pruned_nvfp4.safetensors"
+ source: "hf"
+ repo_id: "lilcheaty/MiniMax-H3-NVFP4"
+ repository_file_path: "minimax_h3_ref2va_pruned_nvfp4.safetensors"
+ text_encoders:
+ - filename: "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors"
+ source: "hf"
+ repo_id: "Comfy-Org/MiniMax-H3"
+ repository_file_path: "text_encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors"
+ vae:
+ - filename: "minimax_h3_audio_vae_fp32.safetensors"
+ source: "hf"
+ repo_id: "Comfy-Org/MiniMax-H3"
+ repository_file_path: "vae/minimax_h3_audio_vae_fp32.safetensors"
+ - filename: "minimax_h3_video_vae_fp16.safetensors"
+ source: "hf"
+ repo_id: "Comfy-Org/MiniMax-H3"
+ repository_file_path: "vae/minimax_h3_video_vae_fp16.safetensors"
+ model_patches:
+ - filename: "minimax_h3_fun_controlnet_union_2.0_pruned_int8_convrot.safetensors"
+ source: "hf"
+ repo_id: "Kijai/MiniMax-H3-experimental"
+ repository_file_path: "model_patches/minimax_h3_fun_controlnet_union_2.0_pruned_int8_convrot.safetensors"
+ - filename: "minimax_h3_fun_controlnet_union_pruned_int8_convrot.safetensors"
+ source: "hf"
+ repo_id: "Kijai/MiniMax-H3-experimental"
+ repository_file_path: "model_patches/minimax_h3_fun_controlnet_union_pruned_int8_convrot.safetensors"
\ No newline at end of file
diff --git a/yaml/h3_controlnet_models.yaml b/yaml/h3_controlnet_models.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..75c164ac2a68bee96a0e40c6fdc416bd2c48ce53
--- /dev/null
+++ b/yaml/h3_controlnet_models.yaml
@@ -0,0 +1,8 @@
+H3_ControlNet:
+ MiniMax-H3:
+ - Filepath: "minimax_h3_fun_controlnet_union_2.0_pruned_int8_convrot.safetensors"
+ Series: "alibaba-pai/MiniMax-H3-Fun-Controlnet-Union-2.0"
+ Type: ["Canny", "Depth", "HED", "MLSD", "Pose", "Scribble", "Layout", "Gray"]
+ - Filepath: "minimax_h3_fun_controlnet_union_pruned_int8_convrot.safetensors"
+ Series: "alibaba-pai/MiniMax-H3-Fun-Controlnet-Union"
+ Type: ["Canny", "Depth", "HED", "MLSD", "Pose"]