Download nodes/basic.py from turtle89431/Moxie-Multimedia: direct link, hf CLI and curl.
- Browser
- Download file 191 kB
-
https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/nodes/basic.py
- Command line
-
hf download hf://turtle89431/Moxie-Multimedia/nodes/basic.py
-
curl -L -o basic.py https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/nodes/basic.py
191 kB
| import json | |
| import math | |
| import os | |
| import re | |
| import tempfile | |
| from copy import deepcopy | |
| from enum import Enum | |
| from pathlib import Path | |
| import folder_paths | |
| import nodes as comfy_nodes | |
| import torch | |
| import torch.nn.functional as F | |
| from comfy_api.latest import InputImpl, Types, io | |
| from comfy_execution.graph_utils import GraphBuilder, is_link | |
| from comfy.utils import ProgressBar | |
| from ..utils import ( | |
| audio_db_to_gain, | |
| audio_is_muted, | |
| audio_volume_db, | |
| build_minimax_multitrack_data_from_prompt_override, | |
| build_multitrack_data_from_prompt_override, | |
| burn_subtitles_with_ffmpeg, | |
| collect_multitrack_subtitle_segments, | |
| default_subtitle_filename, | |
| equirectangular_to_perspective, | |
| ffprobe_info, | |
| frames_to_seconds, | |
| load_audio_waveform, | |
| log_node_info, | |
| log_stage_time, | |
| iter_valid_audio_inputs, | |
| is_minimax_prompt_override, | |
| merge_audio_inputs, | |
| audio_data_uris, | |
| image_tensor_data_uris, | |
| LLAMACPP_MODEL, | |
| MINIMAX_MODEL, | |
| PROMPT_ENHANCER_MAX_TOKENS, | |
| PROMPT_ENHANCER_MODELS, | |
| PromptEnhancerApiError, | |
| PromptEnhancerClient, | |
| prompt_enhancer_video_inputs, | |
| minimax_length_to_seconds, | |
| minimax_prompt_override_media_types, | |
| merge_video_track_with_ffmpeg, | |
| canonicalize_multitrack_slot_content, | |
| multitrack_is_shared_reference, | |
| multitrack_is_muted_image, | |
| multitrack_media_identity, | |
| multitrack_shared_task_images, | |
| multitrack_segments_in_window, | |
| multitrack_slot_media_types, | |
| multitrack_task_images_with_shared, | |
| parse_subtitle_text, | |
| parse_override_segments, | |
| prompt_override_has_frame_ranges, | |
| prompt_override_has_value, | |
| resize_image, | |
| render_single_video_segment_with_ffmpeg, | |
| resolve_video_path, | |
| silence, | |
| trim_audio, | |
| video_input_to_local_file, | |
| video_data_uris, | |
| write_ass_file, | |
| write_srt_file, | |
| ) | |
| from ..utils.prompt_builder import build_llm_prompt, build_prompt_request | |
| from ..utils.multitrack import ( | |
| _as_list_input, | |
| _embedded_multitrack_media, | |
| _index_slot_audio, | |
| _merge_audio_track, | |
| _multitrack_frame_value, | |
| _multitrack_timeline_end, | |
| _parse_track_data, | |
| _resolve_multitrack_audio, | |
| _resolve_multitrack_video, | |
| _resolve_timeline_image_item, | |
| _resize_multitrack_video, | |
| _trim_track_audio, | |
| _video_stream_source, | |
| multitrack_audio_lock_is_effective, | |
| multitrack_runtime_cache, | |
| ) | |
| from ..utils.video import extract_video_audio | |
| # --------------------------------------------------------------------------- | |
| # Resolution combo setup | |
| # --------------------------------------------------------------------------- | |
| class AspectRatio(str, Enum): | |
| SQUARE = "1:1 (Square)" | |
| PHOTO_V = "2:3 (Portrait Photo)" | |
| PHOTO_H = "3:2 (Photo)" | |
| STANDARD_V = "3:4 (Portrait Standard)" | |
| STANDARD_H = "4:3 (Standard)" | |
| WIDESCREEN_V = "9:16 (Portrait Widescreen)" | |
| WIDESCREEN_H = "16:9 (Widescreen)" | |
| ULTRAWIDE_H = "21:9 (Ultrawide)" | |
| ASPECT_RATIOS: dict[AspectRatio, tuple[int, int]] = { | |
| AspectRatio.SQUARE: (1, 1), | |
| AspectRatio.PHOTO_V: (2, 3), | |
| AspectRatio.PHOTO_H: (3, 2), | |
| AspectRatio.STANDARD_V: (3, 4), | |
| AspectRatio.STANDARD_H: (4, 3), | |
| AspectRatio.WIDESCREEN_V: (9, 16), | |
| AspectRatio.WIDESCREEN_H: (16, 9), | |
| AspectRatio.ULTRAWIDE_H: (21, 9), | |
| } | |
| BASE_RESOLUTIONS = [ | |
| ["width", "height", "auto"], | |
| ["width", "height", "shortest"], | |
| ["width", "height", "longest"], | |
| ["width", "height", "custom"], | |
| ["width", "height", "megapixels"], | |
| [480, 832, "9:16"], | |
| [544, 960, "9:16"], | |
| [576, 1024, "9:16"], | |
| [720, 1280, "9:16"], | |
| [768, 1024, "3:4"], | |
| [768, 1344, "9:16"], | |
| [816, 1456, "9:16"], | |
| [817, 1920, "1:2.35"], | |
| [864, 1536, "9:16"], | |
| [1080, 1920, "9:16"], | |
| [1920, 1080, "16:9"], | |
| [1920, 817, "2.35:1"], | |
| [1536, 864, "16:9"], | |
| [1456, 816, "16:9"], | |
| [1344, 768, "16:9"], | |
| [1280, 720, "16:9"], | |
| [1024, 768, "4:3"], | |
| [1024, 576, "16:9"], | |
| [960, 544, "16:9"], | |
| [832, 480, "16:9"], | |
| ] | |
| VIDEO_FORMATS = { | |
| 'MiniMax': {'target_rate': 24, 'dim': (32,0,1344,768), 'frames':(17,5)}, | |
| } | |
| resolution_strings = [f"{w} x {h} ({r})" for w, h, r in BASE_RESOLUTIONS] | |
| resize_method_input = io.Combo.Input( | |
| "resize_method", | |
| default="stretch", | |
| options=["stretch", "resize", "pad", "pad (white)", "pad_edge", "pad_edge_pixel", "crop", "pillarbox_blur"], | |
| ) | |
| megapixels_input = [ | |
| io.Combo.Input( | |
| "aspect_ratio", | |
| options=AspectRatio, | |
| default=AspectRatio.SQUARE, | |
| tooltip="The aspect ratio for the output dimensions.", | |
| ), | |
| io.Float.Input( | |
| "megapixels", | |
| default=1.0, | |
| min=0.1, | |
| max=16.0, | |
| step=0.1, | |
| tooltip="Target total megapixels. 1.0 MP ≈ 1024x1024 for square.", | |
| ), | |
| ] | |
| resolution_combo_options = [ | |
| io.DynamicCombo.Option( | |
| s, | |
| [ | |
| io.Int.Input("width", default=544, min=32, max=8096, step=8), | |
| io.Int.Input("height", default=960, min=32, max=8096, step=8), | |
| resize_method_input, | |
| ] | |
| if "custom" in s | |
| else ( | |
| [ | |
| io.Int.Input("resize_to_pixel", default=960, min=64, max=8096, step=8), | |
| resize_method_input | |
| ] | |
| if "shortest" in s or "longest" in s | |
| else ( | |
| megapixels_input if "megapixels" in s | |
| else [resize_method_input] | |
| ) | |
| ), | |
| ) | |
| for s in resolution_strings | |
| ] | |
| # --------------------------------------------------------------------------- | |
| # Custom types | |
| # --------------------------------------------------------------------------- | |
| TYPE_TIMELINE = io.Custom(io_type="TIMELINE") | |
| TYPE_TIMELINE_INFO = io.Custom(io_type="TIMELINE_INFO") | |
| TYPE_TRACK_DATA = io.Custom(io_type="TRACK_DATA") | |
| TYPE_IMAGE_DATA = io.Custom(io_type="IMAGE_DATA") | |
| TYPE_TRACKS_INFO = io.Custom(io_type="TRACKS_INFO") | |
| TYPE_LLAMACPP_MODEL = io.Custom(io_type="LLAMACPPMODEL") | |
| TYPE_LLAMACPP_MODEL_CONFIG = io.Custom(io_type="LLAMACPPMODEL_CONFIG") | |
| TYPE_PROMPT_ENHANCER_ACCOUNT = io.Custom(io_type="EASY_API_ACCOUNT") | |
| CATEGORY_MEDIA = "EasyUse/Media" | |
| CATEGORY_TIMELINE = "EasyUse/TimelineEditor" | |
| CATEGORY_MULTITRACK = "EasyUse/MultiTrackEditor" | |
| CATEGORY_AUDIO = "EasyUse/Audio" | |
| CATEGORY_LOGIC = "EasyUse/Logic" | |
| CATEGORY_VIDEO = "EasyUse/Video" | |
| PROMPT_FORMAT_OPTIONS = ["default", "promptRelay"] | |
| # Legacy tolerance only: workflows saved while the format combo displayed a | |
| # branded label still queue that label; every consumer in the chain - | |
| # tracks_info, conditioning caches, saved project manifests, frontend lookups - | |
| # keys on the internal value below, so queued values are normalized back at | |
| # both node entries (and check_lazy_status) at execution time. | |
| FORMAT_DISPLAY_LABEL = "Moxie Multimedia" | |
| FORMAT_INTERNAL_VALUE = "MiniMax" | |
| def _normalize_format(format: object) -> str: | |
| """Map the combo's display label (or a legacy queued value) to the | |
| internal format value the whole chain speaks.""" | |
| if isinstance(format, list): | |
| format = format[0] if format else FORMAT_INTERNAL_VALUE | |
| if format in (None, "", FORMAT_DISPLAY_LABEL, FORMAT_INTERNAL_VALUE): | |
| return FORMAT_INTERNAL_VALUE | |
| return str(format) | |
| LLAMA_CPP_INSTRUCT_NODE_ID = "llama_cpp_instruct_adv" | |
| LLAMA_CPP_IMAGE_LIST_BRIDGE_NODE_ID = "easy multiTrackPromptEnhancerImageListBridge" | |
| MULTITRACK_PROMPT_PROJECT_APPLY_NODE_ID = ( | |
| "easy multiTrackPromptEnhanceToProjectApply" | |
| ) | |
| STRING_COMPARE_NODE_ID = "StringCompare" | |
| STRING_REPLACE_NODE_ID = "StringReplace" | |
| STRING_TRIM_NODE_ID = "StringTrim" | |
| SWITCH_NODE_ID = "ComfySwitchNode" | |
| LLAMA_CPP_INSTALL_URL = "https://github.com/lihaoyun6/ComfyUI-llama-cpp_vlm" | |
| PROMPT_ENHANCER_RATIO_OPTIONS = [ | |
| "adaptive", | |
| "21:9", | |
| "16:9", | |
| "4:3", | |
| "1:1", | |
| "3:4", | |
| "9:16", | |
| ] | |
| H3_AUDIO_LATENT_FPS = 40.0 | |
| def _h3_nested_parts( | |
| value: object, value_name: str | |
| ) -> tuple[torch.Tensor, torch.Tensor]: | |
| """Return the video/audio tensors from a Moxie nested value.""" | |
| if not getattr(value, "is_nested", False): | |
| raise ValueError(f"Expected {value_name} to be a Moxie nested tensor.") | |
| try: | |
| parts = ( | |
| tuple(value.tensors) | |
| if hasattr(value, "tensors") | |
| else tuple(value.unbind()) | |
| ) | |
| except (AttributeError, RuntimeError, TypeError) as error: | |
| raise ValueError(f"Unable to read {value_name} streams: {error}") from error | |
| if len(parts) != 2: | |
| raise ValueError( | |
| f"Expected 2 {value_name} streams (video, audio), got {len(parts)}." | |
| ) | |
| return parts[0], parts[1] | |
| def _split_h3_av_latent(latent: dict) -> tuple[torch.Tensor, torch.Tensor]: | |
| """Validate and split a Moxie joint audio/video latent.""" | |
| if not isinstance(latent, dict) or "samples" not in latent: | |
| raise ValueError("Expected a LATENT dictionary with a 'samples' entry.") | |
| samples = latent["samples"] | |
| video, audio = _h3_nested_parts(samples, "latent") | |
| if video.ndim != 5 or video.shape[1] != 24: | |
| raise ValueError( | |
| f"Invalid H3 video latent shape {tuple(video.shape)}; expected [B, 24, T, H, W]." | |
| ) | |
| if audio.ndim != 4 or audio.shape[1] != 32 or audio.shape[2] != 2: | |
| raise ValueError( | |
| f"Invalid H3 audio latent shape {tuple(audio.shape)}; expected [B, 32, 2, T]." | |
| ) | |
| if video.shape[0] != 1 or audio.shape[0] != 1: | |
| raise ValueError("Moxie audio locking currently requires batch size 1.") | |
| return video, audio | |
| def _split_h3_noise_mask( | |
| latent: dict, | |
| ) -> tuple[torch.Tensor | None, torch.Tensor | None]: | |
| """Split an optional H3 mask, including legacy video-only masks.""" | |
| mask = latent.get("noise_mask") | |
| if mask is None: | |
| return None, None | |
| if getattr(mask, "is_nested", False): | |
| return _h3_nested_parts(mask, "noise_mask") | |
| if isinstance(mask, torch.Tensor): | |
| return mask, None | |
| raise ValueError(f"Unsupported H3 noise_mask type: {type(mask)!r}.") | |
| def _fit_h3_audio_waveform( | |
| waveform: torch.Tensor, target_samples: int, short_audio_mode: str | |
| ) -> torch.Tensor: | |
| """Crop, loop, or silence-pad waveform data before H3 audio VAE encoding.""" | |
| current_samples = waveform.shape[-1] | |
| if current_samples == target_samples: | |
| return waveform.contiguous() | |
| if current_samples > target_samples: | |
| return waveform[..., :target_samples].contiguous() | |
| if short_audio_mode == "loop": | |
| if current_samples <= 0: | |
| raise ValueError("Cannot loop an empty audio waveform.") | |
| repeats = math.ceil(target_samples / current_samples) | |
| repeat_shape = [1] * waveform.ndim | |
| repeat_shape[-1] = repeats | |
| return waveform.repeat(*repeat_shape)[..., :target_samples].contiguous() | |
| return F.pad(waveform, (0, target_samples - current_samples), value=0.0) | |
| def _fit_h3_encoded_audio(encoded: torch.Tensor, target_length: int) -> torch.Tensor: | |
| """Correct audio VAE temporal rounding without assuming zero latent is silence.""" | |
| encoded_length = encoded.shape[-1] | |
| if encoded_length == target_length: | |
| return encoded.contiguous() | |
| if encoded_length > target_length: | |
| return encoded[..., :target_length].contiguous() | |
| if encoded_length <= 0: | |
| raise ValueError("The Moxie audio VAE returned an empty latent.") | |
| tail = encoded[..., -1:].repeat_interleave(target_length - encoded_length, dim=-1) | |
| return torch.cat((encoded, tail), dim=-1).contiguous() | |
| def _align_video_frame_count(frame_count: int, format_name: str) -> int: | |
| frame_grid = VIDEO_FORMATS.get(format_name, {}).get("frames") | |
| if not frame_grid: | |
| return frame_count | |
| step, remainder = (int(value) for value in frame_grid) | |
| if step <= 0: | |
| return frame_count | |
| return frame_count + (remainder - frame_count) % step | |
| def _nearest_video_frame_count(frame_count: int | float, format_name: str) -> int: | |
| frame_grid = VIDEO_FORMATS.get(format_name, {}).get("frames") | |
| if not frame_grid: | |
| return max(0, math.floor(float(frame_count) + 0.5)) | |
| step, remainder = (int(value) for value in frame_grid) | |
| if step <= 0: | |
| return max(0, math.floor(float(frame_count) + 0.5)) | |
| normalized_count = max(float(remainder), float(frame_count)) | |
| grid_index = math.floor((normalized_count - remainder) / step + 0.5) | |
| return remainder + grid_index * step | |
| def _video_frame_count_from_duration( | |
| duration_frames: int | float, | |
| source_frame_rate: int | float, | |
| format_name: str, | |
| ) -> int: | |
| if format_name == "MiniMax": | |
| # Keep the timeline's native FPS; only snap its frame count to 17k+5. | |
| return _nearest_video_frame_count(duration_frames, format_name) | |
| format_info = VIDEO_FORMATS.get(format_name, {}) | |
| target_frame_rate = float(format_info.get("target_rate", source_frame_rate)) | |
| safe_source_rate = float(source_frame_rate) | |
| if safe_source_rate <= 0 or target_frame_rate <= 0: | |
| target_frames = max(0.0, float(duration_frames)) | |
| else: | |
| target_frames = max(0.0, float(duration_frames)) * target_frame_rate / safe_source_rate | |
| return _align_video_frame_count(math.ceil(target_frames), format_name) | |
| # --------------------------------------------------------------------------- | |
| # prompt_override parsing helpers | |
| # --------------------------------------------------------------------------- | |
| _parse_override_segments = parse_override_segments | |
| def _is_valid_audio(audio) -> bool: | |
| if not isinstance(audio, dict): | |
| return False | |
| waveform = audio.get('waveform') | |
| if not isinstance(waveform, torch.Tensor): | |
| return False | |
| try: | |
| return bool(waveform.any()) | |
| except (RuntimeError, TypeError, ValueError): | |
| return False | |
| def _single_valid_audio(audio_input) -> 'dict | None': | |
| """Return the only valid audio dict from input, ignoring empty list items.""" | |
| if audio_input is None: | |
| return None | |
| if _is_valid_audio(audio_input): | |
| return audio_input | |
| if not isinstance(audio_input, list): | |
| return None | |
| valid = [ | |
| audio | |
| for audio in audio_input | |
| if _is_valid_audio(audio) | |
| ] | |
| return valid[0] if len(valid) == 1 else None | |
| def _resolve_configured_dimensions( | |
| resolution: str | dict, | |
| format_name: str, | |
| source_dimensions: tuple[int, int] | None = None, | |
| ) -> tuple[int, int]: | |
| if isinstance(resolution, dict): | |
| resolution_label = resolution.get("resolution", "") | |
| width_value = resolution.get("width") | |
| height_value = resolution.get("height") | |
| resize_to_pixel_value = resolution.get("resize_to_pixel") | |
| aspect_ratio_value = resolution.get("aspect_ratio") | |
| megapixels_value = resolution.get("megapixels") | |
| else: | |
| resolution_label = resolution | |
| width_value = None | |
| height_value = None | |
| resize_to_pixel_value = None | |
| aspect_ratio_value = None | |
| megapixels_value = None | |
| if isinstance(resolution_label, list): | |
| resolution_label = resolution_label[0] if resolution_label else "" | |
| if isinstance(width_value, list): | |
| width_value = width_value[0] if width_value else None | |
| if isinstance(height_value, list): | |
| height_value = height_value[0] if height_value else None | |
| if isinstance(resize_to_pixel_value, list): | |
| resize_to_pixel_value = resize_to_pixel_value[0] if resize_to_pixel_value else None | |
| if isinstance(aspect_ratio_value, list): | |
| aspect_ratio_value = aspect_ratio_value[0] if aspect_ratio_value else None | |
| if isinstance(megapixels_value, list): | |
| megapixels_value = megapixels_value[0] if megapixels_value else None | |
| resolution_text = str(resolution_label) | |
| normalized_resolution = resolution_text.lower() | |
| divisor = _video_format_dimension_multiple(format_name) | |
| if "megapixels" in normalized_resolution: | |
| width, height = _resolve_megapixel_dimensions( | |
| aspect_ratio_value, | |
| megapixels_value, | |
| divisor, | |
| ) | |
| elif "custom" in normalized_resolution: | |
| width = int(width_value) if width_value else 544 | |
| height = int(height_value) if height_value else 960 | |
| elif ("shortest" in normalized_resolution or "longest" in normalized_resolution) and source_dimensions: | |
| source_width, source_height = source_dimensions | |
| resize_to_pixel = int(resize_to_pixel_value) if resize_to_pixel_value else 960 | |
| aspect = source_width / source_height | |
| if "longest" in normalized_resolution: | |
| if source_width >= source_height: | |
| width, height = resize_to_pixel, round(resize_to_pixel / aspect) | |
| else: | |
| width, height = round(resize_to_pixel * aspect), resize_to_pixel | |
| elif source_width <= source_height: | |
| width, height = resize_to_pixel, round(resize_to_pixel / aspect) | |
| else: | |
| width, height = round(resize_to_pixel * aspect), resize_to_pixel | |
| else: | |
| preset = re.search(r"(\d+)\s*x\s*(\d+)", resolution_text) | |
| if preset: | |
| width = int(preset.group(1)) | |
| height = int(preset.group(2)) | |
| elif "auto" in normalized_resolution and source_dimensions: | |
| width, height = source_dimensions | |
| else: | |
| width, height = 544, 960 | |
| if divisor > 1 and "megapixels" not in normalized_resolution: | |
| width = max(divisor, ((width + divisor - 1) // divisor) * divisor) | |
| height = max(divisor, ((height + divisor - 1) // divisor) * divisor) | |
| return width, height | |
| def _video_format_dimension_multiple(format_name: str) -> int: | |
| format_info = VIDEO_FORMATS.get(format_name, {}) | |
| return max(1, int(format_info.get("dim", [1])[0]) if format_info else 1) | |
| def _resolve_megapixel_dimensions( | |
| aspect_ratio: object, | |
| megapixels: object, | |
| multiple: int, | |
| ) -> tuple[int, int]: | |
| ratio = ASPECT_RATIOS.get( | |
| str(aspect_ratio or AspectRatio.SQUARE.value), | |
| ASPECT_RATIOS[AspectRatio.SQUARE], | |
| ) | |
| megapixel_value = float(megapixels) if megapixels else 1.0 | |
| total_pixels = megapixel_value * 1024 * 1024 | |
| scale = math.sqrt(total_pixels / (ratio[0] * ratio[1])) | |
| width = round(ratio[0] * scale / multiple) * multiple | |
| height = round(ratio[1] * scale / multiple) * multiple | |
| return width, height | |
| def _configured_resize_method(resolution: str | dict) -> str: | |
| if not isinstance(resolution, dict): | |
| return "stretch" | |
| resize_method = resolution.get("resize_method", "stretch") | |
| if isinstance(resize_method, list): | |
| resize_method = resize_method[0] if resize_method else "stretch" | |
| return str(resize_method) | |
| def _resolution_needs_source_dimensions(resolution: str | dict) -> bool: | |
| label = resolution.get("resolution", "") if isinstance(resolution, dict) else resolution | |
| if isinstance(label, list): | |
| label = label[0] if label else "" | |
| normalized = str(label).lower() | |
| return any(mode in normalized for mode in ("auto", "shortest", "longest")) | |
| def _multitrack_media_is_deferred(info: dict, media_type: str) -> bool: | |
| """Use per-type eager metadata, falling back to the legacy global mode.""" | |
| eager_types = info.get("eager_media_types") | |
| if isinstance(eager_types, (list, tuple, set)): | |
| return media_type not in eager_types | |
| return info.get("media_loading") == "deferred" | |
| def _deferred_video_dimensions(video_segments: list[tuple[int, int, dict]]) -> tuple[int, int] | None: | |
| """Probe the first file or URL video without decoding or materializing it.""" | |
| for _track_index, _segment_index, content in video_segments: | |
| source_type = str(content.get("source_type", "input")) | |
| if source_type in {"slot", "preset"}: | |
| continue | |
| if source_type == "url": | |
| source = content.get("url") | |
| else: | |
| source = resolve_video_path( | |
| source_type, | |
| content.get("file_path"), | |
| content.get("local_path"), | |
| content.get("url"), | |
| ) | |
| if not isinstance(source, str) or not source: | |
| continue | |
| metadata = ffprobe_info(source) or {} | |
| width = metadata.get("width") | |
| height = metadata.get("height") | |
| if width and height: | |
| return int(width), int(height) | |
| return None | |
| _MAX_SHARED_AUDIO_REFERENCE_SECONDS = 15.0 | |
| _MAX_SHARED_VIDEO_REFERENCE_SECONDS = 15.0 | |
| def _shared_reference_segment(track: dict) -> 'dict | None': | |
| """Return the one audio/video clip reused as this track's shared reference.""" | |
| if track.get("type") not in {"audio", "video"}: | |
| return None | |
| for segment in track.get("segments", []): | |
| if not isinstance(segment, dict): | |
| continue | |
| content = segment.get("content", {}) | |
| if ( | |
| isinstance(content, dict) | |
| and content.get("media_type") == track.get("type") | |
| and multitrack_is_shared_reference(content) | |
| ): | |
| return segment | |
| return None | |
| def _build_shared_reference_audio( | |
| segment: dict, | |
| audio: dict, | |
| base_volume_db: float = 0.0, | |
| muted: bool = False, | |
| ) -> dict: | |
| """Return the complete source audio from zero, capped at 15 seconds.""" | |
| waveform = audio.get("waveform") | |
| sample_rate = int(audio.get("sample_rate", 44100)) | |
| if not isinstance(waveform, torch.Tensor): | |
| return { | |
| "waveform": torch.zeros(1, 1, 1), | |
| "sample_rate": sample_rate, | |
| } | |
| max_samples = max(1, round(_MAX_SHARED_AUDIO_REFERENCE_SECONDS * sample_rate)) | |
| reference = waveform[..., :max_samples] | |
| content = segment.get("content", {}) | |
| if not isinstance(content, dict): | |
| content = {} | |
| gain = 0.0 if muted or audio_is_muted(content) else audio_db_to_gain( | |
| base_volume_db + audio_volume_db(content) | |
| ) | |
| return { | |
| "waveform": reference * gain, | |
| "sample_rate": sample_rate, | |
| } | |
| def _build_shared_reference_video(video: object) -> object | None: | |
| """Return the complete shared video, capped at 15 seconds when necessary.""" | |
| if video is None: | |
| return None | |
| try: | |
| duration = float(video.get_duration()) | |
| except (AttributeError, TypeError, ValueError): | |
| return video | |
| if duration <= _MAX_SHARED_VIDEO_REFERENCE_SECONDS: | |
| return video | |
| trimmed = video.as_trimmed( | |
| start_time=0.0, | |
| duration=_MAX_SHARED_VIDEO_REFERENCE_SECONDS, | |
| strict_duration=False, | |
| ) | |
| return trimmed if trimmed is not None else video | |
| def _merge_video_track_tensor( | |
| segments: list[tuple[dict, object]], | |
| total_length: int, | |
| frame_rate: float, | |
| width: int, | |
| height: int, | |
| base_volume_db: float = 0.0, | |
| audio_muted: bool = False, | |
| ): | |
| merged_frames = torch.zeros(total_length, height, width, 3) | |
| embedded_audio_segments: list[tuple[dict, dict]] = [] | |
| components_cache: dict[int, object] = {} | |
| for segment, video in sorted(segments, key=lambda item: int(item[0].get("start_frame", 0))): | |
| components = components_cache.get(id(video)) | |
| if components is None: | |
| components = video.get_components() | |
| components_cache[id(video)] = components | |
| frames = components.images | |
| start_frame = max(0, int(segment.get("start_frame", 0))) | |
| end_frame = min(total_length, max(start_frame, int(segment.get("end_frame", start_frame)))) | |
| segment_frames = end_frame - start_frame | |
| source_rate = float(components.frame_rate) | |
| origin_start = int(segment.get("origin_start_frame", start_frame)) | |
| source_start_frame = max(0, math.floor((start_frame - origin_start) * source_rate / frame_rate)) | |
| available_frames = ( | |
| min( | |
| segment_frames, | |
| max(0, int((frames.shape[0] - source_start_frame) * frame_rate / source_rate)), | |
| ) | |
| if frames.shape[0] > 0 and source_rate > 0 | |
| else 0 | |
| ) | |
| if available_frames > 0: | |
| indices = source_start_frame + torch.floor( | |
| torch.arange(available_frames, device=frames.device) * source_rate / frame_rate | |
| ).long().clamp(max=frames.shape[0] - 1) | |
| merged_frames[start_frame:start_frame + available_frames] = frames[indices].cpu() | |
| if isinstance(components.audio, dict): | |
| embedded_audio_segments.append((segment, components.audio)) | |
| merged_audio = ( | |
| _merge_audio_track( | |
| embedded_audio_segments, | |
| total_length, | |
| frame_rate, | |
| base_volume_db, | |
| audio_muted, | |
| ) | |
| if embedded_audio_segments | |
| else None | |
| ) | |
| return InputImpl.VideoFromComponents( | |
| Types.VideoComponents( | |
| images=merged_frames, | |
| audio=merged_audio, | |
| frame_rate=frame_rate, | |
| ) | |
| ) | |
| def _merge_video_track( | |
| segments: list[tuple[dict, object]], | |
| total_length: int, | |
| frame_rate: float, | |
| width: int, | |
| height: int, | |
| base_volume_db: float = 0.0, | |
| audio_muted: bool = False, | |
| resize_method: str | None = None, | |
| ): | |
| if len(segments) == 1 and total_length > 0 and frame_rate > 0: | |
| segment, video = segments[0] | |
| content = segment.get("content", {}) | |
| if not isinstance(content, dict): | |
| content = {} | |
| segment_start = int(segment.get("start_frame", 0)) | |
| segment_end = int(segment.get("end_frame", 0)) | |
| origin_start = int(segment.get("origin_start_frame", segment_start)) | |
| if ( | |
| segment_start == 0 | |
| and segment_end == total_length | |
| and resize_method is not None | |
| and tuple(video.get_dimensions()) != (width, height) | |
| ): | |
| source = _video_stream_source(video) | |
| if source is not None: | |
| log_node_info( | |
| "MultiTrack Video Track", | |
| "single-segment override covers the task window; cropping and scaling directly", | |
| ) | |
| rendered_path = render_single_video_segment_with_ffmpeg( | |
| source, | |
| max(0, segment_start - origin_start), | |
| total_length, | |
| frame_rate, | |
| width, | |
| height, | |
| resize_method, | |
| audio_volume_db=base_volume_db + audio_volume_db(content), | |
| audio_muted=audio_muted or audio_is_muted(content), | |
| ) | |
| if rendered_path is not None: | |
| return InputImpl.VideoFromFile(rendered_path) | |
| file_segments: list[dict] = [] | |
| for segment, video in segments: | |
| source = _video_stream_source(video) | |
| if source is None: | |
| break | |
| content = segment.get("content", {}) | |
| if not isinstance(content, dict): | |
| content = {} | |
| file_segment = { | |
| "source": source, | |
| "start_frame": int(segment.get("start_frame", 0)), | |
| "end_frame": int(segment.get("end_frame", 0)), | |
| "audio_volume_db": base_volume_db + audio_volume_db(content), | |
| "audio_muted": audio_muted or audio_is_muted(content), | |
| } | |
| origin_start = int(segment.get("origin_start_frame", file_segment["start_frame"])) | |
| source_start_frame = max(0, file_segment["start_frame"] - origin_start) | |
| if source_start_frame > 0: | |
| file_segment["source_start_frame"] = source_start_frame | |
| file_segments.append(file_segment) | |
| else: | |
| merge_args = (file_segments, total_length, frame_rate, width, height) | |
| merged_path = ( | |
| merge_video_track_with_ffmpeg(*merge_args, resize_method=resize_method) | |
| if resize_method is not None | |
| else merge_video_track_with_ffmpeg(*merge_args) | |
| ) | |
| if merged_path is not None: | |
| return InputImpl.VideoFromFile(merged_path) | |
| if resize_method is not None: | |
| resized_cache: dict[tuple, object] = {} | |
| segments = [ | |
| ( | |
| segment, | |
| _resize_multitrack_video( | |
| video, | |
| width, | |
| height, | |
| resize_method, | |
| resized_cache, | |
| lambda _ratio: None, | |
| ), | |
| ) | |
| for segment, video in segments | |
| ] | |
| return _merge_video_track_tensor( | |
| segments, | |
| total_length, | |
| frame_rate, | |
| width, | |
| height, | |
| base_volume_db, | |
| audio_muted, | |
| ) | |
| def _build_tracks_info_and_media_outputs( | |
| data: dict, | |
| image_input, | |
| audio_input, | |
| video_input, | |
| resolution: str | dict, | |
| format_name: str, | |
| materialize_media: bool | set[str] = True, | |
| ) -> tuple[dict, list, list, list]: | |
| tracks = data.get("tracks", []) | |
| if not isinstance(tracks, list): | |
| raise ValueError("TRACK_DATA.tracks must be a list.") | |
| materialized_types = ( | |
| {"image", "audio", "video"} | |
| if materialize_media is True | |
| else set(materialize_media) | |
| if isinstance(materialize_media, set) | |
| else set() | |
| ) | |
| materialize_image = "image" in materialized_types | |
| materialize_audio = "audio" in materialized_types | |
| materialize_video = "video" in materialized_types | |
| frame_rate = float(data.get("frame_rate", 24.0)) | |
| total_length_is_final = data.get("_total_length_is_final") is True | |
| total_length = int(data.get("total_length", 0)) | |
| segment_timeline_end = max( | |
| ( | |
| max(0, int(segment.get("end_frame", 0))) | |
| for track in tracks | |
| if isinstance(track, dict) | |
| for segment in track.get("segments", []) | |
| if isinstance(segment, dict) | |
| ), | |
| default=0, | |
| ) | |
| task_duration_length = sum( | |
| max( | |
| 0, | |
| int(segment.get("end_frame", 0)) - int(segment.get("start_frame", 0)), | |
| ) | |
| for track in tracks | |
| if isinstance(track, dict) and track.get("type") == "task" | |
| for segment in track.get("segments", []) | |
| if isinstance(segment, dict) | |
| ) | |
| if segment_timeline_end > 0: | |
| timeline_total_length = segment_timeline_end | |
| elif format_name == "MiniMax": | |
| timeline_total_length = ( | |
| max(0, total_length - 1) if total_length_is_final else total_length | |
| ) | |
| else: | |
| timeline_total_length = total_length | |
| effective_total_length = task_duration_length or timeline_total_length | |
| if format_name == "MiniMax": | |
| output_total_length = _video_frame_count_from_duration( | |
| effective_total_length, | |
| frame_rate, | |
| format_name, | |
| ) | |
| elif task_duration_length > 0 or segment_timeline_end > 0: | |
| output_total_length = effective_total_length + 1 | |
| else: | |
| output_total_length = total_length if total_length_is_final else total_length + 1 | |
| global_volume_db = audio_volume_db(data) | |
| global_muted = audio_is_muted(data) | |
| has_solo_track = any( | |
| isinstance(track, dict) and | |
| track.get("type") in ("video", "audio") and | |
| track.get("solo") is True | |
| for track in tracks | |
| ) | |
| images_out: list[torch.Tensor] = [] | |
| audio_out: list[dict] = [] | |
| video_out: list = [] | |
| shared_task_images = multitrack_shared_task_images(tracks) | |
| shared_image_media_indexes: dict[tuple, int] = {} | |
| video_segments: list[tuple[int, int, dict]] = [] | |
| for track_index, track in enumerate(tracks): | |
| if not isinstance(track, dict) or track.get("type") != "video": | |
| continue | |
| for segment_index, segment in enumerate(track.get("segments", [])): | |
| if not isinstance(segment, dict): | |
| continue | |
| content = segment.get("content", {}) | |
| if isinstance(content, dict) and content.get("media_type") == "video": | |
| video_segments.append((track_index, segment_index, content)) | |
| progress = ( | |
| ProgressBar(max(1, len(video_segments) * 3)) | |
| if materialize_video and video_segments | |
| else None | |
| ) | |
| progress_value = 0 | |
| if progress is not None: | |
| progress.update_absolute(0) | |
| resolved_videos: dict[tuple[int, int], object] = {} | |
| if materialize_video: | |
| for track_index, segment_index, content in video_segments: | |
| video = _resolve_multitrack_video(content, video_input) | |
| if video is not None: | |
| resolved_videos[(track_index, segment_index)] = video | |
| progress_value += 1 | |
| if progress is not None: | |
| progress.update_absolute(progress_value) | |
| first_video = next(iter(resolved_videos.values()), None) | |
| if first_video is not None: | |
| source_dimensions = first_video.get_dimensions() | |
| elif _resolution_needs_source_dimensions(resolution): | |
| source_dimensions = _deferred_video_dimensions(video_segments) | |
| else: | |
| source_dimensions = None | |
| width, height = _resolve_configured_dimensions(resolution, format_name, source_dimensions) | |
| resize_method = _configured_resize_method(resolution) | |
| resized_video_cache: dict[tuple, object] = {} | |
| normalized_tracks: list[dict] = [] | |
| for track_index, track in enumerate(tracks): | |
| if not isinstance(track, dict): | |
| continue | |
| track_type = track.get("type") | |
| track_volume_db = global_volume_db + audio_volume_db(track) | |
| track_muted = ( | |
| global_muted or | |
| audio_is_muted(track) or | |
| (has_solo_track and track.get("solo") is not True) | |
| ) | |
| normalized_segments: list[dict] = [] | |
| track_audio_segments: list[tuple[dict, dict]] = [] | |
| track_video_segments: list[tuple[dict, object]] = [] | |
| for segment_index, segment in enumerate(track.get("segments", [])): | |
| if not isinstance(segment, dict): | |
| continue | |
| if track_type == "subtitle" and track.get("visible") is False: | |
| continue | |
| content = segment.get("content", {}) | |
| if not isinstance(content, dict): | |
| content = {} | |
| normalized_content = canonicalize_multitrack_slot_content(content) | |
| normalized_content.pop("volume", None) | |
| if track_type in {"audio", "video"}: | |
| normalized_content["shared_reference"] = multitrack_is_shared_reference(content) | |
| normalized_content.pop("speaker_reference", None) | |
| if track_type == "task": | |
| normalized_images: list[dict] = [] | |
| raw_images = multitrack_task_images_with_shared( | |
| content.get("images", []), | |
| shared_task_images, | |
| ) | |
| if isinstance(raw_images, list): | |
| for image_item in raw_images: | |
| if not isinstance(image_item, dict): | |
| continue | |
| normalized_image = canonicalize_multitrack_slot_content(image_item) | |
| if multitrack_is_muted_image(normalized_image): | |
| normalized_image.pop("media_index", None) | |
| normalized_images.append(normalized_image) | |
| continue | |
| panorama_view = image_item.get("panorama_view") | |
| shared_cache_key = None | |
| if multitrack_is_shared_reference(normalized_image): | |
| shared_cache_key = ( | |
| multitrack_media_identity(normalized_image), | |
| json.dumps(panorama_view, sort_keys=True, default=str), | |
| ) | |
| cached_media_index = ( | |
| shared_image_media_indexes.get(shared_cache_key) | |
| if shared_cache_key is not None | |
| else None | |
| ) | |
| if cached_media_index is not None: | |
| normalized_image["media_index"] = cached_media_index | |
| normalized_images.append(normalized_image) | |
| continue | |
| image = ( | |
| _resolve_timeline_image_item(normalized_image, image_input) | |
| if materialize_image | |
| else None | |
| ) | |
| if image is not None: | |
| if panorama_view is not None: | |
| try: | |
| image = equirectangular_to_perspective( | |
| image, | |
| panorama_view, | |
| width, | |
| height, | |
| ) | |
| except (TypeError, ValueError, RuntimeError) as exc: | |
| image_id = image_item.get("id", "") | |
| raise ValueError( | |
| f"Failed to project panorama image {image_id!r}: {exc}" | |
| ) from exc | |
| media_index = len(images_out) | |
| images_out.append(image) | |
| normalized_image["media_index"] = media_index | |
| if shared_cache_key is not None: | |
| shared_image_media_indexes[shared_cache_key] = media_index | |
| normalized_images.append(normalized_image) | |
| normalized_content["images"] = normalized_images | |
| elif materialize_audio and track_type == "audio" and content.get("media_type") == "audio": | |
| audio = _resolve_multitrack_audio(content, audio_input) | |
| if audio is not None: | |
| track_audio_segments.append((segment, audio)) | |
| elif materialize_video and track_type == "video" and content.get("media_type") == "video": | |
| video = resolved_videos.get((track_index, segment_index)) | |
| if video is not None: | |
| progress_start = progress_value | |
| def update_video_progress(ratio: float) -> None: | |
| if progress is not None: | |
| progress.update_absolute(progress_start + min(1.0, max(0.0, ratio)) * 2) | |
| rebuilt_video = _resize_multitrack_video( | |
| video, | |
| width, | |
| height, | |
| resize_method, | |
| resized_video_cache, | |
| update_video_progress, | |
| ) | |
| progress_value = progress_start + 2 | |
| if progress is not None: | |
| progress.update_absolute(progress_value) | |
| track_video_segments.append((segment, rebuilt_video)) | |
| normalized_segment = dict(segment) | |
| normalized_segment.pop("volume", None) | |
| normalized_segment["content"] = normalized_content | |
| normalized_segments.append(normalized_segment) | |
| normalized_track = dict(track) | |
| normalized_track.pop("volume", None) | |
| normalized_track["segments"] = normalized_segments | |
| track_total_length = timeline_total_length | |
| if format_name == "MiniMax" and track_type in ("audio", "video"): | |
| track_end_frame = _track_media_end_frame(normalized_track) | |
| if track_end_frame is not None: | |
| track_total_length = max(0, track_end_frame) | |
| if materialize_audio and track_type == "audio" and (format_name != "MiniMax" or track_audio_segments): | |
| media_index = len(audio_out) | |
| audio_out.append(_merge_audio_track( | |
| track_audio_segments, | |
| track_total_length, | |
| frame_rate, | |
| track_volume_db, | |
| track_muted, | |
| )) | |
| normalized_track["media_index"] = media_index | |
| for normalized_segment in normalized_segments: | |
| content = normalized_segment.get("content", {}) | |
| if content.get("media_type") == "audio": | |
| content["media_index"] = media_index | |
| shared_segment = _shared_reference_segment(normalized_track) | |
| if shared_segment is not None: | |
| shared_segment_id = shared_segment.get("id") | |
| shared_source = next( | |
| ( | |
| audio | |
| for source_segment, audio in track_audio_segments | |
| if source_segment.get("id") == shared_segment_id | |
| ), | |
| None, | |
| ) | |
| if shared_source is not None: | |
| shared_media_index = len(audio_out) | |
| audio_out.append(_build_shared_reference_audio( | |
| shared_segment, | |
| shared_source, | |
| track_volume_db, | |
| track_muted, | |
| )) | |
| shared_segment["content"]["shared_media_index"] = shared_media_index | |
| elif materialize_video and track_type == "video" and (format_name != "MiniMax" or track_video_segments): | |
| media_index = len(video_out) | |
| video_out.append( | |
| _merge_video_track( | |
| track_video_segments, | |
| track_total_length, | |
| frame_rate, | |
| width, | |
| height, | |
| track_volume_db, | |
| track_muted, | |
| ) | |
| ) | |
| normalized_track["media_index"] = media_index | |
| for normalized_segment in normalized_segments: | |
| content = normalized_segment.get("content", {}) | |
| if content.get("media_type") == "video": | |
| content["media_index"] = media_index | |
| shared_segment = _shared_reference_segment(normalized_track) | |
| if shared_segment is not None: | |
| shared_segment_id = shared_segment.get("id") | |
| shared_source = next( | |
| ( | |
| video | |
| for source_segment, video in track_video_segments | |
| if source_segment.get("id") == shared_segment_id | |
| ), | |
| None, | |
| ) | |
| if shared_source is not None: | |
| shared_video = _build_shared_reference_video(shared_source) | |
| if shared_video is not None: | |
| shared_media_index = len(video_out) | |
| video_out.append(shared_video) | |
| shared_segment["content"]["shared_media_index"] = shared_media_index | |
| normalized_tracks.append(normalized_track) | |
| if progress is not None and progress_value < progress.total: | |
| progress.update_absolute(progress.total) | |
| tracks_info = { | |
| # UI track data stores an exclusive timeline end, while prompt_override | |
| # data has already normalized total_length to the final output value. | |
| "total_length": output_total_length, | |
| "timeline_total_length": timeline_total_length, | |
| "frame_rate": frame_rate, | |
| "target_frame_rate": frame_rate, | |
| "format": format_name, | |
| "muted": global_muted, | |
| "volume_db": global_volume_db, | |
| "width": width, | |
| "height": height, | |
| "resize_method": resize_method, | |
| "media_loading": "eager" if materialized_types else "deferred", | |
| "eager_media_types": sorted(materialized_types), | |
| "task_markers": [ | |
| dict(marker) | |
| for marker in data.get("task_markers", []) | |
| if isinstance(marker, dict) | |
| ] if isinstance(data.get("task_markers", []), list) else [], | |
| "tracks": normalized_tracks, | |
| } | |
| audio_result = (audio_out or [None]) if format_name == "MiniMax" else audio_out | |
| video_result = (video_out or [None]) if format_name == "MiniMax" else video_out | |
| if materialized_types: | |
| # Slot values have no reloadable file path. Carry the resolved runtime | |
| # objects in TRACKS_INFO so task/project nodes only need this one link, | |
| # matching the deferred path used by ordinary file-backed media. | |
| tracks_info["media"] = { | |
| "images": images_out, | |
| "audio": audio_result, | |
| "video": video_result, | |
| } | |
| return ( | |
| tracks_info, | |
| images_out, | |
| audio_result, | |
| video_result, | |
| ) | |
| def _sort_timeline_images(images: list[dict]) -> list[dict]: | |
| return sorted( | |
| images, | |
| key=lambda item: int(item.get("start_frame", 0) or 0), | |
| ) | |
| def _collect_timeline_image_items(maintain_segs: list[dict]) -> list[dict]: | |
| all_image_items: list[dict] = [] | |
| for seg in maintain_segs: | |
| all_image_items.extend(_sort_timeline_images(seg.get("images", []))) | |
| return all_image_items | |
| def _select_dimension_image_item(image_items: list[dict]) -> 'dict | None': | |
| for item in image_items: | |
| if item.get("source_type") != "slot": | |
| return item | |
| return image_items[0] if image_items else None | |
| def _count_images(image_input) -> int: | |
| """Return the number of images in image_input.""" | |
| if image_input is None: | |
| return 0 | |
| if isinstance(image_input, list): | |
| return len(image_input) | |
| if isinstance(image_input, torch.Tensor): | |
| return image_input.shape[0] if image_input.dim() == 4 else (1 if image_input.dim() == 3 else 0) | |
| return 0 | |
| def _index_image(image_input, idx_one_based: int) -> 'torch.Tensor | None': | |
| """Return a [1, H, W, C] tensor for the 1-based image index, or None.""" | |
| i = idx_one_based - 1 | |
| if image_input is None: | |
| return None | |
| if isinstance(image_input, list): | |
| if i < len(image_input): | |
| t = image_input[i] | |
| if isinstance(t, torch.Tensor): | |
| return t if t.dim() == 4 else t.unsqueeze(0) | |
| return None | |
| if isinstance(image_input, torch.Tensor): | |
| if image_input.dim() == 4 and i < image_input.shape[0]: | |
| return image_input[i : i + 1] | |
| if image_input.dim() == 3 and i == 0: | |
| return image_input.unsqueeze(0) | |
| return None | |
| def _index_audio(audio_input, idx_one_based: int) -> 'dict | None': | |
| """Return the audio dict for the 1-based index, or None.""" | |
| i = idx_one_based - 1 | |
| if audio_input is None: | |
| return None | |
| if isinstance(audio_input, list): | |
| if i < len(audio_input): | |
| a = audio_input[i] | |
| return a if isinstance(a, dict) and 'waveform' in a else None | |
| return None | |
| if isinstance(audio_input, dict) and 'waveform' in audio_input: | |
| return audio_input if i == 0 else None | |
| return None | |
| def _merge_audio_batches(audio_input) -> 'dict | None': | |
| """With is_input_list=True, a single audio source is split into N batch items. | |
| Concatenate all items along the time axis to reconstruct the full audio.""" | |
| if audio_input is None: | |
| return None | |
| if isinstance(audio_input, dict) and 'waveform' in audio_input: | |
| return audio_input # already a single audio dict | |
| if not isinstance(audio_input, list) or not audio_input: | |
| return None | |
| valid = [a for a in audio_input if isinstance(a, dict) and 'waveform' in a | |
| and isinstance(a['waveform'], torch.Tensor)] | |
| if not valid: | |
| return None | |
| _raw_sr = valid[0].get('sample_rate', 44100) | |
| sr = int(_raw_sr[0] if isinstance(_raw_sr, (list, tuple)) else _raw_sr) | |
| waveforms = [a['waveform'] for a in valid] # each [1, C, T_i] | |
| # Normalize channel count: up-mix mono to stereo if mixed | |
| max_ch = max(w.shape[1] for w in waveforms) | |
| if max_ch > 1: | |
| waveforms = [w.expand(-1, max_ch, -1) if w.shape[1] < max_ch else w for w in waveforms] | |
| combined = torch.cat(waveforms, dim=-1) # [1, C, sum(T_i)] | |
| return {'waveform': combined, 'sample_rate': sr} | |
| # --------------------------------------------------------------------------- | |
| # Node | |
| # --------------------------------------------------------------------------- | |
| class TimelineEditor(io.ComfyNode): | |
| def define_schema(cls): | |
| return io.Schema( | |
| node_id="easy timelineEditor", | |
| display_name="Timeline Editor", | |
| category=CATEGORY_TIMELINE, | |
| description="Load a timeline of media items (prompt, image, audio tracks) and outputs structured data.", | |
| is_input_list=True, | |
| inputs=[ | |
| io.DynamicCombo.Input( | |
| "resolution", | |
| options=resolution_combo_options, | |
| tooltip="Select a resolution or choose 'Custom' to specify your own width and height.", | |
| ), | |
| io.Combo.Input("format", options=[FORMAT_INTERNAL_VALUE], default=FORMAT_INTERNAL_VALUE, tooltip="Moxie Multimedia Suite only supports the bundled Moxie video model."), | |
| TYPE_TIMELINE.Input( | |
| "timeline_data", | |
| ), | |
| io.AnyType.Input("prompt_override", optional=True, tooltip="If provided, overrides all segment prompts in the timeline.",), | |
| io.Image.Input("image", optional=True, tooltip="List of images to override images in the timeline."), | |
| io.Audio.Input("audio", optional=True, tooltip="List of audio clips to override audio in the timeline."), | |
| ], | |
| outputs=[ | |
| TYPE_TIMELINE_INFO.Output("TIMELINE_INFO"), | |
| io.Image.Output("IMAGES"), | |
| io.Audio.Output("AUDIO"), | |
| ], | |
| ) | |
| def execute( | |
| cls, | |
| resolution: str | dict, | |
| format: str, | |
| timeline_data: str | dict, | |
| **kwargs: object, | |
| ) -> io.NodeOutput: | |
| # is_input_list=True: every param arrives as a list; unwrap scalars here | |
| if isinstance(resolution, list): | |
| resolution = resolution[0] | |
| format = _normalize_format(format) | |
| if isinstance(timeline_data, list): | |
| timeline_data = timeline_data[0] | |
| prompt_override = kwargs.get('prompt_override') | |
| if isinstance(prompt_override, list) and len(prompt_override) == 1: | |
| prompt_override = prompt_override[0] | |
| image_input = kwargs.get('image') # kept as list | |
| audio_input = kwargs.get('audio') # kept as list | |
| # Unwrap double-wrapped list from is_input_list (list of audio lists) | |
| # When audio comes from MakeAudioList (is_output_list=True), it's already a list. | |
| # With is_input_list=True, that list gets wrapped again → [[audio1, audio2, ...]] | |
| # We need to unwrap to get the original list of audio dicts. | |
| if isinstance(audio_input, list) and len(audio_input) == 1: | |
| inner = audio_input[0] | |
| if isinstance(inner, list): | |
| audio_input = inner | |
| # Segment parsing override: only needs non-empty prompt_override | |
| use_prompt_override = prompt_override_has_value(prompt_override) | |
| # Audio override: prompt_override + audio (image is NOT required) | |
| audio_override = ( | |
| use_prompt_override | |
| and audio_input is not None | |
| and (not isinstance(audio_input, list) or len(audio_input) > 0) | |
| ) | |
| # Keep use_override as alias for image-loading context (prompt_override active) | |
| use_override = use_prompt_override | |
| # ---- Parse data source ---- | |
| if use_prompt_override: | |
| # Still read frame_rate from timeline_data metadata if available | |
| if isinstance(timeline_data, str): | |
| try: | |
| _td = json.loads(timeline_data) | |
| except json.JSONDecodeError: | |
| _td = {} | |
| else: | |
| _td = dict(timeline_data) if timeline_data else {} | |
| frame_rate: int = int(_td.get('frame_rate', 24)) | |
| total_length = int(_td.get('total_length', 121)) | |
| override_segs = _parse_override_segments( | |
| prompt_override, | |
| total_length=total_length, | |
| frame_rate=frame_rate, | |
| ) | |
| if prompt_override_has_frame_ranges(prompt_override): | |
| max_override_end = max((s['end_frame'] for s in override_segs), default=120) | |
| total_length = max_override_end + 1 | |
| # Build maintain_segs — images stored as slot refs with _tensor_idx | |
| maintain_segs: list[dict] = [] | |
| for s in override_segs: | |
| n_img = len(s['image_indices']) | |
| seg_start = s['start_frame'] | |
| seg_end = s['end_frame'] | |
| seg_duration = seg_end - seg_start | |
| images: list[dict] = [] | |
| for i, idx_1based in enumerate(s['image_indices']): | |
| img_entry: dict = { | |
| 'source_type': 'slot', | |
| 'file_name': f'image_{idx_1based}', | |
| '_tensor_idx': idx_1based, | |
| } | |
| if n_img > 1: | |
| img_entry['start_frame'] = round(seg_start + i * seg_duration / n_img) | |
| img_entry['end_frame'] = round(seg_start + (i + 1) * seg_duration / n_img) | |
| images.append(img_entry) | |
| maintain_segs.append({ | |
| 'start_frame': seg_start, | |
| 'end_frame': seg_end, | |
| 'text': s['text'], | |
| 'images': images, | |
| 'type': s['type'], | |
| '_audio_indices': s['audio_indices'], | |
| }) | |
| tracks: list = [] # not used in override path; defined for audio else-branch | |
| else: | |
| # ---- Normal path: Parse timeline_data ---- | |
| if isinstance(timeline_data, str): | |
| try: | |
| data = json.loads(timeline_data) | |
| except json.JSONDecodeError: | |
| data = {} | |
| else: | |
| data = dict(timeline_data) if timeline_data else {} | |
| tracks = data.get("tracks", []) | |
| total_length: int = int(data.get("total_length", 121)) | |
| frame_rate: int = int(data.get("frame_rate", 24)) | |
| # ========================================================= | |
| # Collect maintain (main) track segments | |
| # ========================================================= | |
| maintain_segs: list[dict] = [] | |
| for track in tracks: | |
| if track.get("type") != "maintain": | |
| continue | |
| for seg in sorted(track.get("segments", []), key=lambda s: s.get("start_frame", 0)): | |
| content = seg.get("content", {}) | |
| maintain_segs.append({ | |
| "start_frame": int(seg.get("start_frame", 0)), | |
| "end_frame": int(seg.get("end_frame", 0)), | |
| "text": content.get("text", ""), | |
| "images": _sort_timeline_images(content.get("images", [])), # list of ImageItem dicts | |
| "type": content.get("type", "flf"), | |
| }) | |
| if format == "MiniMax": | |
| output_total_length = _video_frame_count_from_duration( | |
| max(0, total_length - 1), | |
| frame_rate, | |
| format, | |
| ) | |
| else: | |
| output_total_length = _align_video_frame_count(total_length, format) | |
| # Flat list of all image items from maintain segments, in order | |
| all_image_items = _collect_timeline_image_items(maintain_segs) | |
| # ========================================================= | |
| # Resolve target dimensions | |
| # ========================================================= | |
| def _unwrap(v, default=None): | |
| """If DynamicCombo sub-value is wrapped as a list (is_input_list side-effect), unwrap it.""" | |
| if isinstance(v, list): | |
| return v[0] if v else default | |
| return v if v is not None else default | |
| _resolution: str = _unwrap(resolution.get("resolution"), "") | |
| resize_method: str = _unwrap(resolution.get("resize_method"), "stretch") | |
| resize_to_pixel: int | None = _unwrap(resolution.get("resize_to_pixel"), None) | |
| width_custom: int | None = _unwrap(resolution.get("width"), None) | |
| height_custom: int | None = _unwrap(resolution.get("height"), None) | |
| aspect_ratio: str = str(_unwrap(resolution.get("aspect_ratio"), AspectRatio.SQUARE.value)) | |
| megapixels: float = float(_unwrap(resolution.get("megapixels"), 1.0)) | |
| # Detect mode from resolution string | |
| if "auto" in _resolution: | |
| mode = "auto" | |
| elif "longest" in _resolution: | |
| mode = "longest" | |
| elif "shortest" in _resolution: | |
| mode = "shortest" | |
| elif "custom" in _resolution: | |
| mode = "custom" | |
| elif "megapixels" in _resolution: | |
| mode = "megapixels" | |
| else: | |
| mode = "preset" | |
| # image_override: True whenever image input is connected, regardless of full override mode | |
| image_override = ( | |
| image_input is not None | |
| and (not isinstance(image_input, list) or len(image_input) > 0) | |
| ) | |
| # Load one image for dimension inference (auto / longest / shortest) | |
| dimension_image_tensor: torch.Tensor | None = None | |
| if mode in ("auto", "longest", "shortest"): | |
| if use_override and image_override: | |
| dimension_image_tensor = _index_image(image_input, 1) | |
| elif all_image_items: | |
| dimension_item = _select_dimension_image_item(all_image_items) | |
| if dimension_item is not None: | |
| dimension_image_tensor = _resolve_timeline_image_item(dimension_item, image_input) | |
| target_w: int | |
| target_h: int | |
| div = _video_format_dimension_multiple(format) | |
| if mode == "megapixels": | |
| target_w, target_h = _resolve_megapixel_dimensions(aspect_ratio, megapixels, div) | |
| elif mode == "preset": | |
| target_w, target_h = 544, 960 | |
| for entry in BASE_RESOLUTIONS: | |
| w, h = entry[0], entry[1] | |
| if isinstance(w, int) and f"{w} x {h}" in _resolution: | |
| target_w, target_h = int(w), int(h) | |
| break | |
| elif mode == "auto": | |
| if dimension_image_tensor is not None: | |
| target_h = dimension_image_tensor.shape[1] | |
| target_w = dimension_image_tensor.shape[2] | |
| else: | |
| target_w, target_h = 544, 960 | |
| elif mode in ("longest", "shortest"): | |
| if dimension_image_tensor is not None: | |
| img_h = dimension_image_tensor.shape[1] | |
| img_w = dimension_image_tensor.shape[2] | |
| pix = int(resize_to_pixel) if resize_to_pixel else 960 | |
| aspect = img_w / img_h # width / height | |
| if mode == "longest": | |
| if img_w >= img_h: | |
| target_w = pix | |
| target_h = round(pix / aspect) | |
| else: | |
| target_h = pix | |
| target_w = round(pix * aspect) | |
| else: # shortest | |
| if img_w <= img_h: | |
| target_w = pix | |
| target_h = round(pix / aspect) | |
| else: | |
| target_h = pix | |
| target_w = round(pix * aspect) | |
| else: | |
| target_w, target_h = 544, 960 | |
| else: # custom | |
| target_w = int(width_custom) if width_custom else 544 | |
| target_h = int(height_custom) if height_custom else 960 | |
| # Apply format divisibility to finalise target dimensions | |
| if div > 1 and mode != "megapixels": | |
| target_w = max(div, ((target_w + div - 1) // div) * div) | |
| target_h = max(div, ((target_h + div - 1) // div) * div) | |
| # ========================================================= | |
| # Load and resize images from maintain segments | |
| # ========================================================= | |
| image_tensors: list[torch.Tensor] = [] | |
| for idx, item in enumerate(all_image_items): | |
| if use_override and image_override: | |
| # Use connected image input (positional for normal path, _tensor_idx for override) | |
| tensor_idx = item.get('_tensor_idx', idx + 1) | |
| if idx == 0 and dimension_image_tensor is not None: | |
| t = dimension_image_tensor | |
| else: | |
| t = _index_image(image_input, tensor_idx) | |
| else: | |
| t = _resolve_timeline_image_item(item, image_input) | |
| if t is None: | |
| continue | |
| t = resize_image(t, target_w, target_h, resize_method) | |
| # Normalize to RGB (3 channels) — drop alpha channel if present | |
| if t.shape[-1] == 1: | |
| t = t.expand(-1, -1, -1, 3) | |
| elif t.shape[-1] == 4: | |
| t = t[..., :3] | |
| elif t.shape[-1] != 3: | |
| continue | |
| image_tensors.append(t) | |
| if image_tensors: | |
| images_out = torch.cat(image_tensors, dim=0) | |
| else: | |
| images_out = torch.zeros(1, target_h, target_w, 3) | |
| # ========================================================= | |
| # Audio track processing | |
| # ========================================================= | |
| default_sr = 44100 | |
| merged_waveform: torch.Tensor | None = None | |
| # ---- Single audio as whole timeline: clip/pad to total duration ---- | |
| single_timeline_audio = _single_valid_audio(audio_input) if prompt_override else None | |
| if prompt_override and prompt_override != '' and "@audio" not in prompt_override and "@音频" not in prompt_override and single_timeline_audio is not None: | |
| a = single_timeline_audio | |
| channels = a['waveform'].shape[1] if 'waveform' in a else 2 | |
| _raw_sr = a.get('sample_rate', default_sr) | |
| sr = int(_raw_sr[0] if isinstance(_raw_sr, (list, tuple)) else _raw_sr) | |
| if sr != default_sr: | |
| default_sr = sr | |
| total_sec = (total_length - 1) / frame_rate | |
| wav = a['waveform'][0] # [C, T] | |
| target_samples = max(1, int(total_sec * sr)) | |
| chunk = wav[:, :target_samples] | |
| if chunk.shape[-1] < target_samples: | |
| chunk = torch.cat([ | |
| chunk, | |
| torch.zeros(channels, target_samples - chunk.shape[-1], | |
| dtype=chunk.dtype, device=chunk.device) | |
| ], dim=-1) | |
| merged_waveform = chunk.unsqueeze(0) | |
| elif audio_override: | |
| # ---- Override audio: build from audio input per segment ---- | |
| # audio_input is a list from MakeAudioList (is_output_list) where | |
| # index N-1 corresponds to @audioN reference in prompt_override. | |
| # Detect channel count from first non-silent real audio clip. | |
| channels = 2 | |
| for _probe_idx in range(1, 11): | |
| _probe = _index_audio(audio_input, _probe_idx) | |
| if _probe is not None and _probe['waveform'].any(): | |
| channels = _probe['waveform'].shape[1] | |
| _raw_sr = _probe.get('sample_rate', default_sr) | |
| default_sr = int(_raw_sr[0] if isinstance(_raw_sr, (list, tuple)) else _raw_sr) | |
| break | |
| def _extract_clip(a: dict, duration_sec: float) -> torch.Tensor: | |
| """Extract from the beginning of audio clip `a`, clip/pad to duration_sec. Returns [C, T].""" | |
| _raw = a.get('sample_rate', default_sr) | |
| sr = int(_raw[0] if isinstance(_raw, (list, tuple)) else _raw) | |
| wav = a['waveform'][0] # [C, T] | |
| # Up-mix mono to stereo if needed | |
| if wav.shape[0] < channels: | |
| wav = wav.expand(channels, -1) | |
| n_ch = wav.shape[0] | |
| target_samples = max(1, int(duration_sec * sr)) | |
| chunk = wav[:, :target_samples] | |
| if chunk.shape[-1] < target_samples: | |
| chunk = torch.cat( | |
| [chunk, torch.zeros(n_ch, target_samples - chunk.shape[-1], | |
| dtype=chunk.dtype, device=chunk.device)], | |
| dim=-1, | |
| ) | |
| return chunk[:, :target_samples] | |
| audio_parts: list[torch.Tensor] = [] | |
| prev_end_sec = 0.0 | |
| for seg in maintain_segs: | |
| start_sec = seg['start_frame'] / frame_rate | |
| end_sec = seg['end_frame'] / frame_rate | |
| duration_sec = max(0.0, end_sec - start_sec) | |
| # Gap silence before this segment | |
| if start_sec > prev_end_sec + 1e-6: | |
| audio_parts.append(silence(default_sr, start_sec - prev_end_sec, channels)) | |
| audio_indices = seg.get('_audio_indices', []) | |
| if audio_indices: | |
| # @audioN present → try list indexing first, fall back to single audio | |
| a = _index_audio(audio_input, audio_indices[0]) | |
| if a is None and not isinstance(audio_input, list): | |
| # Single audio input (not a list) — use it directly | |
| a = audio_input | |
| if a is not None: | |
| chunk = _extract_clip(a, duration_sec) | |
| audio_parts.append(chunk.unsqueeze(0)) | |
| else: | |
| audio_parts.append(silence(default_sr, duration_sec, channels)) | |
| else: | |
| # No @audio reference → mute this segment | |
| audio_parts.append(silence(default_sr, duration_sec, channels)) | |
| prev_end_sec = end_sec | |
| if audio_parts: | |
| merged_waveform = torch.cat(audio_parts, dim=-1) | |
| else: | |
| # ---- Normal path: audio from timeline tracks ---- | |
| for track in tracks: | |
| if track.get("type") != "audio": | |
| continue | |
| track_parts: list[torch.Tensor] = [] | |
| prev_end_sec = 0.0 | |
| channels = 2 | |
| for seg in sorted(track.get("segments", []), key=lambda s: s.get("start_frame", 0)): | |
| start = int(seg.get("start_frame", 0)) | |
| end = min(int(seg.get("end_frame", 0)), total_length - 1) | |
| start_sec = max(0.0, frames_to_seconds(start, frame_rate)) | |
| end_sec = frames_to_seconds(end, frame_rate) | |
| duration_sec = max(0.0, end_sec - start_sec) | |
| # Trim offset: how far into the source audio this segment starts | |
| origin_start = int(seg.get("origin_start_frame", start)) | |
| # Use plain frame-count division (not frames_to_seconds which applies a -1 offset for indices) | |
| trim_offset_sec = max(0.0, (start - origin_start) / frame_rate) if start > origin_start else 0.0 | |
| content = seg.get("content", {}) | |
| slot_audio = None | |
| if content.get("source_type") == "slot": | |
| slot_audio = _index_slot_audio(audio_input, content.get("slot_name") or content.get("file_name")) | |
| waveform = ( | |
| slot_audio.get("waveform") | |
| if slot_audio is not None | |
| else load_audio_waveform( | |
| content.get("source_type", "input"), | |
| content.get("file_path"), | |
| content.get("local_path"), | |
| content.get("url"), | |
| default_sr, | |
| ) | |
| ) | |
| if slot_audio is not None: | |
| _raw_sr = slot_audio.get('sample_rate', default_sr) | |
| default_sr = int(_raw_sr[0] if isinstance(_raw_sr, (list, tuple)) else _raw_sr) | |
| # Determine channel count from loaded audio before adding gap silence, | |
| # so the silence tensor has matching channels and torch.cat won't fail. | |
| if waveform is not None: | |
| channels = waveform.shape[1] | |
| # Silence gap before this segment (inserted after channel count is known) | |
| if start_sec > prev_end_sec + 1e-6: | |
| track_parts.append(silence(default_sr, start_sec - prev_end_sec, channels)) | |
| if waveform is not None: | |
| wav = waveform[0] # [C,T] | |
| # Apply trim offset — skip samples from the start of the source | |
| if trim_offset_sec > 0.0: | |
| offset_samples = int(default_sr * trim_offset_sec) | |
| wav = wav[:, offset_samples:] | |
| target_samples = max(1, int(default_sr * duration_sec)) | |
| if wav.shape[-1] > target_samples: | |
| wav = wav[:, :target_samples] | |
| elif wav.shape[-1] < target_samples: | |
| wav = torch.cat([wav, torch.zeros(channels, target_samples - wav.shape[-1])], dim=-1) | |
| track_parts.append(wav.unsqueeze(0)) | |
| else: | |
| track_parts.append(silence(default_sr, duration_sec, channels)) | |
| prev_end_sec = end_sec | |
| if track_parts: | |
| merged_waveform = torch.cat(track_parts, dim=-1) | |
| total_sec = (total_length - 1) / frame_rate | |
| channels = 2 | |
| if merged_waveform is not None: | |
| channels = merged_waveform.shape[1] | |
| total_samples = max(1, int(default_sr * total_sec)) | |
| wav = merged_waveform[0] | |
| if wav.shape[-1] > total_samples: | |
| wav = wav[:, :total_samples] | |
| elif wav.shape[-1] < total_samples: | |
| wav = torch.cat([wav, torch.zeros(channels, total_samples - wav.shape[-1])], dim=-1) | |
| merged_waveform = wav.unsqueeze(0) | |
| else: | |
| merged_waveform = silence(default_sr, total_sec, channels) | |
| audio_out = {"waveform": merged_waveform, "sample_rate": default_sr} | |
| # ========================================================= | |
| # Build audio segment info from maintain segment boundaries | |
| # Collect audio sources from tracks for output in timeline_info | |
| # ========================================================= | |
| audio_seg_info: list[dict] = [] | |
| audio_sources: list[dict] = [] # Track audio sources with their frame ranges | |
| for track in tracks: | |
| if track.get("type") != "audio": | |
| continue | |
| for seg in sorted(track.get("segments", []), key=lambda s: s.get("start_frame", 0)): | |
| content = seg.get("content", {}) | |
| audio_sources.append({ | |
| "start_frame": int(seg.get("start_frame", 0)), | |
| "end_frame": int(seg.get("end_frame", 0)), | |
| "source_type": content.get("source_type", "input"), | |
| "file_path": content.get("file_path", ""), | |
| "local_path": content.get("local_path", ""), | |
| "url": content.get("url", ""), | |
| "file_name": content.get("file_name", ""), | |
| }) | |
| for i, seg in enumerate(maintain_segs): | |
| start_sec = seg["start_frame"] / frame_rate | |
| if i < len(maintain_segs) - 1: | |
| end_sec = maintain_segs[i + 1]["start_frame"] / frame_rate | |
| else: | |
| end_sec = min(seg["end_frame"], total_length - 1) / frame_rate | |
| audio_entry: dict = { | |
| "start_sec": round(start_sec, 4), | |
| "end_sec": round(end_sec, 4), | |
| "duration": round(end_sec - start_sec, 4), | |
| } | |
| # Find audio source that overlaps with this maintain segment | |
| for src in audio_sources: | |
| if (src["start_frame"] >= seg["start_frame"] and src["start_frame"] <= seg["end_frame"]) or \ | |
| (src["end_frame"] >= seg["start_frame"] and src["end_frame"] <= seg["end_frame"]): | |
| if src.get("file_path"): | |
| audio_entry["file_path"] = src["file_path"] | |
| if src.get("source_type"): | |
| audio_entry["source_type"] = src["source_type"] | |
| break | |
| audio_seg_info.append(audio_entry) | |
| # ========================================================= | |
| # Build per-segment info for timeline_info | |
| # ========================================================= | |
| seg_infos: list[dict] = [] | |
| for seg in maintain_segs: | |
| images_info: list[dict] = [] | |
| for img in seg["images"]: | |
| entry: dict = { | |
| "source_type": img.get("source_type", "input"), | |
| "file_name": img.get("file_name", ""), | |
| } | |
| if img.get("file_path"): | |
| entry["file_path"] = img["file_path"] | |
| if img.get("start_frame") is not None: | |
| entry["start_frame"] = img["start_frame"] | |
| if img.get("end_frame") is not None: | |
| entry["end_frame"] = img["end_frame"] | |
| images_info.append(entry) | |
| seg_info: dict = { | |
| "start_frame": seg["start_frame"], | |
| "end_frame": seg["end_frame"], | |
| "prompt": seg["text"], | |
| "images": images_info, | |
| } | |
| if images_info: | |
| seg_info["type"] = seg["type"] | |
| seg_infos.append(seg_info) | |
| # ========================================================= | |
| # timeline_info output | |
| # ========================================================= | |
| timeline_info = { | |
| "total_length": output_total_length, | |
| "timeline_total_length": total_length, | |
| "frame_rate": frame_rate, | |
| "target_frame_rate": frame_rate, | |
| "format": format, | |
| "width": target_w, | |
| "height": target_h, | |
| "segments": seg_infos, | |
| "audio": { | |
| "segments": audio_seg_info, | |
| }, | |
| } | |
| return io.NodeOutput(timeline_info, images_out, audio_out) | |
| class MultiImagesLoader(io.ComfyNode): | |
| def define_schema(cls) -> io.Schema: | |
| return io.Schema( | |
| node_id="easy multiImagesLoader", | |
| display_name="Multi Images Loader", | |
| category=CATEGORY_MEDIA, | |
| description="Load up to 25 images and resize each using the selected resolution.", | |
| inputs=[ | |
| io.DynamicCombo.Input("resolution", options=resolution_combo_options), | |
| io.Int.Input( | |
| "max_limit", | |
| default=-1, | |
| max=25, | |
| min=-1, | |
| tooltip="Maximum number of images to output. -1 means no limit (output all loaded images).", | |
| ), | |
| TYPE_IMAGE_DATA.Input("image_data"), | |
| ], | |
| outputs=[io.Image.Output("IMAGES", is_output_list=True)], | |
| ) | |
| def execute( | |
| cls, | |
| resolution: str | dict, | |
| max_limit: int, | |
| image_data: str | dict, | |
| ) -> io.NodeOutput: | |
| if isinstance(image_data, str): | |
| try: | |
| image_data = json.loads(image_data) | |
| except json.JSONDecodeError as exc: | |
| raise ValueError("IMAGE_DATA must be valid JSON.") from exc | |
| if not isinstance(image_data, dict) or not isinstance(image_data.get("images"), list): | |
| raise ValueError("IMAGE_DATA.images must be a list.") | |
| images = image_data["images"] | |
| if len(images) > 25: | |
| raise ValueError("IMAGE_DATA supports at most 25 images.") | |
| if max_limit >= 0 and len(images) > max_limit: | |
| images = images[:max_limit] | |
| resize_method = _configured_resize_method(resolution) | |
| output: list[torch.Tensor] = [] | |
| for index, item in enumerate(images): | |
| if not isinstance(item, dict): | |
| raise ValueError(f"IMAGE_DATA image {index + 1} must be an object.") | |
| if item.get("source_type") not in {"input", "output", "local", "url"}: | |
| raise ValueError(f"IMAGE_DATA image {index + 1} has an unsupported source type.") | |
| image = _resolve_timeline_image_item(item, None) | |
| if image is None: | |
| raise ValueError(f"Unable to load IMAGE_DATA image {index + 1}.") | |
| source_dimensions = (int(image.shape[2]), int(image.shape[1])) | |
| width, height = _resolve_configured_dimensions(resolution, "None", source_dimensions) | |
| output.append(resize_image(image, width, height, resize_method)) | |
| return io.NodeOutput(output) | |
| class MultiTrackEditor(io.ComfyNode): | |
| def define_schema(cls): | |
| return io.Schema( | |
| node_id="easy multiTrackEditor", | |
| display_name="MultiTrack Editor", | |
| category=CATEGORY_MULTITRACK, | |
| description=( | |
| "Edit multitrack data. Slot-backed timelines materialize media " | |
| "immediately; timelines without slots defer media loading to " | |
| "downstream task and audio output nodes." | |
| ), | |
| is_input_list=True, | |
| inputs=[ | |
| io.DynamicCombo.Input( | |
| "resolution", | |
| options=resolution_combo_options, | |
| tooltip="Select a resolution or choose 'Custom'. Width and height of 32 enable audio-only output in MultiTrack Project.", | |
| ), | |
| io.Combo.Input("format", options=[FORMAT_INTERNAL_VALUE], default=FORMAT_INTERNAL_VALUE, tooltip="Moxie Multimedia Suite only supports the bundled Moxie video model."), | |
| TYPE_TRACK_DATA.Input("track_data"), | |
| io.AnyType.Input("prompt_override", optional=True, tooltip="If provided, overrides all segment prompts in the timeline.",), | |
| io.Image.Input("image", optional=True, lazy=True, tooltip="Optional image media list for slot-based multitrack segments."), | |
| io.Audio.Input("audio", optional=True, lazy=True, tooltip="Optional audio media list for slot-based multitrack segments."), | |
| io.Video.Input("video", optional=True, lazy=True, tooltip="Optional video media list for slot-based multitrack segments."), | |
| ], | |
| outputs=[ | |
| TYPE_TRACKS_INFO.Output("TRACKS_INFO"), | |
| io.Image.Output("IMAGES", is_output_list=True), | |
| io.Audio.Output("AUDIO", is_output_list=True), | |
| io.Video.Output("VIDEO", is_output_list=True), | |
| ], | |
| ) | |
| def check_lazy_status( | |
| cls, | |
| resolution: str | dict, | |
| format: str, | |
| track_data: str | dict, | |
| prompt_override: object = None, | |
| image: object = None, | |
| audio: object = None, | |
| video: object = None, | |
| ) -> list[str]: | |
| del resolution | |
| raw_format = _normalize_format(format) | |
| raw_track_data = track_data[0] if isinstance(track_data, list) and track_data else track_data | |
| raw_override = ( | |
| prompt_override[0] | |
| if isinstance(prompt_override, list) and len(prompt_override) == 1 | |
| else prompt_override | |
| ) | |
| if raw_format == "MiniMax" and is_minimax_prompt_override(raw_override): | |
| slot_types = minimax_prompt_override_media_types(raw_override) | |
| else: | |
| data = _parse_track_data(raw_track_data) | |
| if prompt_override_has_value(raw_override): | |
| data = build_multitrack_data_from_prompt_override(data, raw_override) | |
| slot_types = multitrack_slot_media_types(data) | |
| values = {"image": image, "audio": audio, "video": video} | |
| def _missing_lazy_input(v): | |
| # An unevaluated lazy input under is_input_list arrives as (None,) rather | |
| # than None; treat None / empty / all-None sequences as missing so the | |
| # engine is asked to evaluate it. | |
| if v is None: | |
| return True | |
| if isinstance(v, (list, tuple)): | |
| return len(v) == 0 or all(x is None for x in v) | |
| return False | |
| return [media_type for media_type in sorted(slot_types) if _missing_lazy_input(values[media_type])] | |
| def execute( | |
| cls, | |
| resolution: str | dict, | |
| format: str, | |
| track_data: str | dict, | |
| **kwargs: object, | |
| ) -> io.NodeOutput: | |
| if isinstance(resolution, list): | |
| resolution = resolution[0] | |
| format = _normalize_format(format) | |
| if isinstance(track_data, list): | |
| track_data = track_data[0] | |
| prompt_override = kwargs.get('prompt_override') | |
| if isinstance(prompt_override, list) and len(prompt_override) == 1: | |
| prompt_override = prompt_override[0] | |
| data = _parse_track_data(track_data) | |
| if format == "MiniMax" and is_minimax_prompt_override(prompt_override): | |
| data = build_minimax_multitrack_data_from_prompt_override(data, prompt_override) | |
| elif prompt_override_has_value(prompt_override): | |
| data = build_multitrack_data_from_prompt_override(data, prompt_override) | |
| materialize_media = multitrack_slot_media_types(data) | |
| tracks_info, images_out, audio_out, video_out = _build_tracks_info_and_media_outputs( | |
| data, | |
| kwargs.get("image"), | |
| kwargs.get("audio"), | |
| kwargs.get("video"), | |
| resolution, | |
| format, | |
| materialize_media=materialize_media, | |
| ) | |
| return io.NodeOutput(tracks_info, images_out, audio_out, video_out) | |
| class TimelineInfoOutput(io.ComfyNode): | |
| def define_schema(cls): | |
| return io.Schema( | |
| node_id="easy timelineInfoOutput", | |
| display_name="Timeline Info Output", | |
| category=CATEGORY_TIMELINE, | |
| description="Output timeline info including formatted prompt, dimensions, and image indexes.", | |
| inputs=[ | |
| TYPE_TIMELINE_INFO.Input("timeline_info"), | |
| io.Combo.Input( | |
| "prompt_format", | |
| options=PROMPT_FORMAT_OPTIONS, | |
| default="default", | |
| tooltip="Choose prompt format. promptRelay formats prompts with frame ranges.", | |
| ), | |
| ], | |
| outputs=[ | |
| io.String.Output("PROMPT"), | |
| io.Int.Output("WIDTH"), | |
| io.Int.Output("HEIGHT"), | |
| io.Int.Output("TOTAL_FRAMES"), | |
| io.Float.Output("FPS"), | |
| io.String.Output("IMAGE_INDEXES"), | |
| ], | |
| ) | |
| def execute( | |
| cls, | |
| timeline_info: str | dict, | |
| prompt_format: str, | |
| **kwargs: object, | |
| ) -> io.NodeOutput: | |
| if isinstance(timeline_info, str): | |
| try: | |
| info = json.loads(timeline_info) | |
| except json.JSONDecodeError: | |
| info = {} | |
| else: | |
| info = dict(timeline_info) if timeline_info else {} | |
| total_length: int = info.get("total_length", 121) | |
| frame_rate: int = info.get("target_frame_rate", info.get("frame_rate", 24)) | |
| width: int = info.get("width", 544) | |
| height: int = info.get("height", 960) | |
| segments: list[dict] = info.get("segments", []) | |
| # Build image_indexes: comma-separated string of starting frames | |
| image_indexes: str = ",".join(str(int(seg.get("start_frame", 0))) for seg in segments if seg.get("images", [])) | |
| def normalize_prompt(value: str | list | None) -> str: | |
| if value is None: | |
| return "" | |
| if isinstance(value, list): | |
| return "\n".join(v for v in value if isinstance(v, str)) | |
| return str(value).strip() | |
| # Build prompt string | |
| if prompt_format == "promptRelay": | |
| prompt_parts: list[str] = [] | |
| for seg in segments: | |
| seg_text = normalize_prompt(seg.get("prompt")) | |
| if seg_text: | |
| start = int(seg.get("start_frame", 0)) | |
| end = int(seg.get("end_frame", 0)) | |
| prompt_parts.append(f"{seg_text} [{start}-{end}]") | |
| prompt_str = " | ".join(prompt_parts) | |
| else: | |
| prompt_str = [seg.get("prompt").strip() for seg in segments] | |
| return io.NodeOutput( | |
| prompt_str, | |
| width, | |
| height, | |
| total_length, | |
| float(frame_rate), | |
| image_indexes, | |
| ) | |
| class MultiTrackInfoOutput(io.ComfyNode): | |
| def define_schema(cls): | |
| return io.Schema( | |
| node_id="easy multiTrackInfoOutput", | |
| display_name="MultiTrack Info Output", | |
| category=CATEGORY_MULTITRACK, | |
| description="Output multitrack dimensions, duration, frame rate, and task count.", | |
| inputs=[ | |
| TYPE_TRACKS_INFO.Input("tracks_info"), | |
| ], | |
| outputs=[ | |
| io.Int.Output("WIDTH"), | |
| io.Int.Output("HEIGHT"), | |
| io.Int.Output("TOTAL_FRAMES"), | |
| io.Float.Output("FPS"), | |
| io.Int.Output("TASK_COUNT"), | |
| ], | |
| ) | |
| def execute(cls, tracks_info: str | dict) -> io.NodeOutput: | |
| if isinstance(tracks_info, str): | |
| try: | |
| info = json.loads(tracks_info) | |
| except json.JSONDecodeError: | |
| info = {} | |
| else: | |
| info = dict(tracks_info) if tracks_info else {} | |
| task_count = len(_multitrack_task_entries(info)) | |
| return io.NodeOutput( | |
| int(info.get("width", 544)), | |
| int(info.get("height", 960)), | |
| int(info.get("total_length", 121)), | |
| float(info.get("target_frame_rate", info.get("frame_rate", 24))), | |
| task_count, | |
| ) | |
| def _multitrack_task_segments(info: dict) -> list[dict]: | |
| tracks = info.get("tracks", []) | |
| if not isinstance(tracks, list): | |
| return [] | |
| return sorted( | |
| [ | |
| segment | |
| for track in tracks | |
| if isinstance(track, dict) and track.get("type") == "task" | |
| for segment in track.get("segments", []) | |
| if isinstance(segment, dict) | |
| ], | |
| key=lambda segment: _multitrack_frame_value(segment.get("start_frame")), | |
| ) | |
| def _task_for_marker_range(tasks: list[dict], start_frame: int, end_frame: int) -> dict: | |
| if not tasks: | |
| return {} | |
| return max( | |
| tasks, | |
| key=lambda task: max( | |
| 0, | |
| min(end_frame, _multitrack_frame_value(task.get("end_frame"))) - | |
| max(start_frame, _multitrack_frame_value(task.get("start_frame"))), | |
| ), | |
| ) | |
| def _multitrack_task_entries(info: dict) -> list[dict]: | |
| tasks = _multitrack_task_segments(info) | |
| markers = info.get("task_markers", []) | |
| if not isinstance(markers, list) or not markers: | |
| return [ | |
| { | |
| "task": task, | |
| "start_frame": max(0, _multitrack_frame_value(task.get("start_frame"))), | |
| "end_frame": max( | |
| max(0, _multitrack_frame_value(task.get("start_frame"))), | |
| _multitrack_frame_value(task.get("end_frame")), | |
| ), | |
| } | |
| for task in tasks | |
| ] | |
| range_start = 0 | |
| has_timeline_end = info.get("timeline_total_length") is not None | |
| total_length = max(0, _multitrack_frame_value( | |
| info.get("timeline_total_length", info.get("total_length")), | |
| )) | |
| marker_end = max( | |
| ( | |
| _multitrack_frame_value(marker.get("frame")) | |
| for marker in markers | |
| if isinstance(marker, dict) | |
| and 0 < _multitrack_frame_value(marker.get("frame")) <= total_length | |
| ), | |
| default=0, | |
| ) | |
| range_end = max( | |
| 0, | |
| total_length if has_timeline_end else total_length - 1, | |
| marker_end, | |
| ) | |
| if range_end <= range_start and tasks: | |
| range_end = max(_multitrack_frame_value(task.get("end_frame"), range_start) for task in tasks) | |
| if range_end <= range_start: | |
| return [] | |
| marker_frames: set[int] = set() | |
| for marker in markers: | |
| if not isinstance(marker, dict): | |
| continue | |
| try: | |
| frame = int(marker.get("frame")) | |
| except (TypeError, ValueError, OverflowError): | |
| continue | |
| if range_start < frame <= range_end: | |
| marker_frames.add(frame) | |
| if not marker_frames: | |
| return [ | |
| { | |
| "task": task, | |
| "start_frame": max(0, _multitrack_frame_value(task.get("start_frame"))), | |
| "end_frame": max(0, _multitrack_frame_value(task.get("end_frame"))), | |
| } | |
| for task in tasks | |
| ] | |
| boundaries = [range_start, *sorted(marker_frames)] | |
| if boundaries[-1] < range_end: | |
| boundaries.append(range_end) | |
| return [ | |
| { | |
| "task": _task_for_marker_range(tasks, start_frame, end_frame), | |
| "start_frame": start_frame, | |
| "end_frame": end_frame, | |
| "marker_mode": True, | |
| } | |
| for start_frame, end_frame in zip(boundaries, boundaries[1:]) | |
| if end_frame > start_frame | |
| ] | |
| def _audio_track_frame_range(track: object, frame_rate: float) -> tuple[int, int] | None: | |
| if not isinstance(track, dict): | |
| return None | |
| segments = track.get("segments") | |
| if not isinstance(segments, list): | |
| return None | |
| starts: list[int] = [] | |
| ends: list[int] = [] | |
| for segment in segments: | |
| if not isinstance(segment, dict): | |
| continue | |
| try: | |
| if segment.get("start_frame") is not None: | |
| starts.append(int(segment["start_frame"])) | |
| elif segment.get("start_time") is not None: | |
| starts.append(round(float(segment["start_time"]) * frame_rate)) | |
| if segment.get("end_frame") is not None: | |
| ends.append(int(segment["end_frame"])) | |
| elif segment.get("end_time") is not None: | |
| ends.append(round(float(segment["end_time"]) * frame_rate)) | |
| except (TypeError, ValueError, OverflowError): | |
| continue | |
| if not starts or not ends: | |
| return None | |
| start_frame = min(starts) | |
| end_frame = max(ends) | |
| return (start_frame, end_frame) if end_frame > start_frame else None | |
| def _trim_audio_to_track(audio: dict | None, frame_range: tuple[int, int] | None, frame_rate: float) -> dict | None: | |
| if audio is None or frame_range is None: | |
| return None | |
| start_frame, end_frame = frame_range | |
| try: | |
| return trim_audio(audio, start_frame / frame_rate, (end_frame - start_frame) / frame_rate) | |
| except (KeyError, TypeError, ValueError, ZeroDivisionError): | |
| return None | |
| def _audio_track_range_within_task( | |
| track: object, | |
| task_range: tuple[int, int] | None, | |
| frame_rate: float, | |
| ) -> tuple[int, int] | None: | |
| if not isinstance(track, dict) or task_range is None: | |
| return None | |
| segments = track.get("segments") | |
| if not isinstance(segments, list): | |
| return None | |
| task_start, task_end = task_range | |
| intersections: list[tuple[int, int]] = [] | |
| for segment in segments: | |
| segment_range = _audio_track_frame_range({"segments": [segment]}, frame_rate) | |
| if segment_range is None: | |
| continue | |
| start_frame = max(task_start, segment_range[0]) | |
| end_frame = min(task_end, segment_range[1]) | |
| if end_frame > start_frame: | |
| intersections.append((start_frame, end_frame)) | |
| if not intersections: | |
| return None | |
| return min(start for start, _end in intersections), max(end for _start, end in intersections) | |
| def _silent_audio_for_range( | |
| audio: dict | None, | |
| frame_range: tuple[int, int] | None, | |
| frame_rate: float, | |
| ) -> dict | None: | |
| if frame_range is None: | |
| return None | |
| sample_rate = 44100 | |
| channels = 2 | |
| if isinstance(audio, dict): | |
| try: | |
| sample_rate = int(audio.get("sample_rate", sample_rate)) | |
| waveform = audio.get("waveform") | |
| if isinstance(waveform, torch.Tensor) and waveform.ndim >= 2: | |
| channels = int(waveform.shape[-2]) | |
| except (TypeError, ValueError, OverflowError): | |
| sample_rate = 44100 | |
| channels = 2 | |
| start_frame, end_frame = frame_range | |
| duration = (end_frame - start_frame) / frame_rate | |
| return {"waveform": silence(sample_rate, duration, channels), "sample_rate": sample_rate} | |
| def _materialize_deferred_audio_tracks( | |
| info: dict, | |
| tracks: list[dict], | |
| start_frame: int, | |
| end_frame: int, | |
| *, | |
| omit_empty: bool = False, | |
| ) -> list[dict]: | |
| frame_rate = max(0.001, float(info.get("frame_rate", 24))) | |
| duration_frames = max(0, end_frame - start_frame) | |
| if duration_frames <= 0: | |
| return [] | |
| global_volume_db = audio_volume_db(info) | |
| global_muted = audio_is_muted(info) | |
| has_solo_track = any( | |
| track.get("type") in {"audio", "video"} and track.get("solo") is True | |
| for track in tracks | |
| if isinstance(track, dict) | |
| ) | |
| outputs: list[dict] = [] | |
| for track in tracks: | |
| if not isinstance(track, dict) or track.get("type") != "audio": | |
| continue | |
| local_segments = multitrack_segments_in_window(track, start_frame, end_frame) | |
| resolved_segments: list[tuple[dict, dict]] = [] | |
| for local_segment in local_segments: | |
| content = local_segment.get("content", {}) | |
| resolved_audio = _resolve_multitrack_audio(content, None) | |
| if resolved_audio is not None: | |
| resolved_segments.append((local_segment, resolved_audio)) | |
| if omit_empty and not resolved_segments: | |
| continue | |
| outputs.append(_merge_audio_track( | |
| resolved_segments, | |
| duration_frames, | |
| frame_rate, | |
| global_volume_db + audio_volume_db(track), | |
| global_muted | |
| or audio_is_muted(track) | |
| or (has_solo_track and track.get("solo") is not True), | |
| )) | |
| return outputs | |
| class MultiTrackAudioOutput(io.ComfyNode): | |
| def define_schema(cls) -> io.Schema: | |
| return io.Schema( | |
| node_id="easy multiTrackAudioOutput", | |
| display_name="MultiTrack Audio Output", | |
| category=CATEGORY_MULTITRACK, | |
| description="Merge all audio tracks and output the first two tracks in full or cropped for S2V.", | |
| is_input_list=True, | |
| inputs=[ | |
| TYPE_TRACKS_INFO.Input("tracks_info"), | |
| io.Audio.Input("audio", optional=True), | |
| io.Combo.Input( | |
| "mode", | |
| options=["default", "crop"], | |
| default="default", | |
| socketless=True, | |
| ), | |
| io.Int.Input( | |
| "task_index", | |
| default=0, | |
| min=0, | |
| step=1, | |
| tooltip="Select a zero-based task segment range.", | |
| ), | |
| ], | |
| outputs=[ | |
| io.Audio.Output("combine_audio"), | |
| io.Audio.Output("audio_0"), | |
| io.Int.Output("audio_0_start"), | |
| io.Audio.Output("audio_1"), | |
| io.Int.Output("audio_1_start"), | |
| ], | |
| ) | |
| def execute( | |
| cls, | |
| tracks_info: list | dict | str, | |
| audio: list | dict | None = None, | |
| mode: str | list[str] = "default", | |
| task_index: int | list[int] = 0, | |
| ) -> io.NodeOutput: | |
| raw_info = _unwrap_list_scalar(tracks_info, {}) | |
| info = _parse_track_data(raw_info) | |
| frame_rate = max(0.001, float(info.get("frame_rate", 24))) | |
| tracks = info.get("tracks", []) | |
| audio_tracks = [ | |
| track for track in tracks | |
| if isinstance(track, dict) and track.get("type") == "audio" | |
| ] if isinstance(tracks, list) else [] | |
| audios = iter_valid_audio_inputs(_as_list_input(audio)) | |
| if not audios: | |
| audios = iter_valid_audio_inputs(_embedded_multitrack_media(info, "audio")) | |
| if _multitrack_media_is_deferred(info, "audio"): | |
| timeline_end = max( | |
| 0, | |
| int(info.get("timeline_total_length", info.get("total_length", 0))), | |
| ) | |
| audios = _materialize_deferred_audio_tracks( | |
| info, | |
| audio_tracks, | |
| 0, | |
| timeline_end, | |
| omit_empty=info.get("format") == "MiniMax", | |
| ) | |
| combined_audio = merge_audio_inputs(audios, "add") | |
| selected_mode = str(_unwrap_list_scalar(mode, "default")) | |
| if selected_mode == "default": | |
| return io.NodeOutput( | |
| combined_audio, | |
| audios[0] if audios else None, | |
| 0, | |
| audios[1] if len(audios) > 1 else None, | |
| 0, | |
| ) | |
| selected_task_index = int(_unwrap_list_scalar(task_index, 0)) | |
| if selected_task_index >= 0: | |
| task_entries = _multitrack_task_entries(info) | |
| task_range = ( | |
| ( | |
| int(task_entries[selected_task_index]["start_frame"]), | |
| int(task_entries[selected_task_index]["end_frame"]), | |
| ) | |
| if selected_task_index < len(task_entries) | |
| else None | |
| ) | |
| first_range = _audio_track_range_within_task( | |
| audio_tracks[0] if audio_tracks else None, | |
| task_range, | |
| frame_rate, | |
| ) | |
| second_range = _audio_track_range_within_task( | |
| audio_tracks[1] if len(audio_tracks) > 1 else None, | |
| task_range, | |
| frame_rate, | |
| ) | |
| task_start = task_range[0] if task_range is not None else -1 | |
| first_input = audios[0] if audios else None | |
| second_input = audios[1] if len(audios) > 1 else None | |
| return io.NodeOutput( | |
| combined_audio, | |
| _trim_audio_to_track(first_input, first_range, frame_rate) | |
| or _silent_audio_for_range(first_input, task_range, frame_rate), | |
| first_range[0] - task_start if first_range is not None else -1, | |
| _trim_audio_to_track(second_input, second_range, frame_rate) | |
| or _silent_audio_for_range(second_input, task_range, frame_rate), | |
| second_range[0] - task_start if second_range is not None else -1, | |
| ) | |
| first_range = _audio_track_frame_range(audio_tracks[0], frame_rate) if audio_tracks else None | |
| second_range = _audio_track_frame_range(audio_tracks[1], frame_rate) if len(audio_tracks) > 1 else None | |
| first_audio = _trim_audio_to_track(audios[0] if audios else None, first_range, frame_rate) | |
| second_audio = _trim_audio_to_track(audios[1] if len(audios) > 1 else None, second_range, frame_rate) | |
| first_start = first_range[0] if first_range is not None else -1 | |
| second_start = second_range[0] if second_range is not None else -1 | |
| return io.NodeOutput( | |
| combined_audio, | |
| first_audio, | |
| first_start, | |
| second_audio, | |
| second_start, | |
| ) | |
| def _subtitle_base_name(video_path: str | None) -> str: | |
| if video_path: | |
| stem = Path(video_path).stem.strip() | |
| if stem: | |
| return default_subtitle_filename(stem) | |
| return default_subtitle_filename() | |
| def _add_subtitle_segments_to_video( | |
| video: object, | |
| subtitle_segments: list[object], | |
| srt_save: str, | |
| ) -> io.NodeOutput: | |
| if not subtitle_segments: | |
| return io.NodeOutput(video) | |
| save_mode = str(_unwrap_list_scalar(srt_save, "temp")) | |
| if save_mode not in {"temp", "output"}: | |
| save_mode = "temp" | |
| width, height = video.get_dimensions() | |
| input_path, temp_files = video_input_to_local_file( | |
| video, | |
| suffix=".mp4", | |
| save_kwargs={ | |
| "format": Types.VideoContainer.AUTO, | |
| "codec": Types.VideoCodec.AUTO, | |
| }, | |
| ) | |
| ass_path: Path | None = None | |
| try: | |
| base_name = _subtitle_base_name(input_path) | |
| if save_mode == "output": | |
| srt_dir = Path(folder_paths.get_output_directory()) / "srt" | |
| else: | |
| srt_dir = Path(folder_paths.get_temp_directory()) | |
| write_srt_file(subtitle_segments, srt_dir / f"{base_name}.srt") | |
| ass_fd, ass_raw_path = tempfile.mkstemp( | |
| prefix=f"{base_name}_", | |
| suffix=".ass", | |
| dir=folder_paths.get_temp_directory(), | |
| ) | |
| os.close(ass_fd) | |
| ass_path = write_ass_file(subtitle_segments, Path(ass_raw_path), width, height) | |
| output_fd, output_path = tempfile.mkstemp( | |
| prefix=f"{base_name}_subtitled_", | |
| suffix=".mp4", | |
| dir=folder_paths.get_temp_directory(), | |
| ) | |
| os.close(output_fd) | |
| burn_subtitles_with_ffmpeg(input_path, str(ass_path), output_path) | |
| return io.NodeOutput(InputImpl.VideoFromFile(output_path)) | |
| finally: | |
| for path in temp_files: | |
| try: | |
| os.unlink(path) | |
| except OSError: | |
| pass | |
| if ass_path is not None: | |
| try: | |
| ass_path.unlink(missing_ok=True) | |
| except OSError: | |
| pass | |
| class AddSubtitleToVideo(io.ComfyNode): | |
| def define_schema(cls) -> io.Schema: | |
| return io.Schema( | |
| node_id="easy addSubtitleToVideo", | |
| display_name="Add Subtitle To Video", | |
| category=CATEGORY_VIDEO, | |
| description=( | |
| "Burn multiline SRT, timestamp, or bracket-formatted subtitle text " | |
| "into a VIDEO and save a normalized SRT file." | |
| ), | |
| inputs=[ | |
| io.String.Input( | |
| "subtitle_text", | |
| multiline=True, | |
| default="", | |
| placeholder=( | |
| "1\n00:00:00,000 --> 00:00:02,000\nSubtitle text\n\n" | |
| "or [00:02.000 --> 00:04.000] Subtitle text" | |
| ), | |
| dynamic_prompts=False, | |
| ), | |
| io.Video.Input("video"), | |
| io.Combo.Input( | |
| "srt_save", | |
| options=["temp", "output"], | |
| default="temp", | |
| tooltip="Save the normalized SRT in temp or output/srt.", | |
| ), | |
| io.Int.Input("font_size", default=16, min=8, max=96, step=1), | |
| ], | |
| outputs=[io.Video.Output("VIDEO")], | |
| ) | |
| def execute( | |
| cls, | |
| subtitle_text: str, | |
| video: object, | |
| srt_save: str = "temp", | |
| font_size: int = 16, | |
| ) -> io.NodeOutput: | |
| segments = parse_subtitle_text( | |
| str(_unwrap_list_scalar(subtitle_text, "")), | |
| style={"font_size": int(_unwrap_list_scalar(font_size, 16))}, | |
| ) | |
| return _add_subtitle_segments_to_video(video, segments, srt_save) | |
| class MultiTrackAddSubtitleToVideo(io.ComfyNode): | |
| def define_schema(cls): | |
| return io.Schema( | |
| node_id="easy multiTrackAddSubtitleToVIdeo", | |
| display_name="MultiTrack Add Subtitle To Video", | |
| category=CATEGORY_MULTITRACK, | |
| description="Burn all subtitle track segments from TRACKS_INFO into a VIDEO and save an SRT file.", | |
| inputs=[ | |
| TYPE_TRACKS_INFO.Input("tracks_info"), | |
| io.Video.Input("video"), | |
| io.Combo.Input( | |
| "srt_save", | |
| options=["temp", "output"], | |
| default="temp", | |
| tooltip="Save the generated SRT in temp or output/srt.", | |
| ), | |
| ], | |
| outputs=[ | |
| io.Video.Output("VIDEO"), | |
| ], | |
| ) | |
| def execute(cls, tracks_info: str | dict, video, srt_save: str = "temp") -> io.NodeOutput: | |
| if isinstance(tracks_info, list): | |
| tracks_info = tracks_info[0] if tracks_info else {} | |
| info = _parse_track_data(tracks_info) | |
| subtitle_segments = collect_multitrack_subtitle_segments(info) | |
| return _add_subtitle_segments_to_video(video, subtitle_segments, srt_save) | |
| def _unwrap_list_scalar(value, default=None): | |
| if isinstance(value, list): | |
| return value[0] if value else default | |
| return value if value is not None else default | |
| def _unwrap_singleton_container(value, default=None): | |
| while isinstance(value, (list, tuple)): | |
| if not value: | |
| return default | |
| if len(value) != 1: | |
| return value | |
| value = value[0] | |
| return value if value is not None else default | |
| def _track_output_index(track: dict) -> 'int | None': | |
| raw_index = track.get("media_index") | |
| if raw_index is None: | |
| for segment in track.get("segments", []): | |
| if isinstance(segment, dict): | |
| content = segment.get("content", {}) | |
| if isinstance(content, dict) and content.get("media_index") is not None: | |
| raw_index = content["media_index"] | |
| break | |
| try: | |
| return int(raw_index) if raw_index is not None else None | |
| except (TypeError, ValueError): | |
| return None | |
| def _shared_reference_output_index(segment: dict) -> 'int | None': | |
| content = segment.get("content", {}) | |
| raw_index = None | |
| if isinstance(content, dict): | |
| raw_index = content.get("shared_media_index", content.get("speaker_media_index")) | |
| try: | |
| return int(raw_index) if raw_index is not None else None | |
| except (TypeError, ValueError): | |
| return None | |
| def _track_media_end_frame( | |
| track: dict, | |
| start_frame: int | None = None, | |
| end_frame: int | None = None, | |
| ) -> 'int | None': | |
| track_type = track.get("type") | |
| valid_ends: list[int] = [] | |
| for segment in track.get("segments", []): | |
| if not isinstance(segment, dict): | |
| continue | |
| content = segment.get("content") | |
| if not isinstance(content, dict) or content.get("media_type") != track_type: | |
| continue | |
| segment_start = _multitrack_frame_value(segment.get("start_frame")) | |
| segment_end = _multitrack_frame_value(segment.get("end_frame"), -1) | |
| if segment_end < 0: | |
| continue | |
| if start_frame is not None and segment_end <= start_frame: | |
| continue | |
| if end_frame is not None and segment_start >= end_frame: | |
| continue | |
| valid_ends.append(min(segment_end, end_frame) if end_frame is not None else segment_end) | |
| return max(valid_ends) if valid_ends else None | |
| def _ranges_overlap(start: int, end: int, segment: dict) -> bool: | |
| return int(segment.get("start_frame", 0)) < end and int(segment.get("end_frame", 0)) > start | |
| def _multitrack_task_type(task: dict, image_count: int, has_video: bool) -> str: | |
| content = task.get("content", {}) | |
| explicit_task_type = content.get("task_type") if isinstance(content, dict) else None | |
| if isinstance(explicit_task_type, str) and explicit_task_type.strip(): | |
| return explicit_task_type.strip() | |
| mode = content.get("task_mode", "default") if isinstance(content, dict) else "default" | |
| if mode == "l2v": | |
| return "l2v" | |
| if mode == "ref": | |
| return "rv2v" if has_video else "r2v" | |
| if mode == "edit": | |
| return "vi2v" if image_count > 0 else "v2v" | |
| return "i2v" if image_count > 0 else "t2v" | |
| # code based on https://github.com/RH-RunningHub/ComfyUI-RH-Bernini/blob/main/nodes_bernini.py | |
| def _build_chat_prompts(system_prompt, api_prompt, original_prompt): | |
| system_prompt = (system_prompt or "").strip() | |
| api_prompt = (api_prompt or "").strip() | |
| original_prompt = (original_prompt or "").strip() | |
| if not api_prompt or api_prompt == original_prompt: | |
| return system_prompt, original_prompt | |
| text = api_prompt | |
| match = re.search( | |
| r"\n\s*(?P<label>Original (?:instruction|description)):\s*\n(?P<user>.*?)\s*$", | |
| text, | |
| flags=re.DOTALL, | |
| ) | |
| if match: | |
| return text[: match.start()].strip(), match.group("user").strip() | |
| match = re.search( | |
| r"(?m)^\s*-?\s*User's (?:raw instruction|editing instruction|instruction|prompt):\s*\"(?P<user>.*?)\"\s*$", | |
| text, | |
| ) | |
| if match: | |
| cleaned = (text[: match.start()] + text[match.end() :]).strip() | |
| return cleaned, match.group("user").strip() | |
| return api_prompt, original_prompt | |
| def _format_multitrack_prompt_relay( | |
| prompt: str, | |
| start_frame: int, | |
| end_frame: int, | |
| image_count: int, | |
| ) -> str: | |
| prompt = (prompt or "").strip() | |
| if not prompt or end_frame <= start_frame: | |
| return prompt | |
| if image_count <= 0: | |
| return f"{prompt} [{start_frame}-{end_frame}]" | |
| parts = [part.strip() for part in prompt.split("|") if part.strip()] | |
| frame_count = end_frame - start_frame | |
| formatted: list[str] = [] | |
| for index, part in enumerate(parts[:image_count]): | |
| range_start = start_frame + math.ceil(index * frame_count / image_count) | |
| range_end = start_frame + math.ceil((index + 1) * frame_count / image_count) | |
| formatted.append(f"{part} [{range_start}-{range_end}]") | |
| return " | ".join(formatted) | |
| def _selected_multitrack_user_prompt(content: dict) -> str: | |
| if str(content.get("user_prompt_variant", "a")).lower() == "b": | |
| prompt = str(content.get("user_prompt_b") or "") | |
| else: | |
| prompt = str(content.get("user_prompt") or content.get("text") or "") | |
| return prompt.replace("@", "") | |
| def _format_marker_task_prompt_relay( | |
| tasks: list[dict], | |
| start_frame: int, | |
| end_frame: int, | |
| ) -> str: | |
| formatted: list[str] = [] | |
| for task in tasks: | |
| task_start = max(start_frame, _multitrack_frame_value(task.get("start_frame"))) | |
| task_end = min(end_frame, _multitrack_frame_value(task.get("end_frame"))) | |
| if task_end <= task_start: | |
| continue | |
| content = task.get("content", {}) | |
| if not isinstance(content, dict): | |
| continue | |
| prompt = _selected_multitrack_user_prompt(content).strip() | |
| if prompt: | |
| formatted.append(_format_multitrack_prompt_relay(prompt, task_start, task_end, 0)) | |
| return " | ".join(formatted) | |
| def _evenly_distributed_image_indexes(image_count: int, duration_frames: int) -> str: | |
| if image_count <= 0: | |
| return "" | |
| if image_count == 1: | |
| return "0" | |
| indexes = [0] | |
| indexes.extend( | |
| math.ceil(index * max(0, duration_frames) / (image_count - 1)) | |
| for index in range(1, image_count - 1) | |
| ) | |
| indexes.append(-1) | |
| return ",".join(str(index) for index in indexes) | |
| class MultiTrackTaskOutput(io.ComfyNode): | |
| def define_schema(cls): | |
| return io.Schema( | |
| node_id="easy multiTrackTaskOutput", | |
| display_name="MultiTrack Task Output", | |
| category=CATEGORY_MULTITRACK, | |
| description="Output prompts and task-range media for a multitrack task segment.", | |
| is_input_list=True, | |
| inputs=[ | |
| TYPE_TRACKS_INFO.Input("tracks_info"), | |
| io.Image.Input("images", optional=True), | |
| io.Audio.Input("audio", optional=True), | |
| io.Video.Input("video", optional=True), | |
| io.Int.Input( | |
| "task_index", | |
| default=0, | |
| min=-1, | |
| tooltip=( | |
| "When set to -1, output the complete timeline media from " | |
| "all clips." | |
| ), | |
| ), | |
| io.Combo.Input( | |
| "prompt_format", | |
| options=PROMPT_FORMAT_OPTIONS + ["api", "llm"], | |
| default="api", | |
| tooltip="Choose prompt format.", | |
| ), | |
| io.AnyType.Input( | |
| "previous", | |
| optional=True, | |
| tooltip="Optional project-loop execution dependency.", | |
| ), | |
| ], | |
| outputs=[ | |
| io.String.Output("SYSTEM_PROMPT"), | |
| io.String.Output("USER_PROMPT"), | |
| io.String.Output("TYPE"), | |
| io.Int.Output("LENGTH"), | |
| io.Image.Output("IMAGES", is_output_list=True), | |
| io.Audio.Output("AUDIO", is_output_list=True), | |
| io.Video.Output("VIDEO", is_output_list=True), | |
| io.String.Output("IMAGE_INDEXES"), | |
| io.Audio.Output("LOCKED_AUDIO"), | |
| ], | |
| ) | |
| def execute( | |
| cls, | |
| tracks_info: list | dict | str, | |
| images: list | torch.Tensor | None = None, | |
| audio: list | dict | None = None, | |
| video: list | object | None = None, | |
| task_index: list[int] | int | None = None, | |
| prompt_format: list[str] | str | None = None, | |
| previous: object | None = None, | |
| ) -> io.NodeOutput: | |
| del previous | |
| raw_info = _unwrap_list_scalar(tracks_info, {}) | |
| runtime_cache = multitrack_runtime_cache(raw_info) | |
| info = _parse_track_data(raw_info) | |
| preloaded_media = info.get("_preloaded_media", {}) | |
| if not isinstance(preloaded_media, dict): | |
| preloaded_media = {} | |
| image_items = _as_list_input(images) | |
| audio_items = _as_list_input(audio) | |
| video_items = _as_list_input(video) | |
| if not any(isinstance(item, torch.Tensor) for item in image_items): | |
| image_items = _embedded_multitrack_media(info, "images") | |
| if not iter_valid_audio_inputs(audio_items): | |
| audio_items = _embedded_multitrack_media(info, "audio") | |
| if not any(item is not None for item in video_items): | |
| video_items = _embedded_multitrack_media(info, "video") | |
| requested_index = int(_unwrap_list_scalar(task_index, 0)) | |
| output_full_timeline = requested_index == -1 | |
| index = max(0, requested_index) | |
| selected_prompt_format = str(_unwrap_list_scalar(prompt_format, "default")) | |
| # Share ffmpeg-extracted audio across tracks inside the same execute | |
| # call so a video referenced by many locked audio/video tracks (or by | |
| # multiple iterations of the timeline loop below) decodes once. | |
| video_audio_cache: dict = {} | |
| task_cache_key = ( | |
| "multitrack_task_output", | |
| requested_index, | |
| selected_prompt_format, | |
| ) | |
| can_restore_runtime = ( | |
| isinstance(runtime_cache, dict) | |
| and "_preloaded_media" in info | |
| and not image_items | |
| and not audio_items | |
| and not video_items | |
| ) | |
| cached_task_output = ( | |
| runtime_cache.get(task_cache_key) | |
| if can_restore_runtime | |
| else None | |
| ) | |
| if isinstance(cached_task_output, io.NodeOutput): | |
| cache_status = info.get("_easy_media_cache_status", {}) | |
| if isinstance(cache_status, dict): | |
| cache_status["task_output"] = "cache-hit" | |
| log_node_info( | |
| "MultiTrack Cache", | |
| f"segment={requested_index} | " | |
| f"ProjectMedia={cache_status.get('project_media', 'unknown')} | " | |
| f"SegmentMedia={cache_status.get('segment_media', 'unknown')} | " | |
| "TaskOutput=cache-hit", | |
| ) | |
| return cached_task_output | |
| if "_preloaded_media" in info: | |
| cache_status = info.get("_easy_media_cache_status", {}) | |
| if isinstance(cache_status, dict): | |
| cache_status["task_output"] = "first-load" | |
| log_node_info( | |
| "MultiTrack Cache", | |
| f"segment={requested_index} | " | |
| f"ProjectMedia={cache_status.get('project_media', 'unknown')} | " | |
| f"SegmentMedia={cache_status.get('segment_media', 'unknown')} | " | |
| "TaskOutput=first-load", | |
| ) | |
| tracks = info.get("tracks", []) | |
| task_entries = _multitrack_task_entries(info) | |
| task_entry_index = min(index, len(task_entries) - 1) if task_entries else -1 | |
| task_entry = task_entries[task_entry_index] if task_entry_index >= 0 else {} | |
| task = task_entry.get("task", {}) if isinstance(task_entry, dict) else {} | |
| content = task.get("content", {}) if isinstance(task.get("content", {}), dict) else {} | |
| start_frame = max(0, int(task_entry.get("start_frame", task.get("start_frame", 0)))) | |
| end_frame = max(start_frame, int(task_entry.get("end_frame", task.get("end_frame", start_frame)))) | |
| if output_full_timeline: | |
| start_frame = 0 | |
| end_frame = _multitrack_timeline_end(info) | |
| duration_frames = end_frame - start_frame | |
| frame_rate = float(info.get("frame_rate", 24)) | |
| is_minimax = info.get("format") == "MiniMax" | |
| next_task_start = None | |
| if ( | |
| is_minimax | |
| and not output_full_timeline | |
| and task_entry_index + 1 < len(task_entries) | |
| ): | |
| next_task_start = max( | |
| start_frame, | |
| _multitrack_frame_value(task_entries[task_entry_index + 1].get("start_frame")), | |
| ) | |
| media_duration_frames = ( | |
| next_task_start - start_frame if next_task_start is not None else None | |
| ) | |
| if output_full_timeline: | |
| length = ( | |
| _video_frame_count_from_duration(duration_frames, frame_rate, "MiniMax") | |
| if is_minimax | |
| else duration_frames + 1 | |
| ) | |
| elif not task_entry: | |
| length = 0 | |
| elif is_minimax: | |
| length = _video_frame_count_from_duration(duration_frames, frame_rate, "MiniMax") | |
| else: | |
| length = duration_frames + 1 | |
| task_content_entries = [(content, start_frame)] | |
| if output_full_timeline: | |
| task_content_entries = [ | |
| (candidate_content, _multitrack_frame_value(candidate.get("start_frame"))) | |
| for candidate in _multitrack_task_segments(info) | |
| for candidate_content in [candidate.get("content", {})] | |
| if isinstance(candidate_content, dict) | |
| ] | |
| elif task_entry.get("marker_mode"): | |
| task_content_entries = [ | |
| ( | |
| candidate_content, | |
| max(start_frame, _multitrack_frame_value(candidate.get("start_frame"))), | |
| ) | |
| for candidate in _multitrack_task_segments(info) | |
| if _ranges_overlap(start_frame, end_frame, candidate) | |
| for candidate_content in [candidate.get("content", {})] | |
| if isinstance(candidate_content, dict) | |
| ] | |
| with log_stage_time( | |
| "MultiTrack Task Output", | |
| f"segment {requested_index} / media_loading | " | |
| f"{info.get('width', 544)}x{info.get('height', 960)}", | |
| ): | |
| log_node_info( | |
| "MultiTrack Task Output", | |
| f"segment={requested_index} | loading segment media", | |
| ) | |
| selected_images = [ | |
| item | |
| for item in _as_list_input(preloaded_media.get("images")) | |
| if isinstance(item, torch.Tensor) | |
| ] | |
| selected_image_indexes: set[int] = set() | |
| selected_shared_image_identities: set[tuple[str, str]] = set() | |
| marker_image_frames: list[int] = [] | |
| deferred_images = _multitrack_media_is_deferred(info, "image") | |
| deferred_audio = _multitrack_media_is_deferred(info, "audio") | |
| deferred_video = _multitrack_media_is_deferred(info, "video") | |
| media_progress = ProgressBar(2) | |
| media_progress.update_absolute(0) | |
| for task_content, task_content_start in task_content_entries: | |
| for image_info in task_content.get("images", []): | |
| if not isinstance(image_info, dict): | |
| continue | |
| if multitrack_is_muted_image(image_info): | |
| continue | |
| shared_identity = ( | |
| multitrack_media_identity(image_info) | |
| if multitrack_is_shared_reference(image_info) | |
| else None | |
| ) | |
| if shared_identity is not None and shared_identity in selected_shared_image_identities: | |
| continue | |
| if deferred_images: | |
| image = _resolve_timeline_image_item(image_info, None) | |
| if image is None: | |
| continue | |
| panorama_view = image_info.get("panorama_view") | |
| if panorama_view is not None: | |
| try: | |
| image = equirectangular_to_perspective( | |
| image, | |
| panorama_view, | |
| int(info.get("width", 544)), | |
| int(info.get("height", 960)), | |
| ) | |
| except (TypeError, ValueError, RuntimeError) as exc: | |
| image_id = image_info.get("id", "") | |
| raise ValueError( | |
| f"Failed to project panorama image {image_id!r}: {exc}" | |
| ) from exc | |
| selected_images.append(image) | |
| if shared_identity is not None: | |
| selected_shared_image_identities.add(shared_identity) | |
| if output_full_timeline or task_entry.get("marker_mode"): | |
| marker_image_frames.append(max(0, task_content_start - start_frame)) | |
| continue | |
| try: | |
| media_index = int(image_info.get("media_index")) | |
| except (TypeError, ValueError): | |
| continue | |
| if ( | |
| media_index not in selected_image_indexes | |
| and 0 <= media_index < len(image_items) | |
| and isinstance(image_items[media_index], torch.Tensor) | |
| ): | |
| selected_image_indexes.add(media_index) | |
| selected_images.append(image_items[media_index]) | |
| if shared_identity is not None: | |
| selected_shared_image_identities.add(shared_identity) | |
| if output_full_timeline or task_entry.get("marker_mode"): | |
| marker_image_frames.append(max(0, task_content_start - start_frame)) | |
| media_progress.update_absolute(1) | |
| if output_full_timeline or task_entry.get("marker_mode"): | |
| image_indexes = ",".join(str(frame) for frame in marker_image_frames) | |
| else: | |
| image_indexes = _evenly_distributed_image_indexes( | |
| len(selected_images), | |
| duration_frames, | |
| ) | |
| selected_audio = list( | |
| iter_valid_audio_inputs( | |
| _as_list_input(preloaded_media.get("audio")) | |
| ) | |
| ) | |
| locked_audio: dict | None = None | |
| locked_audio_priority = 0 | |
| selected_video = [ | |
| item | |
| for item in _as_list_input(preloaded_media.get("video")) | |
| if item is not None | |
| ] | |
| deferred_shared_video_cache: dict[tuple, object] = {} | |
| has_video = bool(selected_video) | |
| global_volume_db = audio_volume_db(info) | |
| global_muted = audio_is_muted(info) | |
| has_solo_track = any( | |
| isinstance(track, dict) | |
| and track.get("type") in {"audio", "video"} | |
| and track.get("solo") is True | |
| for track in tracks | |
| ) if isinstance(tracks, list) else False | |
| media_tracks = tracks if isinstance(tracks, list) else [] | |
| if not output_full_timeline: | |
| media_tracks = media_tracks[1:] | |
| if output_full_timeline: | |
| if not deferred_audio: | |
| selected_audio = list(audio_items) | |
| if not deferred_video: | |
| selected_video = list(video_items) | |
| has_video = any(item is not None for item in selected_video) | |
| for track in media_tracks if isinstance(media_tracks, list) else []: | |
| if not isinstance(track, dict): | |
| continue | |
| media_index = _track_output_index(track) | |
| if multitrack_audio_lock_is_effective( | |
| info, | |
| track, | |
| 0, | |
| _multitrack_timeline_end(info), | |
| has_solo_track=has_solo_track, | |
| ): | |
| lock_priority = 2 if track.get("type") == "audio" else 1 | |
| if ( | |
| track.get("type") == "audio" | |
| and not deferred_audio | |
| and media_index is not None | |
| and 0 <= media_index < len(audio_items) | |
| and isinstance(audio_items[media_index], dict) | |
| and lock_priority > locked_audio_priority | |
| ): | |
| locked_audio = audio_items[media_index] | |
| locked_audio_priority = lock_priority | |
| elif ( | |
| track.get("type") == "video" | |
| and not deferred_video | |
| and media_index is not None | |
| and 0 <= media_index < len(video_items) | |
| and video_items[media_index] is not None | |
| and lock_priority > locked_audio_priority | |
| ): | |
| video_audio = extract_video_audio( | |
| video_items[media_index], | |
| cache=video_audio_cache, | |
| ) | |
| if isinstance(video_audio, dict): | |
| locked_audio = video_audio | |
| locked_audio_priority = lock_priority | |
| media_tracks = [ | |
| track | |
| for track in media_tracks | |
| if isinstance(track, dict) | |
| and ( | |
| (track.get("type") == "audio" and deferred_audio) | |
| or (track.get("type") == "video" and deferred_video) | |
| ) | |
| ] | |
| for track in media_tracks if isinstance(media_tracks, list) else []: | |
| if not isinstance(track, dict): | |
| continue | |
| media_index = _track_output_index(track) | |
| shared_segment = _shared_reference_segment(track) if not output_full_timeline else None | |
| if shared_segment is not None: | |
| shared_content = shared_segment.get("content", {}) | |
| if track.get("type") == "audio": | |
| shared_audio: dict | None = None | |
| if deferred_audio: | |
| resolved_audio = _resolve_multitrack_audio(shared_content, None) | |
| if resolved_audio is not None: | |
| shared_audio = _build_shared_reference_audio( | |
| shared_segment, | |
| resolved_audio, | |
| global_volume_db + audio_volume_db(track), | |
| global_muted | |
| or audio_is_muted(track) | |
| or (has_solo_track and track.get("solo") is not True), | |
| ) | |
| else: | |
| shared_media_index = _shared_reference_output_index(shared_segment) | |
| if ( | |
| shared_media_index is not None | |
| and 0 <= shared_media_index < len(audio_items) | |
| and isinstance(audio_items[shared_media_index], dict) | |
| ): | |
| shared_audio = audio_items[shared_media_index] | |
| if shared_audio is not None: | |
| selected_audio.append(shared_audio) | |
| continue | |
| if track.get("type") == "video": | |
| shared_video = None | |
| if deferred_video: | |
| shared_video = _resolve_multitrack_video(shared_content, None) | |
| if shared_video is not None: | |
| shared_video = _resize_multitrack_video( | |
| shared_video, | |
| int(info.get("width", 544)), | |
| int(info.get("height", 960)), | |
| str(info.get("resize_method", "stretch")), | |
| deferred_shared_video_cache, | |
| lambda _ratio: None, | |
| ) | |
| else: | |
| shared_media_index = _shared_reference_output_index(shared_segment) | |
| if shared_media_index is not None and 0 <= shared_media_index < len(video_items): | |
| shared_video = video_items[shared_media_index] | |
| if shared_video is not None: | |
| selected_video.append(shared_video) | |
| has_video = True | |
| continue | |
| track_media_duration_frames = media_duration_frames | |
| if is_minimax: | |
| track_media_end = _track_media_end_frame( | |
| track, | |
| start_frame, | |
| next_task_start, | |
| ) | |
| if track_media_end is None: | |
| continue | |
| available_frames = max(0, track_media_end - start_frame) | |
| track_media_duration_frames = ( | |
| min(track_media_duration_frames, available_frames) | |
| if track_media_duration_frames is not None | |
| else available_frames | |
| ) | |
| if track_media_duration_frames is not None and track_media_duration_frames <= 0: | |
| continue | |
| lock_duration = ( | |
| track_media_duration_frames | |
| if is_minimax | |
| else duration_frames | |
| ) | |
| locked_audio_track = multitrack_audio_lock_is_effective( | |
| info, | |
| track, | |
| start_frame, | |
| start_frame + max(0, int(lock_duration or 0)), | |
| has_solo_track=has_solo_track, | |
| ) | |
| track_media_deferred = ( | |
| (track.get("type") == "audio" and deferred_audio) | |
| or (track.get("type") == "video" and deferred_video) | |
| ) | |
| if track_media_deferred: | |
| local_duration = ( | |
| track_media_duration_frames | |
| if is_minimax | |
| else duration_frames | |
| ) | |
| if local_duration is None or local_duration <= 0: | |
| continue | |
| local_segments = multitrack_segments_in_window( | |
| track, | |
| start_frame, | |
| start_frame + local_duration, | |
| ) | |
| track_volume_db = global_volume_db + audio_volume_db(track) | |
| track_muted = ( | |
| global_muted | |
| or audio_is_muted(track) | |
| or (has_solo_track and track.get("solo") is not True) | |
| ) | |
| if track.get("type") == "audio": | |
| resolved_audio_segments: list[tuple[dict, dict]] = [] | |
| for local_segment in local_segments: | |
| local_content = local_segment.get("content", {}) | |
| resolved_audio = _resolve_multitrack_audio(local_content, None) | |
| if resolved_audio is not None: | |
| resolved_audio_segments.append((local_segment, resolved_audio)) | |
| if not is_minimax or resolved_audio_segments: | |
| selected_audio.append(_merge_audio_track( | |
| resolved_audio_segments, | |
| local_duration, | |
| frame_rate, | |
| track_volume_db, | |
| track_muted, | |
| )) | |
| if ( | |
| locked_audio_track | |
| and resolved_audio_segments | |
| and locked_audio_priority < 2 | |
| ): | |
| locked_audio = _merge_audio_track( | |
| resolved_audio_segments, | |
| duration_frames, | |
| frame_rate, | |
| track_volume_db, | |
| track_muted, | |
| ) | |
| locked_audio_priority = 2 | |
| continue | |
| resolved_video_segments: list[tuple[dict, object]] = [] | |
| for local_segment in local_segments: | |
| local_content = local_segment.get("content", {}) | |
| resolved_video = _resolve_multitrack_video(local_content, None) | |
| if resolved_video is None: | |
| continue | |
| resolved_video_segments.append((local_segment, resolved_video)) | |
| has_video = has_video or bool(local_segments) | |
| if not is_minimax or resolved_video_segments: | |
| log_node_info( | |
| "MultiTrack Task Output", | |
| f"segment={requested_index} | compositing video track " | |
| f"({len(resolved_video_segments)} clips)", | |
| ) | |
| merged_video = _merge_video_track( | |
| resolved_video_segments, | |
| local_duration, | |
| frame_rate, | |
| int(info.get("width", 544)), | |
| int(info.get("height", 960)), | |
| track_volume_db, | |
| track_muted, | |
| resize_method=str(info.get("resize_method", "stretch")), | |
| ) | |
| selected_video.append(merged_video) | |
| if locked_audio_priority < 1 and locked_audio_track: | |
| log_node_info( | |
| "MultiTrack Task Output", | |
| f"segment={requested_index} | extracting locked video audio", | |
| ) | |
| video_audio = extract_video_audio( | |
| merged_video, | |
| cache=video_audio_cache, | |
| ) | |
| if isinstance(video_audio, dict): | |
| locked_audio = _trim_track_audio( | |
| video_audio, | |
| 0, | |
| duration_frames, | |
| frame_rate, | |
| ) | |
| locked_audio_priority = 1 | |
| continue | |
| if track.get("type") == "audio" and media_index is not None and 0 <= media_index < len(audio_items): | |
| track_audio = audio_items[media_index] | |
| if isinstance(track_audio, dict): | |
| task_audio = _trim_track_audio( | |
| track_audio, | |
| start_frame, | |
| track_media_duration_frames if is_minimax else duration_frames, | |
| frame_rate, | |
| ) | |
| selected_audio.append(task_audio) | |
| if locked_audio_track and locked_audio_priority < 2: | |
| locked_audio = task_audio | |
| locked_audio_priority = 2 | |
| elif track.get("type") == "video" and media_index is not None and 0 <= media_index < len(video_items): | |
| track_video = video_items[media_index] | |
| if locked_audio_priority < 1 and locked_audio_track: | |
| video_audio = extract_video_audio( | |
| track_video, | |
| cache=video_audio_cache, | |
| ) | |
| if isinstance(video_audio, dict): | |
| locked_audio = _trim_track_audio( | |
| video_audio, | |
| start_frame, | |
| track_media_duration_frames if is_minimax else duration_frames, | |
| frame_rate, | |
| ) | |
| locked_audio_priority = 1 | |
| video_duration = duration_frames / frame_rate | |
| if is_minimax: | |
| video_duration = ( | |
| track_media_duration_frames / frame_rate | |
| if track_media_duration_frames is not None | |
| else 0.0 | |
| ) | |
| trimmed = track_video.as_trimmed( | |
| start_time=start_frame / frame_rate, | |
| duration=video_duration, | |
| strict_duration=False, | |
| ) | |
| if trimmed is not None: | |
| selected_video.append(trimmed) | |
| has_video = has_video or any( | |
| isinstance(segment, dict) | |
| and isinstance(segment.get("content"), dict) | |
| and _ranges_overlap(start_frame, end_frame, segment) | |
| for segment in track.get("segments", []) | |
| ) | |
| media_progress.update_absolute(2) | |
| task_type = _multitrack_task_type(task, len(selected_images), has_video) | |
| prompt = _selected_multitrack_user_prompt(content) | |
| system_prompt, api_prompt, json_mode = build_prompt_request( | |
| task_type, | |
| prompt, | |
| images=selected_images, | |
| video=selected_video, | |
| custom_system_prompt=( | |
| str(content.get("system_prompt")).replace("@", "") | |
| if content.get("system_prompt") | |
| else None | |
| ), | |
| video_format=info.get("format"), | |
| task_mode=content.get("task_mode", "default"), | |
| ) | |
| chat_system_prompt, chat_user_prompt = _build_chat_prompts(system_prompt, api_prompt, prompt) | |
| llm_prompt = build_llm_prompt(chat_system_prompt, chat_user_prompt, json_mode) | |
| if selected_prompt_format == "promptRelay": | |
| if output_full_timeline or task_entry.get("marker_mode"): | |
| user_prompt = _format_marker_task_prompt_relay( | |
| _multitrack_task_segments(info), | |
| start_frame, | |
| end_frame, | |
| ) | |
| else: | |
| user_prompt = _format_multitrack_prompt_relay( | |
| chat_user_prompt, | |
| start_frame, | |
| end_frame, | |
| len(selected_images), | |
| ) | |
| elif selected_prompt_format == "api": | |
| user_prompt = chat_user_prompt | |
| elif selected_prompt_format == "llm": | |
| user_prompt = llm_prompt | |
| else: | |
| user_prompt = chat_user_prompt | |
| output_system_prompt = ( | |
| "" if selected_prompt_format in {"default", "promptRelay"} else chat_system_prompt | |
| ) | |
| output = io.NodeOutput( | |
| output_system_prompt, | |
| user_prompt, | |
| task_type, | |
| length, | |
| selected_images, | |
| (selected_audio or [None]) if is_minimax else selected_audio, | |
| (selected_video or [None]) if is_minimax else selected_video, | |
| image_indexes, | |
| locked_audio, | |
| ) | |
| if can_restore_runtime: | |
| runtime_cache[task_cache_key] = output | |
| return output | |
| class MultiTrackPromptEnhancer(io.ComfyNode): | |
| def _api_key_input() -> object: | |
| return io.String.Input( | |
| "apikey", | |
| default="", | |
| tooltip=( | |
| "Provider API key. If empty, the matching key is read from " | |
| "config.yaml or the environment." | |
| ), | |
| ) | |
| def _model_options(cls, force_synchronous: bool = False) -> list: | |
| options: list = [] | |
| for model_name in PROMPT_ENHANCER_MODELS: | |
| inputs: list = [] | |
| if model_name != LLAMACPP_MODEL: | |
| inputs.append(cls._api_key_input()) | |
| if model_name == MINIMAX_MODEL: | |
| inputs.extend( | |
| [ | |
| io.Combo.Input( | |
| "ratio", | |
| options=PROMPT_ENHANCER_RATIO_OPTIONS, | |
| default="adaptive", | |
| tooltip=( | |
| "MiniMax official API ratio. Text-only adaptive " | |
| "requests use 16:9." | |
| ), | |
| ), | |
| io.Boolean.Input( | |
| "return_async", | |
| default=False, | |
| tooltip=( | |
| "Project prompt replacement always waits for the final " | |
| "text, so this option is ignored." | |
| if force_synchronous | |
| else "Only effective for h3-context-ir. When enabled, " | |
| "return the task ID without polling the task status." | |
| ), | |
| ), | |
| ] | |
| ) | |
| elif model_name in PROMPT_ENHANCER_MAX_TOKENS: | |
| if model_name == LLAMACPP_MODEL: | |
| inputs.extend( | |
| [ | |
| io.Combo.Input( | |
| "inference_mode", | |
| options=["one by one", "images", "video"], | |
| default="images", | |
| tooltip=( | |
| "one by one: process every list item separately; " | |
| "a multi-image batch inside one item is inferred " | |
| "together. images: combine all images from every " | |
| "list item into one prompt. video: treat each image " | |
| "list item as a separate video clip." | |
| ), | |
| ), | |
| io.Boolean.Input( | |
| "force_offload", | |
| default=True, | |
| tooltip="Unload the local llama.cpp model after inference.", | |
| ), | |
| ] | |
| ) | |
| default, maximum = PROMPT_ENHANCER_MAX_TOKENS[model_name] | |
| value_name = "max_size" if model_name == LLAMACPP_MODEL else "max_tokens" | |
| inputs.append( | |
| io.Int.Input( | |
| value_name, | |
| default=default, | |
| min=128 if model_name == LLAMACPP_MODEL else 1, | |
| max=maximum, | |
| step=64 if model_name == LLAMACPP_MODEL else 1, | |
| tooltip=( | |
| "Maximum input image size for all llama.cpp inference modes." | |
| if model_name == LLAMACPP_MODEL | |
| else "Maximum number of output tokens generated by the model." | |
| ), | |
| ) | |
| ) | |
| options.append(io.DynamicCombo.Option(model_name, inputs)) | |
| return options | |
| def define_schema(cls) -> io.Schema: | |
| return io.Schema( | |
| node_id="easy multiTrackPromptEnhancer", | |
| display_name="MultiTrack Prompt Enhancer", | |
| category=CATEGORY_MULTITRACK, | |
| description=( | |
| "Use Moxie-Context-IR, a configured third-party multimodal LLM, " | |
| "or a local llama.cpp model to enhance a multitrack video prompt." | |
| ), | |
| is_input_list=True, | |
| not_idempotent=True, | |
| enable_expand=True, | |
| inputs=[ | |
| io.String.Input( | |
| "system_prompt", | |
| default="", | |
| multiline=True, | |
| force_input=True, | |
| tooltip="Optional system instructions from MultiTrack Task Output.", | |
| ), | |
| io.String.Input( | |
| "user_prompt", | |
| default="", | |
| multiline=True, | |
| force_input=True, | |
| tooltip="Optional user prompt from MultiTrack Task Output.", | |
| ), | |
| io.String.Input( | |
| "type", | |
| default="t2v", | |
| force_input=True, | |
| tooltip=( | |
| "Task type used to select H3 t2va, i2va, or r2va and " | |
| "assemble provider-specific media roles." | |
| ), | |
| ), | |
| io.Int.Input( | |
| "length", | |
| default=124, | |
| min=1, | |
| max=0x7FFFFFFF, | |
| force_input=True, | |
| tooltip="Moxie-aligned frame length; converted back to 4–15 integer seconds.", | |
| ), | |
| io.Image.Input( | |
| "images", | |
| optional=True, | |
| tooltip=( | |
| "Optional image, batch, or list. H3 uploads each selected image; " | |
| "third-party inputs are limited to 2 megapixels." | |
| ), | |
| ), | |
| io.Audio.Input( | |
| "audio", | |
| optional=True, | |
| tooltip=( | |
| "Optional H3 r2va audio input or list. Audio is uploaded to " | |
| "MiniMax and omitted for third-party models." | |
| ), | |
| ), | |
| io.Video.Input( | |
| "video", | |
| optional=True, | |
| tooltip=( | |
| "Optional video input or list. A supported public video URL " | |
| "or local video data is sent natively when the provider accepts " | |
| "it; otherwise up to 24 resized frames are sent per video. " | |
| "Native video uploads are limited to 15 seconds; RunningHub " | |
| "videos are additionally limited to 10MB." | |
| ), | |
| ), | |
| io.AnyType.Input( | |
| "files", | |
| optional=True, | |
| tooltip="Reserved file input for a future provider implementation.", | |
| ), | |
| TYPE_LLAMACPP_MODEL.Input( | |
| "llama_model", | |
| optional=True, | |
| lazy=True, | |
| raw_link=True, | |
| tooltip=( | |
| "Local llama.cpp model input; evaluated only when the local " | |
| "provider is selected. Requires llama_cpp_instruct_adv from " | |
| f"{LLAMA_CPP_INSTALL_URL}." | |
| ), | |
| ), | |
| io.DynamicCombo.Input( | |
| "model", | |
| options=cls._model_options(), | |
| tooltip="Provider and model used to enhance the prompt.", | |
| ), | |
| io.Int.Input( | |
| "seed", | |
| default=0, | |
| min=0, | |
| max=0xFFFFFFFFFFFFFFFF, | |
| control_after_generate=True, | |
| tooltip="ComfyUI generation seed; used by compatible third-party LLM APIs.", | |
| ), | |
| io.Boolean.Input( | |
| "enabled", | |
| default=True, | |
| tooltip=( | |
| "Enhance the prompt when enabled. When disabled, return " | |
| "user_prompt unchanged without calling the selected model." | |
| ), | |
| ), | |
| TYPE_PROMPT_ENHANCER_ACCOUNT.Input( | |
| "api_account", | |
| tooltip=( | |
| "Provider balance and API key management. This widget is for " | |
| "account status only and does not affect prompt enhancement." | |
| ), | |
| ), | |
| ], | |
| outputs=[ | |
| io.String.Output("PROMPT", tooltip="Enhanced video prompt."), | |
| io.String.Output( | |
| "TASK_ID", | |
| tooltip="MiniMax task ID; empty for all other providers.", | |
| ), | |
| io.String.Output( | |
| "FILE_IDS", | |
| tooltip=( | |
| "Comma-separated MiniMax file IDs for uploaded images, " | |
| "videos, and audio; empty when no media was uploaded." | |
| ), | |
| ), | |
| ], | |
| ) | |
| def execute( | |
| cls, | |
| system_prompt: list[str] | str | None = None, | |
| user_prompt: list[str] | str | None = None, | |
| type: list[str] | str | None = None, | |
| length: list[int] | int | None = None, | |
| images: list | torch.Tensor | None = None, | |
| video: list | object | None = None, | |
| audio: list | dict | None = None, | |
| files: list | object | None = None, | |
| llama_model: list | object | None = None, | |
| model: list[dict] | dict | None = None, | |
| seed: list[int] | int | None = None, | |
| enabled: list[bool] | bool | None = None, | |
| api_account: list[str] | str | None = None, | |
| ) -> io.NodeOutput: | |
| system_text = str(_unwrap_list_scalar(system_prompt, "")) | |
| user_text = str(_unwrap_list_scalar(user_prompt, "")) | |
| if not bool(_unwrap_list_scalar(enabled, True)): | |
| return io.NodeOutput(user_text, "", "") | |
| model_config = _unwrap_list_scalar(model, {}) | |
| if not isinstance(model_config, dict): | |
| raise TypeError("model must be a DynamicCombo configuration dictionary.") | |
| selected_model = str( | |
| _unwrap_list_scalar(model_config.get("model"), MINIMAX_MODEL) | |
| ) | |
| selected_api_key = str( | |
| _unwrap_list_scalar(model_config.get("apikey"), "") | |
| ) | |
| selected_max_size = model_config.get("max_size") | |
| selected_max_tokens = model_config.get("max_tokens") | |
| selected_inference_mode = model_config.get( | |
| "inference_mode", "images" | |
| ) | |
| selected_force_offload = model_config.get("force_offload", True) | |
| selected_ratio_value = model_config.get("ratio", "adaptive") | |
| selected_return_async = model_config.get("return_async", False) | |
| progress_total = 100 | |
| process_bar = ProgressBar(progress_total) | |
| process_bar.update_absolute(0, progress_total) | |
| selected_seed = int(_unwrap_list_scalar(seed, 0)) | |
| if selected_model == LLAMACPP_MODEL: | |
| if LLAMA_CPP_INSTRUCT_NODE_ID not in getattr( | |
| comfy_nodes, "NODE_CLASS_MAPPINGS", {} | |
| ): | |
| raise RuntimeError( | |
| f"Missing node '{LLAMA_CPP_INSTRUCT_NODE_ID}'. Please download " | |
| f"and install ComfyUI-llama-cpp_vlm from {LLAMA_CPP_INSTALL_URL} " | |
| "and restart ComfyUI." | |
| ) | |
| selected_llama_model = _unwrap_singleton_container(llama_model, None) | |
| if selected_llama_model is None: | |
| raise RuntimeError( | |
| "llama_model must be connected when the llama.cpp model backend is selected." | |
| ) | |
| if not isinstance(selected_llama_model, dict) and not is_link( | |
| selected_llama_model | |
| ): | |
| raise TypeError( | |
| "llama_model must resolve to a graph link or llama.cpp " | |
| "configuration dictionary; " | |
| f"received {type(selected_llama_model).__name__}." | |
| ) | |
| graph = GraphBuilder() | |
| local_max_size = min( | |
| int(_unwrap_list_scalar(selected_max_size, 512)), | |
| PROMPT_ENHANCER_MAX_TOKENS[LLAMACPP_MODEL][1], | |
| ) | |
| node_inputs: dict[str, object] = { | |
| "llama_model": selected_llama_model, | |
| "preset_prompt": "Empty - Nothing", | |
| "custom_prompt": user_text, | |
| "system_prompt": system_text, | |
| "inference_mode": str( | |
| _unwrap_list_scalar(selected_inference_mode, "images") | |
| ), | |
| "max_frames": 24, | |
| "max_size": local_max_size, | |
| "seed": selected_seed, | |
| "force_offload": bool( | |
| _unwrap_list_scalar(selected_force_offload, True) | |
| ), | |
| "save_states": False, | |
| } | |
| image_inputs = _as_list_input(images) | |
| if image_inputs: | |
| image_bridge = graph.node( | |
| LLAMA_CPP_IMAGE_LIST_BRIDGE_NODE_ID, | |
| id="local_llama_images", | |
| images=image_inputs, | |
| inference_mode=str( | |
| _unwrap_list_scalar(selected_inference_mode, "one by one") | |
| ), | |
| max_size=local_max_size, | |
| ) | |
| node_inputs["images"] = image_bridge.out(0) | |
| enhancer = graph.node( | |
| LLAMA_CPP_INSTRUCT_NODE_ID, | |
| id="local_llama_prompt_enhancer", | |
| **node_inputs, | |
| ) | |
| trimmed = graph.node( | |
| STRING_TRIM_NODE_ID, | |
| id="local_llama_prompt_trim", | |
| string=enhancer.out(0), | |
| mode="Both", | |
| ) | |
| starts_with_text_fence = graph.node( | |
| STRING_COMPARE_NODE_ID, | |
| id="local_llama_prompt_starts_with_text_fence", | |
| string_a=trimmed.out(0), | |
| string_b="```text", | |
| mode="Starts With", | |
| case_sensitive=True, | |
| ) | |
| ends_with_fence = graph.node( | |
| STRING_COMPARE_NODE_ID, | |
| id="local_llama_prompt_ends_with_fence", | |
| string_a=trimmed.out(0), | |
| string_b="```", | |
| mode="Ends With", | |
| case_sensitive=True, | |
| ) | |
| without_text_fence = graph.node( | |
| STRING_REPLACE_NODE_ID, | |
| id="local_llama_prompt_remove_text_fence", | |
| string=trimmed.out(0), | |
| find="```text", | |
| replace="", | |
| ) | |
| without_closing_fence = graph.node( | |
| STRING_REPLACE_NODE_ID, | |
| id="local_llama_prompt_remove_closing_fence", | |
| string=without_text_fence.out(0), | |
| find="```", | |
| replace="", | |
| ) | |
| cleaned = graph.node( | |
| STRING_TRIM_NODE_ID, | |
| id="local_llama_prompt_cleaned_trim", | |
| string=without_closing_fence.out(0), | |
| mode="Both", | |
| ) | |
| cleaned_if_ending_matches = graph.node( | |
| SWITCH_NODE_ID, | |
| id="local_llama_prompt_end_switch", | |
| switch=ends_with_fence.out(0), | |
| on_false=trimmed.out(0), | |
| on_true=cleaned.out(0), | |
| ) | |
| final_prompt = graph.node( | |
| SWITCH_NODE_ID, | |
| id="local_llama_prompt_start_switch", | |
| switch=starts_with_text_fence.out(0), | |
| on_false=trimmed.out(0), | |
| on_true=cleaned_if_ending_matches.out(0), | |
| ) | |
| process_bar.update_absolute(progress_total, progress_total) | |
| return io.NodeOutput( | |
| final_prompt.out(0), | |
| "", | |
| "", | |
| expand=graph.finalize(), | |
| ) | |
| try: | |
| client = PromptEnhancerClient( | |
| selected_model, | |
| selected_api_key, | |
| ) | |
| is_minimax = selected_model == MINIMAX_MODEL | |
| image_urls = image_tensor_data_uris( | |
| _as_list_input(images), | |
| max_pixels=None if is_minimax else 2_000_000, | |
| ) | |
| video_urls = ( | |
| video_data_uris(_as_list_input(video), max_duration=15) | |
| if is_minimax | |
| else prompt_enhancer_video_inputs( | |
| selected_model, | |
| _as_list_input(video), | |
| ) | |
| ) | |
| audio_urls = audio_data_uris(_as_list_input(audio)) if is_minimax else [] | |
| file_items = [item for item in _as_list_input(files) if item is not None] | |
| process_bar.update_absolute(10, progress_total) | |
| # The file socket is reserved until provider uploads are implemented. | |
| duration = minimax_length_to_seconds(_unwrap_list_scalar(length, 124)) | |
| task_type = str(_unwrap_list_scalar(type, "t2v")) | |
| selected_ratio = str(_unwrap_list_scalar(selected_ratio_value, "adaptive")) | |
| async_requested = bool( | |
| _unwrap_list_scalar(selected_return_async, False) | |
| ) | |
| poll_progress = 20 | |
| def on_poll(_status: str) -> None: | |
| nonlocal poll_progress | |
| try: | |
| import comfy.model_management as model_management | |
| model_management.throw_exception_if_processing_interrupted() | |
| except ImportError: | |
| pass | |
| poll_progress = min(95, poll_progress + 5) | |
| process_bar.update_absolute(poll_progress, progress_total) | |
| process_bar.update_absolute(poll_progress, progress_total) | |
| result = client.enhance( | |
| system_prompt=system_text, | |
| user_prompt=user_text, | |
| task_type=task_type, | |
| duration=duration, | |
| ratio=selected_ratio, | |
| seed=selected_seed, | |
| image_urls=image_urls, | |
| video_urls=video_urls, | |
| audio_urls=audio_urls, | |
| max_tokens=( | |
| None | |
| if selected_max_tokens is None | |
| else int(_unwrap_list_scalar(selected_max_tokens, 4096)) | |
| ), | |
| return_async=async_requested, | |
| poll_interval=5.0, | |
| poll_callback=on_poll, | |
| file_count=len(file_items), | |
| request_logger=log_node_info, | |
| ) | |
| except ( | |
| PromptEnhancerApiError, | |
| NotImplementedError, | |
| ValueError, | |
| TypeError, | |
| OSError, | |
| ) as exc: | |
| process_bar.update_absolute(progress_total, progress_total) | |
| raise RuntimeError(f"Prompt enhancement failed: {exc}") from exc | |
| process_bar.update_absolute(progress_total, progress_total) | |
| return io.NodeOutput( | |
| result.prompt, | |
| result.task_id if selected_model == MINIMAX_MODEL else "", | |
| result.file_ids if selected_model == MINIMAX_MODEL else "", | |
| ) | |
| def _multitrack_prompt_value(content: dict) -> str: | |
| """Return the currently selected prompt variant without normalizing its text.""" | |
| if str(content.get("user_prompt_variant", "a")).lower() == "b": | |
| return str(content.get("user_prompt_b") or "") | |
| return str(content.get("user_prompt") or content.get("text") or "") | |
| def _apply_multitrack_enhanced_prompts(info: dict, prompts: list[str]) -> dict: | |
| """Copy track metadata and replace each task segment's selected prompt.""" | |
| output_info = dict(info) | |
| output_info["tracks"] = deepcopy(info.get("tracks", [])) | |
| task_segments = _multitrack_task_segments(output_info) | |
| if len(task_segments) != len(prompts): | |
| raise ValueError( | |
| "Prompt count does not match the number of multitrack task segments." | |
| ) | |
| for segment, prompt in zip(task_segments, prompts): | |
| content = segment.get("content") | |
| if not isinstance(content, dict): | |
| content = {} | |
| segment["content"] = content | |
| prompt_key = ( | |
| "user_prompt_b" | |
| if str(content.get("user_prompt_variant", "a")).lower() == "b" | |
| else "user_prompt" | |
| ) | |
| content[prompt_key] = str(prompt) | |
| return output_info | |
| # Deliberately do not subclass MultiTrackPromptEnhancer. ComfyUI V3 caches | |
| # RETURN_TYPES and related schema-derived attributes on the first class that | |
| # accesses them; a subclass would inherit the enhancer's STRING outputs instead | |
| # of re-evaluating this node's TRACKS_INFO output. | |
| class MultiTrackPromptEnhanceToProject(io.ComfyNode): | |
| def define_schema(cls) -> io.Schema: | |
| return io.Schema( | |
| node_id="easy multitrackPromptEnhanceToProject", | |
| display_name="MultiTrack Prompt Enhance To Project", | |
| category=CATEGORY_MULTITRACK, | |
| description=( | |
| "Enhance every task prompt in TRACKS_INFO with its task-range media, " | |
| "write each result back to its task segment, and return the updated " | |
| "project data plus the ordered prompt list." | |
| ), | |
| is_input_list=True, | |
| not_idempotent=True, | |
| enable_expand=True, | |
| inputs=[ | |
| TYPE_TRACKS_INFO.Input( | |
| "tracks_info", | |
| tooltip="Multitrack project data whose task prompts will be enhanced.", | |
| ), | |
| TYPE_LLAMACPP_MODEL.Input( | |
| "llama_model", | |
| optional=True, | |
| lazy=True, | |
| raw_link=True, | |
| tooltip=( | |
| "Local llama.cpp model input; evaluated only when the local " | |
| "provider is selected. Requires llama_cpp_instruct_adv from " | |
| f"{LLAMA_CPP_INSTALL_URL}." | |
| ), | |
| ), | |
| io.DynamicCombo.Input( | |
| "model", | |
| options=MultiTrackPromptEnhancer._model_options( | |
| force_synchronous=True, | |
| ), | |
| tooltip="Provider and model used to enhance every task prompt.", | |
| ), | |
| io.Int.Input( | |
| "seed", | |
| default=0, | |
| min=0, | |
| max=0xFFFFFFFFFFFFFFFF, | |
| control_after_generate=True, | |
| tooltip="ComfyUI generation seed used for every task segment.", | |
| ), | |
| io.Boolean.Input( | |
| "enabled", | |
| default=True, | |
| tooltip=( | |
| "Enhance and replace every task prompt when enabled; otherwise " | |
| "return an unchanged project copy and its current prompt list." | |
| ), | |
| ), | |
| TYPE_PROMPT_ENHANCER_ACCOUNT.Input( | |
| "api_account", | |
| tooltip=( | |
| "Provider balance and API key management. This widget is for " | |
| "account status only and does not affect prompt enhancement." | |
| ), | |
| ), | |
| ], | |
| outputs=[ | |
| TYPE_TRACKS_INFO.Output( | |
| "TRACKS_INFO", | |
| tooltip="Project data with enhanced prompts written into task segments.", | |
| ), | |
| io.AnyType.Output( | |
| "PROMPTS", | |
| tooltip=( | |
| "Enhanced prompts passed as one complete list in chronological " | |
| "task-segment order." | |
| ), | |
| ), | |
| ], | |
| ) | |
| def execute( | |
| cls, | |
| tracks_info: list | dict | str, | |
| llama_model: list | object | None = None, | |
| model: list[dict] | dict | None = None, | |
| seed: list[int] | int | None = None, | |
| enabled: list[bool] | bool | None = None, | |
| api_account: list[str] | str | None = None, | |
| ) -> io.NodeOutput: | |
| info = _parse_track_data(_unwrap_list_scalar(tracks_info, {})) | |
| tasks = _multitrack_task_segments(info) | |
| current_prompts = [ | |
| _multitrack_prompt_value( | |
| task.get("content", {}) | |
| if isinstance(task.get("content"), dict) | |
| else {} | |
| ) | |
| for task in tasks | |
| ] | |
| if not bool(_unwrap_list_scalar(enabled, True)) or not tasks: | |
| return io.NodeOutput( | |
| _apply_multitrack_enhanced_prompts(info, current_prompts), | |
| current_prompts, | |
| ) | |
| model_config = _unwrap_list_scalar(model, {}) | |
| if not isinstance(model_config, dict): | |
| raise TypeError("model must be a DynamicCombo configuration dictionary.") | |
| synchronous_model_config = dict(model_config) | |
| # Project output must have final text available before it can update TRACKS_INFO. | |
| synchronous_model_config["return_async"] = False | |
| selected_model = str( | |
| _unwrap_list_scalar( | |
| synchronous_model_config.get("model"), | |
| MINIMAX_MODEL, | |
| ) | |
| ) | |
| configured_force_offload = bool( | |
| _unwrap_list_scalar( | |
| synchronous_model_config.get("force_offload"), | |
| True, | |
| ) | |
| ) | |
| selected_seed = int(_unwrap_list_scalar(seed, 0)) | |
| enhanced_prompts: list[object] = [] | |
| expanded_graph: dict[str, dict] = {} | |
| segment_progress = ProgressBar(len(tasks)) if len(tasks) > 1 else None | |
| if segment_progress is not None: | |
| segment_progress.update_absolute(0, len(tasks)) | |
| task_media_info = info | |
| if info.get("task_markers"): | |
| # This node updates task segments rather than marker-defined task ranges. | |
| # Ignore markers while reusing MultiTrackTaskOutput's media resolver so | |
| # task_index remains aligned with the sorted segment list above. | |
| task_media_info = dict(info) | |
| task_media_info["task_markers"] = [] | |
| for task_index in range(len(tasks)): | |
| task_output = MultiTrackTaskOutput.execute( | |
| tracks_info=task_media_info, | |
| task_index=task_index, | |
| prompt_format="api", | |
| ) | |
| system_prompt = task_output[0] | |
| user_prompt = task_output[1] | |
| task_type = task_output[2] | |
| length = task_output[3] | |
| selected_images = task_output[4] | |
| selected_audio = task_output[5] | |
| selected_video = task_output[6] | |
| segment_model_config = dict(synchronous_model_config) | |
| if selected_model == LLAMACPP_MODEL and len(tasks) > 1: | |
| segment_model_config["force_offload"] = ( | |
| configured_force_offload and task_index == len(tasks) - 1 | |
| ) | |
| result = MultiTrackPromptEnhancer.execute( | |
| system_prompt=[system_prompt], | |
| user_prompt=[user_prompt], | |
| type=[task_type], | |
| length=[length], | |
| images=selected_images, | |
| audio=selected_audio, | |
| video=selected_video, | |
| llama_model=llama_model, | |
| model=[segment_model_config], | |
| seed=[selected_seed], | |
| enabled=[True], | |
| api_account=api_account, | |
| ) | |
| enhanced_prompts.append(result[0]) | |
| if result.expand: | |
| expanded_graph.update(result.expand) | |
| if segment_progress is not None: | |
| segment_progress.update_absolute(task_index + 1, len(tasks)) | |
| if expanded_graph: | |
| graph = GraphBuilder() | |
| apply_inputs: dict[str, object] = { | |
| "tracks_info": info, | |
| "prompt_count": len(enhanced_prompts), | |
| } | |
| apply_inputs.update( | |
| { | |
| f"prompt_{index}": prompt | |
| for index, prompt in enumerate(enhanced_prompts) | |
| } | |
| ) | |
| apply_node = graph.node( | |
| MULTITRACK_PROMPT_PROJECT_APPLY_NODE_ID, | |
| id="multitrack_prompt_project_apply", | |
| **apply_inputs, | |
| ) | |
| expanded_graph.update(graph.finalize()) | |
| return io.NodeOutput( | |
| apply_node.out(0), | |
| apply_node.out(1), | |
| expand=expanded_graph, | |
| ) | |
| prompt_texts = [str(prompt) for prompt in enhanced_prompts] | |
| return io.NodeOutput( | |
| _apply_multitrack_enhanced_prompts(info, prompt_texts), | |
| prompt_texts, | |
| ) | |
| class MultiTrackPromptEnhanceToProjectApply(io.ComfyNode): | |
| """Apply dynamically expanded local llama.cpp results to project task prompts.""" | |
| def define_schema(cls) -> io.Schema: | |
| return io.Schema( | |
| node_id=MULTITRACK_PROMPT_PROJECT_APPLY_NODE_ID, | |
| display_name="MultiTrack Prompt Enhance To Project Apply", | |
| category=CATEGORY_MULTITRACK+"/internal", | |
| is_dev_only=True, | |
| accept_all_inputs=True, | |
| inputs=[ | |
| TYPE_TRACKS_INFO.Input("tracks_info"), | |
| io.Int.Input("prompt_count", min=0), | |
| ], | |
| outputs=[ | |
| TYPE_TRACKS_INFO.Output("TRACKS_INFO"), | |
| io.AnyType.Output("PROMPTS"), | |
| ], | |
| ) | |
| def execute( | |
| cls, | |
| tracks_info: dict, | |
| prompt_count: int, | |
| **kwargs: object, | |
| ) -> io.NodeOutput: | |
| prompts = [str(kwargs.get(f"prompt_{index}", "")) for index in range(prompt_count)] | |
| return io.NodeOutput( | |
| _apply_multitrack_enhanced_prompts(tracks_info, prompts), | |
| prompts, | |
| ) | |
| class MultiTrackPromptEnhancerImageListBridge(io.ComfyNode): | |
| def define_schema(cls) -> io.Schema: | |
| return io.Schema( | |
| node_id=LLAMA_CPP_IMAGE_LIST_BRIDGE_NODE_ID, | |
| display_name="MultiTrack Prompt Enhancer Image List Bridge", | |
| category=CATEGORY_MULTITRACK+"/internal", | |
| description=( | |
| "Internal bridge that preserves list-style image inputs for the local " | |
| "llama.cpp expansion graph." | |
| ), | |
| is_dev_only=True, | |
| inputs=[ | |
| io.AnyType.Input( | |
| "images", | |
| tooltip="Image values forwarded by MultiTrack Prompt Enhancer.", | |
| ), | |
| io.Int.Input( | |
| "max_size", | |
| default=512, | |
| min=128, | |
| max=PROMPT_ENHANCER_MAX_TOKENS[LLAMACPP_MODEL][1], | |
| step=64, | |
| tooltip=( | |
| "Maximum image long-edge size used before local llama.cpp " | |
| "vision inference." | |
| ), | |
| ), | |
| io.Combo.Input( | |
| "inference_mode", | |
| options=["one by one", "images", "video"], | |
| default="one by one", | |
| tooltip=( | |
| "Preserves separate references and distributes the visual-size " | |
| "budget across images or sampled video frames." | |
| ), | |
| ), | |
| ], | |
| outputs=[ | |
| io.Image.Output( | |
| "IMAGES", | |
| is_output_list=True, | |
| tooltip="Images forwarded as a ComfyUI output list.", | |
| ) | |
| ], | |
| ) | |
| def execute( | |
| cls, | |
| images: list | torch.Tensor | None = None, | |
| max_size: int = 512, | |
| inference_mode: str = "one by one", | |
| ) -> io.NodeOutput: | |
| image_inputs = _as_list_input(images) | |
| safe_max_size = min( | |
| max_size, | |
| PROMPT_ENHANCER_MAX_TOKENS[LLAMACPP_MODEL][1], | |
| ) | |
| total_frame_count = sum( | |
| image.shape[0] | |
| if isinstance(image, torch.Tensor) and image.ndim == 4 | |
| else 1 | |
| for image in image_inputs | |
| ) | |
| resized_images: list[torch.Tensor] = [] | |
| for image in image_inputs: | |
| if not isinstance(image, torch.Tensor): | |
| raise TypeError("llama.cpp image inputs must be torch.Tensor values.") | |
| if image.ndim not in (3, 4): | |
| raise ValueError( | |
| "llama.cpp image inputs must have shape [H,W,C] or [B,H,W,C]." | |
| ) | |
| batched_image = image.unsqueeze(0) if image.ndim == 3 else image | |
| if inference_mode == "images": | |
| budget_frame_count = total_frame_count | |
| elif inference_mode == "video": | |
| budget_frame_count = min(batched_image.shape[0], 24) | |
| else: | |
| budget_frame_count = batched_image.shape[0] | |
| effective_max_size = max( | |
| 128, | |
| int(safe_max_size / math.sqrt(max(1, budget_frame_count))), | |
| ) | |
| height, width = batched_image.shape[1:3] | |
| long_edge = max(height, width) | |
| if long_edge > effective_max_size: | |
| scale = effective_max_size / long_edge | |
| target_width = max(1, round(width * scale)) | |
| target_height = max(1, round(height * scale)) | |
| batched_image = F.interpolate( | |
| batched_image.movedim(-1, 1), | |
| size=(target_height, target_width), | |
| mode="bilinear", | |
| align_corners=False, | |
| ).movedim(1, -1) | |
| resized_images.append( | |
| batched_image.squeeze(0) if image.ndim == 3 else batched_image | |
| ) | |
| return io.NodeOutput(resized_images) | |
| TYPE_MAP = {"flf": 0, "fmlf": 1, "ref": 2} | |
| class EasyMinimaxH3AudioLock(io.ComfyNode): | |
| def define_schema(cls) -> io.Schema: | |
| return io.Schema( | |
| node_id="easy minimaxH3AudioLock", | |
| display_name="Moxie Audio Lock", | |
| category=CATEGORY_AUDIO, | |
| description=( | |
| "Lock or remix supplied audio into a Moxie joint AV latent. " | |
| "The H3 per-stream noise mask controls how much audio is preserved." | |
| ), | |
| inputs=[ | |
| io.Latent.Input( | |
| "latent", tooltip="Moxie joint audio/video latent." | |
| ), | |
| io.Vae.Input("audio_vae", tooltip="Moxie audio VAE."), | |
| io.Audio.Input( | |
| "audio", | |
| optional=True, | |
| tooltip=( | |
| "Audio to lock into the H3 latent. Missing audio leaves " | |
| "the generated audio latent unchanged." | |
| ), | |
| ), | |
| io.Float.Input( | |
| "remix_strength", | |
| default=1.0, | |
| min=0.0, | |
| max=1.0, | |
| step=0.01, | |
| tooltip="0 fully regenerates audio; 1 hard-locks the supplied audio.", | |
| ), | |
| io.Combo.Input( | |
| "short_audio_mode", | |
| options=["silence", "loop"], | |
| default="silence", | |
| tooltip="Pad short audio with silence or loop it before encoding.", | |
| ), | |
| io.Int.Input( | |
| "prepend_frames", | |
| default=0, | |
| min=0, | |
| max=3600, | |
| tooltip=( | |
| "Silent video-frame duration inserted before locked audio. " | |
| "Used to align audio after context-prefix trimming." | |
| ), | |
| ), | |
| io.Float.Input( | |
| "frame_rate", | |
| default=24.0, | |
| min=1.0, | |
| max=240.0, | |
| step=0.01, | |
| ), | |
| ], | |
| outputs=[io.Latent.Output("latent")], | |
| ) | |
| def execute( | |
| cls, | |
| latent: dict, | |
| audio_vae: object, | |
| audio: dict | None = None, | |
| remix_strength: float = 1.0, | |
| short_audio_mode: str = "silence", | |
| prepend_frames: int = 0, | |
| frame_rate: float = 24.0, | |
| ) -> io.NodeOutput: | |
| selected_latent = latent | |
| if audio is None: | |
| return io.NodeOutput(selected_latent) | |
| selected_audio_vae = audio_vae | |
| selected_strength = float(remix_strength) | |
| selected_short_audio_mode = str(short_audio_mode) | |
| video_latent, base_audio_latent = _split_h3_av_latent(selected_latent) | |
| if selected_short_audio_mode not in {"silence", "loop"}: | |
| raise ValueError( | |
| f"Unsupported short_audio_mode: {selected_short_audio_mode!r}." | |
| ) | |
| waveform = audio.get("waveform") | |
| sample_rate = audio.get("sample_rate") | |
| if not isinstance(waveform, torch.Tensor) or not isinstance(sample_rate, int): | |
| raise ValueError( | |
| "AUDIO input must contain a tensor waveform and integer sample_rate." | |
| ) | |
| if waveform.ndim != 3 or waveform.shape[0] < 1: | |
| raise ValueError("AUDIO waveform must have shape [B, C, T] with B >= 1.") | |
| selected_frame_rate = float(frame_rate) | |
| if not math.isfinite(selected_frame_rate) or selected_frame_rate <= 0: | |
| raise ValueError("frame_rate must be a positive finite number.") | |
| target_length = int(base_audio_latent.shape[-1]) | |
| vae_sample_rate = int( | |
| getattr(selected_audio_vae, "audio_sample_rate", 32000) | |
| ) | |
| waveform = waveform[:1] | |
| if sample_rate != vae_sample_rate: | |
| try: | |
| import torchaudio | |
| except ImportError as error: | |
| raise RuntimeError( | |
| "torchaudio is required to resample Moxie lock audio." | |
| ) from error | |
| waveform = torchaudio.functional.resample( | |
| waveform, sample_rate, vae_sample_rate | |
| ) | |
| prepend_samples = max( | |
| 0, | |
| round(int(prepend_frames) / selected_frame_rate * vae_sample_rate), | |
| ) | |
| if prepend_samples > 0: | |
| waveform = F.pad(waveform, (prepend_samples, 0), value=0.0) | |
| target_samples = max( | |
| 1, round(target_length / H3_AUDIO_LATENT_FPS * vae_sample_rate) | |
| ) | |
| waveform = _fit_h3_audio_waveform( | |
| waveform, target_samples, selected_short_audio_mode | |
| ) | |
| try: | |
| encoded = selected_audio_vae.encode(waveform.movedim(1, -1)) | |
| except (AttributeError, RuntimeError, TypeError, ValueError) as error: | |
| raise RuntimeError(f"Failed to encode Moxie lock audio: {error}") from error | |
| if not isinstance(encoded, torch.Tensor) or encoded.ndim != 4: | |
| raise ValueError("Moxie audio VAE must return a 4D tensor.") | |
| encoded = _fit_h3_encoded_audio(encoded, target_length) | |
| if encoded.shape[0] != 1 or encoded.shape[1] != 32 or encoded.shape[2] != 2: | |
| raise ValueError( | |
| f"Unexpected H3 audio VAE output shape {tuple(encoded.shape)}; " | |
| "expected [1, 32, 2, T]." | |
| ) | |
| encoded = encoded.to( | |
| device=base_audio_latent.device, | |
| dtype=base_audio_latent.dtype, | |
| ).contiguous() | |
| strength = max(0.0, min(1.0, selected_strength)) | |
| clean_audio = base_audio_latent if strength == 0.0 else encoded | |
| old_video_mask, _ = _split_h3_noise_mask(selected_latent) | |
| video_mask = ( | |
| torch.ones_like(video_latent, dtype=torch.float32) | |
| if old_video_mask is None | |
| else old_video_mask.to( | |
| device=video_latent.device, dtype=torch.float32 | |
| ).contiguous() | |
| ) | |
| audio_mask = torch.full_like( | |
| clean_audio, | |
| fill_value=1.0 - strength, | |
| dtype=torch.float32, | |
| ) | |
| try: | |
| import comfy.nested_tensor | |
| except ImportError as error: | |
| raise RuntimeError( | |
| "Moxie audio locking requires ComfyUI nested tensor support." | |
| ) from error | |
| output = dict(selected_latent) | |
| output["samples"] = comfy.nested_tensor.NestedTensor((video_latent, clean_audio)) | |
| output["noise_mask"] = comfy.nested_tensor.NestedTensor((video_mask, audio_mask)) | |
| return io.NodeOutput(output) | |
| class TimelineSegmentOutput(io.ComfyNode): | |
| def define_schema(cls): | |
| return io.Schema( | |
| node_id="easy timelineSegmentOutput", | |
| display_name="Timeline Segment Output", | |
| category=CATEGORY_TIMELINE, | |
| description="Output data for a specific segment from the timeline.", | |
| inputs=[ | |
| TYPE_TIMELINE_INFO.Input("timeline_info"), | |
| io.Combo.Input( | |
| "prompt_format", | |
| options=PROMPT_FORMAT_OPTIONS, | |
| default="default", | |
| tooltip="Choose prompt format. promptRelay formats prompts with frame ranges.", | |
| ), | |
| io.Image.Input("images", optional=True), | |
| io.Audio.Input("audio", optional=True), | |
| io.Int.Input("segment_index", default=0, min=0), | |
| ], | |
| outputs=[ | |
| io.String.Output("PROMPT"), | |
| io.Int.Output("TYPE"), | |
| io.Boolean.Output("NO_IMAGES"), | |
| io.String.Output("IMAGE_INDEXES"), | |
| io.Int.Output("LENGTH"), | |
| io.Image.Output("IMAGES"), | |
| io.Audio.Output("AUDIO"), | |
| ], | |
| ) | |
| def execute( | |
| cls, | |
| timeline_info: str | dict, | |
| prompt_format: str, | |
| segment_index: int, | |
| images: 'torch.Tensor | None' = None, | |
| audio: dict | None = None, | |
| ) -> io.NodeOutput: | |
| if isinstance(timeline_info, str): | |
| try: | |
| info = json.loads(timeline_info) | |
| except json.JSONDecodeError: | |
| info = {} | |
| else: | |
| info = dict(timeline_info) if timeline_info else {} | |
| segments: list[dict] = info.get("segments", []) | |
| height = info.get("height", 960) | |
| width = info.get("width", 544) | |
| # Clamp index to valid range | |
| segment_index = max(0, min(segment_index, len(segments) - 1)) | |
| seg = segments[segment_index] if segments else {} | |
| seg_images = seg.get("images", []) | |
| start_frame = seg.get("start_frame", 0) | |
| end_frame = seg.get("end_frame", 0) | |
| no_images = len(seg_images) == 0 | |
| seg_type_str = seg.get("type", "flf") | |
| seg_type = TYPE_MAP.get(seg_type_str, 0) | |
| raw_prompt = seg.get("prompt", "") or "" | |
| if prompt_format == "promptRelay" and raw_prompt.strip(): | |
| parts = [p.strip() for p in raw_prompt.split("|") if p.strip()] | |
| prompt_parts: list[str] = [] | |
| for i, p in enumerate(parts): | |
| if i < len(seg_images): | |
| img = seg_images[i] | |
| img_start = img.get("start_frame") | |
| img_end = img.get("end_frame") | |
| if img_start is not None and img_end is not None: | |
| prompt_parts.append(f"{p} [{int(img_start)}-{int(img_end)}]") | |
| prompt = " | ".join(prompt_parts) | |
| else: | |
| prompt = raw_prompt.split('|') if len(seg_images) == 1 and seg_type <= 1 and "|" in raw_prompt else raw_prompt | |
| audio_segments = info.get("audio", {}).get("segments", []) | |
| frame_rate = info.get("frame_rate", 30) | |
| # Calculate segment length (frame count) | |
| if seg_images: | |
| duration_frames = max(0, end_frame - start_frame) | |
| segment_length = duration_frames + 1 | |
| elif segment_index < len(audio_segments): | |
| duration_frames = max( | |
| 0.0, | |
| float(audio_segments[segment_index].get("duration", 0.0)) * frame_rate, | |
| ) | |
| segment_length = int(duration_frames) | |
| else: | |
| duration_frames = None | |
| segment_length = 0 | |
| if info.get("format") == "MiniMax" and duration_frames is not None: | |
| segment_length = _video_frame_count_from_duration( | |
| duration_frames, | |
| frame_rate, | |
| "MiniMax", | |
| ) | |
| # Output images from segment (based on images array in segment) | |
| num_seg_images = len(seg_images) | |
| if images is not None and isinstance(images, torch.Tensor) and num_seg_images > 0: | |
| # Calculate offset: sum of images in all previous segments | |
| offset = sum(len(segments[i].get("images", [])) for i in range(segment_index)) | |
| if offset + num_seg_images <= images.shape[0]: | |
| images_out = images[offset:offset + num_seg_images] | |
| else: | |
| images_out = images[offset:] | |
| images_indexes_str = ",".join(str(int(img.get("start_frame", 0))) for img in seg_images) | |
| else: | |
| images_out = torch.zeros(1, height, width, 3) | |
| images_indexes_str = "" | |
| # Output audio from segment (trimmed by segment index) | |
| if audio is not None and isinstance(audio, dict): | |
| waveform = audio.get("waveform") | |
| sample_rate = audio.get("sample_rate", 44100) | |
| if waveform is not None and isinstance(waveform, torch.Tensor): | |
| if segment_index < len(audio_segments): | |
| seg_audio = audio_segments[segment_index] | |
| audio_out = trim_audio( | |
| {"waveform": waveform, "sample_rate": sample_rate}, | |
| seg_audio["start_sec"], | |
| seg_audio["duration"], | |
| ) | |
| else: | |
| audio_out = {"waveform": waveform, "sample_rate": sample_rate} | |
| else: | |
| audio_out = {"waveform": None, "sample_rate": sample_rate} | |
| else: | |
| audio_out = {"waveform": None, "sample_rate": 44100} | |
| return io.NodeOutput( | |
| prompt, | |
| seg_type, | |
| no_images, | |
| images_indexes_str, | |
| segment_length, | |
| images_out, | |
| audio_out, | |
| ) | |
| class TimelineSegmentCount(io.ComfyNode): | |
| def define_schema(cls): | |
| return io.Schema( | |
| node_id="easy timelineSegmentCount", | |
| display_name="Timeline Segment Count", | |
| category=CATEGORY_TIMELINE, | |
| description="Output the total number of segments in the timeline.", | |
| inputs=[ | |
| TYPE_TIMELINE_INFO.Input("timeline_info"), | |
| ], | |
| outputs=[ | |
| io.Int.Output("COUNT"), | |
| ], | |
| ) | |
| def execute(cls, timeline_info: str | dict) -> io.NodeOutput: | |
| if isinstance(timeline_info, str): | |
| try: | |
| info = json.loads(timeline_info) | |
| except json.JSONDecodeError: | |
| info = {} | |
| else: | |
| info = dict(timeline_info) if timeline_info else {} | |
| count: int = len(info.get("segments", [])) | |
| return io.NodeOutput(count) | |