From de6a1dbd7a5a76ba3c30ea48f56a5ada23ad764b Mon Sep 17 00:00:00 2001 From: Mick Date: Sun, 23 Aug 2026 21:51:25 +0800 Subject: [PATCH] [diffusion] feat: support hybrid conditioning for minimax h3 (#36080) --- .../minimax_h3/canvas.py | 20 ++-- .../minimax_h3/packed_sequence.py | 48 +++++++-- .../minimax_h3/request_validation.py | 38 +++++-- .../minimax_h3/resolved_plan.py | 28 +++-- .../minimax_h3/stages/denoising.py | 85 ++++++++------- .../minimax_h3/stages/text_encoding.py | 9 +- .../minimax_h3/stages/visual_encoding.py | 101 +++++++++--------- .../minimax_h3/task_profiles.py | 20 +++- .../test/unit/test_minimax_h3_admission.py | 46 +++++++- .../unit/test_minimax_h3_packed_sequence.py | 28 +++++ 10 files changed, 293 insertions(+), 130 deletions(-) diff --git a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/canvas.py b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/canvas.py index df4453d14..802718945 100644 --- a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/canvas.py +++ b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/canvas.py @@ -105,40 +105,42 @@ def _keyframe_canvas_size(shape: Any) -> tuple[int, int]: geometry = str(shape["geometry"]) if geometry != "resolved_v2": raise ValueError( - "fl2va keyframe preparation requires pre-queue resolved_v2 " + "keyframe preparation requires pre-queue resolved_v2 " f"geometry, got {geometry!r}" ) return int(shape["width"]), int(shape["height"]) def _validate_keyframe_materials(plan: Any, keyframes: list[Any]) -> tuple[int, ...]: - if str(plan.task) != "fl2va": - raise ValueError("keyframe target-canvas materials require plan.task='fl2va'") + if str(plan.task) not in {"fl2va", "ref2va"}: + raise ValueError( + "keyframe target-canvas materials require plan.task='fl2va' or 'ref2va'" + ) semantic_indices = tuple(material.frame_index for material in keyframes) if semantic_indices not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES: raise ValueError( - "fl2va keyframes must use one of the ordered frame_index signatures " + "MiniMax H3 keyframes must use one of the ordered frame_index signatures " f"{MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES!r}, got {semantic_indices!r}" ) frame_count = plan.shape.get("frame_count") if isinstance(frame_count, bool) or not isinstance(frame_count, int): - raise ValueError("fl2va keyframe preparation requires an integer frame_count") + raise ValueError("keyframe preparation requires an integer frame_count") if frame_count <= 1: - raise ValueError("fl2va keyframe preparation requires frame_count > 1") + raise ValueError("keyframe preparation requires frame_count > 1") expected_pixels = tuple( frame_count - 1 if index == -1 else index for index in semantic_indices ) resolved_pixels = tuple(material.resolved_frame_index for material in keyframes) if resolved_pixels != expected_pixels: raise ValueError( - "fl2va keyframe resolved_frame_index values disagree with semantic " + "keyframe resolved_frame_index values disagree with semantic " f"anchors: expected {expected_pixels!r}, got {resolved_pixels!r}" ) return semantic_indices def minimax_h3_prepared_keyframes(batch: Any, plan: Any) -> dict[str, Any]: - """Resolve + prepare one or two fl2va keyframes once per request. + """Resolve + prepare one or two first/last keyframes once per request. The target canvas is shared across keyframes and must already be frozen by the pre-queue probe/resolve hook. @@ -154,7 +156,7 @@ def minimax_h3_prepared_keyframes(batch: Any, plan: Any) -> dict[str, Any]: cached_images = cached.get("images") or () if cached_indices != semantic_indices or len(cached_images) != len(keyframes): raise ValueError( - "cached fl2va keyframe preparation disagrees with the resolved plan" + "cached keyframe preparation disagrees with the resolved plan" ) return cached diff --git a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/packed_sequence.py b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/packed_sequence.py index d6f1de66f..45a27926f 100644 --- a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/packed_sequence.py +++ b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/packed_sequence.py @@ -279,6 +279,8 @@ def minimax_h3_packed_sequence_ref2va_blocks( latent_w: int, audio_t: int, ref_blocks: Sequence[Mapping[str, object]], + keyframe_frame_indices: list[int] | tuple[int, ...] | None = None, + frame_count: int | None = None, audio_channel: int = 2, seq_len: int | None = None, ) -> dict[str, Any]: @@ -290,10 +292,12 @@ def minimax_h3_packed_sequence_ref2va_blocks( - ``{"kind": "video"|"video_audio", "ref_audio_t": T, "latent_t": RT, "latent_h": RH, "latent_w": RW}`` - Video-bearing blocks pack their audio rows immediately before their video - rows; both share the same temporal origin and advance by the longer of the - audio and video spans. Standalone audio advances the target origin by its - own T, and image blocks advance it by one integer slot. + Optional first/last keyframes are packed immediately after text, matching + Comfy's hybrid Ref2VA + guide layout. Video-bearing blocks pack their audio + rows immediately before their video rows; both share the same temporal + origin and advance by the longer of the audio and video spans. Standalone + audio advances the target origin by its own T, and image blocks advance it + by one integer slot. """ if not isinstance(ref_blocks, Sequence) or isinstance(ref_blocks, (str, bytes)): raise ValueError("ref_blocks must be a sequence") @@ -345,10 +349,19 @@ def minimax_h3_packed_sequence_ref2va_blocks( ph, pw = latent_h // _PATCH_H, latent_w // _PATCH_W frame_rows = ph * pw + keyframe_indices = _keyframe_cond_frame_indices( + include_keyframe_cond=keyframe_frame_indices is not None, + keyframe_frame_indices=keyframe_frame_indices, + ) + resolved_keyframe_indices = _resolve_keyframe_frame_indices( + keyframe_indices, + frame_count=frame_count, + ) + keyframe_rows = len(keyframe_indices) * frame_rows video_rows = latent_t * frame_rows audio_rows = audio_t * audio_channel ref_rows = ref_visual_rows + ref_audio_rows - used = text_len + ref_rows + audio_rows + video_rows + used = text_len + keyframe_rows + ref_rows + audio_rows + video_rows if seq_len is None: seq_len = ( (used + MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT - 1) @@ -359,7 +372,8 @@ def minimax_h3_packed_sequence_ref2va_blocks( raise ValueError(f"seq_len {seq_len} < used rows {used}") text_sl = slice(0, text_len) - cursor = text_len + keyframe_sl = slice(text_len, text_len + keyframe_rows) + cursor = keyframe_sl.stop block_slices: list[dict[str, object]] = [] for item in parsed: kind = str(item["kind"]) @@ -466,13 +480,31 @@ def minimax_h3_packed_sequence_ref2va_blocks( video_g[:, :, 0] = _video_t_grid(latent_t, t_cursor)[:, None] video_g[:, :, 1:] = target_frame[None] + for block_index, pixel_index in enumerate(resolved_keyframe_indices): + sl = slice( + keyframe_sl.start + block_index * frame_rows, + keyframe_sl.start + (block_index + 1) * frame_rows, + ) + if pixel_index == 0: + cond_t = t_cursor + elif frame_count is not None and pixel_index == frame_count - 1: + cond_t = t_cursor + _temporal_position_span(latent_t) - _FRAME_RESCALE + else: + raise ValueError( + "hybrid ref2va layout only supports first/last keyframe anchors, " + f"got resolved frame index {pixel_index}" + ) + g[sl, 0] = cond_t + g[sl, 1:] = target_frame + + keyframe_img_pos = _range_for_slice(keyframe_sl) target_img_pos = _range_for_slice(video_sl) target_audio_pos = _range_for_slice(audio_sl) - img_pos = _cat_ranges(ref_img_pos_parts + [target_img_pos]) + img_pos = _cat_ranges([keyframe_img_pos] + ref_img_pos_parts + [target_img_pos]) audio_pos = _cat_ranges(ref_audio_pos_parts + [target_audio_pos]) update_mask = torch.zeros(img_pos.shape[0], dtype=torch.bool) - update_mask[ref_visual_rows:] = True + update_mask[keyframe_rows + ref_visual_rows :] = True audio_update_mask = torch.zeros(audio_pos.shape[0], dtype=torch.bool) audio_update_mask[ref_audio_rows:] = True text_pos = torch.arange(0, text_len) diff --git a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/request_validation.py b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/request_validation.py index a49ac3384..d18f0d7f2 100644 --- a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/request_validation.py +++ b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/request_validation.py @@ -248,16 +248,31 @@ def _validate_conditions( return normalized -def _validate_fl2va_conditions(conditions: Sequence[Mapping[str, Any]]) -> None: - """Enforce the public FL contract after per-entry schema validation.""" +def _validate_keyframe_conditions( + conditions: Sequence[Mapping[str, Any]], *, task: str +) -> None: + """Enforce the shared first/last-frame contract after schema validation.""" - frame_indices = tuple(condition.get("frame_index") for condition in conditions) + keyframes = [ + condition + for condition in conditions + if condition["role"] == MINIMAX_H3_CONDITION_ROLE_KEYFRAME + ] + frame_indices = tuple(condition.get("frame_index") for condition in keyframes) if frame_indices not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES: raise ValueError( - "conditions for task 'fl2va' must be one or two ordered " + f"conditions for task {task!r} must include one or two ordered " "image/keyframe entries with frame_index [0], [-1], or [0, -1], " f"got {list(frame_indices)!r}" ) + if task == MINIMAX_H3_TASK_REF2VA and not any( + condition["role"] == MINIMAX_H3_CONDITION_ROLE_REFERENCE + for condition in conditions + ): + raise ValueError( + "ref2va keyframes require at least one reference condition; " + "use task 'fl2va' for keyframe-only generation" + ) def minimax_h3_validate_canonical_request( @@ -300,11 +315,16 @@ def minimax_h3_validate_canonical_request( frame_count=requested_frame_count, ) if profile.task == MINIMAX_H3_TASK_FL2VA: - _validate_fl2va_conditions(normalized_conditions) - # ref2va accepts ordered material streams containing any mix of - # image/audio/video/video_audio references. Type admission is handled by - # the task profile; temporal ambiguity is validated later when target - # duration is omitted. + _validate_keyframe_conditions(normalized_conditions, task=profile.task) + elif profile.task == MINIMAX_H3_TASK_REF2VA and any( + condition["role"] == MINIMAX_H3_CONDITION_ROLE_KEYFRAME + for condition in normalized_conditions + ): + _validate_keyframe_conditions(normalized_conditions, task=profile.task) + # ref2va accepts ordered reference streams and, for hybrid checkpoints, + # one first/last keyframe signature. Type admission is handled by the task + # profile; temporal ambiguity is validated later when target duration is + # omitted. if not profile.video_reference_supported: for index, cond in enumerate(normalized_conditions): if cond["type"] in ("video", "video_audio"): diff --git a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/resolved_plan.py b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/resolved_plan.py index 12a88c81f..f15f00246 100644 --- a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/resolved_plan.py +++ b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/resolved_plan.py @@ -276,8 +276,17 @@ def minimax_h3_resolve_plan(canonical: Mapping[str, Any]) -> MiniMaxH3ResolvedPl if key not in canonical: raise ValueError(f"canonical request missing {key!r}") profile = minimax_h3_task_profile(str(canonical["task"])) - if profile.task == "fl2va": - conditions = canonical["conditions"] + conditions = canonical["conditions"] + keyframe_conditions = ( + [ + condition + for condition in conditions + if isinstance(condition, Mapping) and condition.get("role") == "keyframe" + ] + if isinstance(conditions, (list, tuple)) + else [] + ) + if profile.task == "fl2va" or keyframe_conditions: signatures = ( [ ( @@ -285,10 +294,9 @@ def minimax_h3_resolve_plan(canonical: Mapping[str, Any]) -> MiniMaxH3ResolvedPl condition.get("role"), condition.get("frame_index"), ) - for condition in conditions + for condition in keyframe_conditions ] - if isinstance(conditions, (list, tuple)) - and all(isinstance(condition, Mapping) for condition in conditions) + if all(isinstance(condition, Mapping) for condition in keyframe_conditions) else [] ) frame_signature = tuple(signature[2] for signature in signatures) @@ -298,7 +306,8 @@ def minimax_h3_resolve_plan(canonical: Mapping[str, Any]) -> MiniMaxH3ResolvedPl or frame_signature not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES ): raise ValueError( - "fl2va ResolvedPlan requires one or two ordered image/keyframe " + f"{profile.task} ResolvedPlan requires one or two ordered " + "image/keyframe " "conditions with frame_index [0], [-1], or [0, -1], got " f"{signatures!r}" ) @@ -363,10 +372,15 @@ def minimax_h3_resolve_plan(canonical: Mapping[str, Any]) -> MiniMaxH3ResolvedPl if rule.audio_tokenizer_encode: audio_encode.append(index) + qwen_condition_indices = [ + index + for index, condition in enumerate(canonical["conditions"]) + if profile.task != "ref2va" or condition["role"] == "reference" + ] encoders = { "qwen": { "prompt": canonical["prompt"], - "ordered_condition_indices": list(range(len(canonical["conditions"]))), + "ordered_condition_indices": qwen_condition_indices, }, "visual": visual_encode, "audio": audio_encode, diff --git a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/stages/denoising.py b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/stages/denoising.py index 381d257e4..3b7918b83 100644 --- a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/stages/denoising.py +++ b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/stages/denoising.py @@ -76,38 +76,42 @@ def minimax_h3_condition_noise_aug(sampling: Any) -> tuple[float, float]: return float(imgvid_noise_aug), float(audio_noise_aug) -def _validate_fl2va_keyframe_payload(plan: Any, keyframe: Any) -> None: +def _validate_keyframe_payload(plan: Any, keyframe: Any) -> None: """Reject stale/middle/reordered keyframe payloads at the DiT sink.""" task = None if plan is None else str(plan.task) - if task != "fl2va": + if task not in {"fl2va", "ref2va"}: if keyframe is not None: raise ValueError( - "keyframe condition rows are only valid for plan.task='fl2va'" + "keyframe condition rows require plan.task='fl2va' or 'ref2va'" ) return + if keyframe is None: + if task == "fl2va": + raise ValueError("fl2va denoising requires encoded keyframe condition rows") + return if not isinstance(keyframe, Mapping): - raise ValueError("fl2va denoising requires encoded keyframe condition rows") + raise ValueError("encoded keyframe condition rows must be a mapping") semantic_indices = tuple(keyframe.get("semantic_frame_indices") or ()) if semantic_indices not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES: raise ValueError( - "fl2va denoising requires semantic_frame_indices in " + "keyframe denoising requires semantic_frame_indices in " f"{MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES!r}, " f"got {semantic_indices!r}" ) frame_count = keyframe.get("frame_count") if isinstance(frame_count, bool) or not isinstance(frame_count, int): - raise ValueError("fl2va keyframe payload requires an integer frame_count") + raise ValueError("keyframe payload requires an integer frame_count") if frame_count <= 1: - raise ValueError("fl2va keyframe payload frame_count must be greater than one") + raise ValueError("keyframe payload frame_count must be greater than one") pixel_indices = keyframe.get("pixel_frame_indices") expected_pixel_indices = [ frame_count - 1 if index == -1 else index for index in semantic_indices ] if pixel_indices != expected_pixel_indices: raise ValueError( - "fl2va denoising requires pixel_frame_indices resolved from the " + "keyframe denoising requires pixel_frame_indices resolved from the " "semantic anchors, " f"got {pixel_indices!r} for frame_count={frame_count}" ) @@ -119,15 +123,15 @@ def _validate_fl2va_keyframe_payload(plan: Any, keyframe: Any) -> None: or any(not isinstance(entry, Mapping) for entry in entries) ): raise ValueError( - "fl2va denoising requires one encoded keyframe per semantic anchor" + "keyframe denoising requires one encoded keyframe per semantic anchor" ) if [entry.get("frame_index") for entry in entries] != list(semantic_indices): - raise ValueError("fl2va encoded keyframes must remain in semantic anchor order") + raise ValueError("encoded keyframes must remain in semantic anchor order") if [ entry.get("resolved_frame_index") for entry in entries ] != expected_pixel_indices: raise ValueError( - "fl2va encoded keyframes must carry matching resolved_frame_index values" + "encoded keyframes must carry matching resolved_frame_index values" ) latent_h = int(keyframe.get("latent_h") or 0) @@ -142,7 +146,7 @@ def _validate_fl2va_keyframe_payload(plan: Any, keyframe: Any) -> None: ): actual_rows = None if not isinstance(rows, torch.Tensor) else int(rows.shape[0]) raise ValueError( - "fl2va encoded keyframe rows do not match target-canvas blocks: " + "encoded keyframe rows do not match target-canvas blocks: " f"expected={expected_rows}, actual={actual_rows}" ) @@ -155,8 +159,26 @@ def _imgvid_condition_shapes( ) -> list[tuple[int, int, int]]: """Return visual-condition ``(T,H,W)`` in packed anchor-row order.""" + shapes = [] + if isinstance(keyframe, Mapping): + entries = keyframe.get("keyframes") + if isinstance(entries, list) and entries: + shapes.extend( + (1, int(entry["latent_h"]), int(entry["latent_w"])) for entry in entries + ) + else: + latent_h = int(keyframe["latent_h"]) + latent_w = int(keyframe["latent_w"]) + frame_rows = (latent_h // 2) * (latent_w // 2) + rows = keyframe["rows"] + if frame_rows <= 0 or int(rows.shape[0]) % frame_rows: + raise ValueError( + "legacy keyframe rows cannot be split into visual-condition frames" + ) + shapes.extend( + [(1, latent_h, latent_w)] * (int(rows.shape[0]) // frame_rows) + ) if ref2va_blocks is not None: - shapes = [] for block in ref2va_blocks: kind = str(block["kind"]) if kind == "image": @@ -176,23 +198,7 @@ def _imgvid_condition_shapes( # supplied above; reaching here indicates an upstream bug. raise ValueError("ref2va visual-condition shapes require ordered blocks") - if not isinstance(keyframe, Mapping): - return [] - entries = keyframe.get("keyframes") - if isinstance(entries, list) and entries: - return [ - (1, int(entry["latent_h"]), int(entry["latent_w"])) for entry in entries - ] - - latent_h = int(keyframe["latent_h"]) - latent_w = int(keyframe["latent_w"]) - frame_rows = (latent_h // 2) * (latent_w // 2) - rows = keyframe["rows"] - if frame_rows <= 0 or int(rows.shape[0]) % frame_rows: - raise ValueError( - "legacy keyframe rows cannot be split into visual-condition frames" - ) - return [(1, latent_h, latent_w)] * (int(rows.shape[0]) // frame_rows) + return shapes def _ref2va_payload_entry( @@ -246,6 +252,8 @@ def _ref2va_ordered_blocks_and_rows( for material in plan.materials: chain = str(material.material_chain) condition_index = int(material.condition_index) + if chain == "image.target_canvas": + continue if chain == "image.reference_preserve": entry = _ref2va_payload_entry( ref_image, @@ -789,8 +797,7 @@ class _FullLoopContext: def _resolve_full_loop_context(batch: Req) -> _FullLoopContext: """Read/validate extras and the denoise state into a loop context. - Enforces the task-payload exclusivity rules (keyframe vs reference - exclusivity) and cross-checks the resolved latent dims. + Validates task payloads and cross-checks the resolved latent dims. """ from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import ( MINIMAX_H3_DENOISE_STATE_EXTRA_KEY, @@ -819,9 +826,7 @@ def _resolve_full_loop_context(batch: Req) -> _FullLoopContext: or ctx.ref_audio is not None or ctx.ref_video is not None ) - if ctx.is_ref2va and ctx.keyframe is not None: - raise ValueError("keyframe and reference extras are mutually exclusive") - _validate_fl2va_keyframe_payload(ctx.plan, ctx.keyframe) + _validate_keyframe_payload(ctx.plan, ctx.keyframe) ctx.latent_t = int(ctx.state["latent_t"]) ctx.latent_h = int(ctx.state["latent_h"]) @@ -844,7 +849,7 @@ def _assemble_condition_rows(ctx: _FullLoopContext) -> None: "ref2va reference extras require a resolved plan; " "plan-less ref2va requests are unsupported" ) - ctx.ref2va_positive_blocks, ctx.cond_rows, ctx.audio_ref_rows = ( + ctx.ref2va_positive_blocks, ref_rows, ctx.audio_ref_rows = ( _ref2va_ordered_blocks_and_rows( plan=ctx.plan, ref_image=ctx.ref_image, @@ -852,6 +857,12 @@ def _assemble_condition_rows(ctx: _FullLoopContext) -> None: ref_video=ctx.ref_video, ) ) + condition_parts = [] + if ctx.keyframe is not None: + condition_parts.append(ctx.keyframe["rows"]) + if ref_rows is not None: + condition_parts.append(ref_rows) + ctx.cond_rows = _cat_optional(condition_parts) ctx.include_cond = ctx.cond_rows is not None else: ctx.cond_rows = ctx.keyframe["rows"] if ctx.include_cond else None @@ -885,6 +896,8 @@ def _build_packed_layout( latent_w=ctx.latent_w, audio_t=ctx.audio_t, ref_blocks=ctx.ref2va_positive_blocks, + keyframe_frame_indices=ctx.keyframe_frame_indices, + frame_count=ctx.keyframe_frame_count, ) else: packed = minimax_h3_packed_sequence( diff --git a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/stages/text_encoding.py b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/stages/text_encoding.py index 7ac916e21..1af1395ab 100644 --- a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/stages/text_encoding.py +++ b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/minimax_h3/stages/text_encoding.py @@ -245,11 +245,11 @@ class MiniMaxH3TextEncodingStage(TextEncodingStage): keyframes = [ m for m in plan.materials if m.material_chain == "image.target_canvas" ] - if plan.task == "fl2va": + if plan.task in {"fl2va", "ref2va"} and keyframes: frame_indices = tuple(material.frame_index for material in keyframes) if frame_indices not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES: raise ValueError( - "fl2va text encoding requires an ordered keyframe signature " + "MiniMax H3 text encoding requires an ordered keyframe signature " f"in {MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES!r}, got " f"{frame_indices!r}" ) @@ -360,7 +360,8 @@ class MiniMaxH3TextEncodingStage(TextEncodingStage): Per condition in order — image i: ': ' label + vision block (prepared reference image); audio j: '