diff --git a/__init__.py b/__init__.py index 3bccdd5..d87b193 100644 --- a/__init__.py +++ b/__init__.py @@ -3161,3 +3161,10 @@ from .iamccs_moss_frame_output import ( ) NODE_CLASS_MAPPINGS.update(_MOSS_RESULT_CLASSES) NODE_DISPLAY_NAME_MAPPINGS.update(_MOSS_RESULT_NAMES) + +from .iamccs_minimax_h3_open_refine import ( + NODE_CLASS_MAPPINGS as _OPEN_H3_REFINE_CLASSES, + NODE_DISPLAY_NAME_MAPPINGS as _OPEN_H3_REFINE_NAMES, +) +NODE_CLASS_MAPPINGS.update(_OPEN_H3_REFINE_CLASSES) +NODE_DISPLAY_NAME_MAPPINGS.update(_OPEN_H3_REFINE_NAMES) diff --git a/iamccs_h3_continuity_guard.py b/iamccs_h3_continuity_guard.py new file mode 100644 index 0000000..b86b72c --- /dev/null +++ b/iamccs_h3_continuity_guard.py @@ -0,0 +1,174 @@ +# SPDX-FileCopyrightText: 2026 Carmine Cristallo Scalzi (IAMCCS) +# SPDX-License-Identifier: GPL-3.0-or-later +"""Read-only continuation diagnostics. Reports are evidence, not identity scores.""" +from __future__ import annotations + +import hashlib +import json +import logging +import math +import re +import shutil +import uuid +from pathlib import Path + +import numpy as np +import torch +import torch.nn.functional as F + + +def settings_schema(): + # Retired fields keep their positions so existing workflows still load. + retired = "Retired compatibility field. Ignored; no repair or reference injection runs." + return { + "h3_guard_mode": (["off", "monitor", "repair"], {"default": "off", "display_name": "GUARD - MODE", "tooltip": "Read-only Extended AV diagnostics. Legacy repair values resolve to Off with a warning."}), + "h3_guard_reference_memory": ("BOOLEAN", {"default": True, "display_name": "RETIRED - REFERENCE MEMORY", "tooltip": retired}), + "h3_guard_repair_denoise": ("FLOAT", {"default": 0.15, "min": 0.01, "max": 0.35, "step": 0.01, "display_name": "RETIRED - REPAIR DENOISE", "tooltip": retired}), + "h3_guard_repair_steps": ("INT", {"default": 6, "min": 1, "max": 30, "display_name": "RETIRED - REPAIR STEPS", "tooltip": retired}), + "h3_guard_detail_ratio": ("FLOAT", {"default": 0.65, "min": 0.1, "max": 1.0, "step": 0.01, "display_name": "GUARD - DETAIL LOSS THRESHOLD", "tooltip": "Diagnostic heuristic only. Camera, focus and motion changes can cause false alarms."}), + "h3_guard_motion_tolerance": ("FLOAT", {"default": 0.20, "min": 0.01, "max": 1.0, "step": 0.01, "display_name": "RETIRED - MOTION CHANGE LIMIT", "tooltip": retired}), + } + + + +def effective_mode(config): + mode = config.get("mode", "off") + if mode == "repair": + logging.getLogger(__name__).warning( + "Continuity Guard Repair has been retired after unsuccessful validation; using Off. " + "Select Monitor for read-only diagnostics.") + return "off" + if mode not in {"off", "monitor"}: + raise ValueError("Unknown Continuity Guard mode") + return mode + + +def configuration(settings): + mode = effective_mode({"mode": settings.get("h3_guard_mode", "off")}) + threshold = float(settings.get("h3_guard_detail_ratio", 0.65)) + if not math.isfinite(threshold) or not 0 < threshold <= 1: + raise ValueError("Invalid Continuity Guard detail ratio") + return {"mode": mode, "detail_ratio": threshold} + + +def storage(output_directory, render_id): + if not render_id: + raise ValueError("Continuity Guard requires the R49 render identity") + key = hashlib.sha256(str(render_id).encode()).hexdigest()[:24] + path = Path(output_directory) / "IAMCCS" / "continuity_guard" / key + path.mkdir(parents=True, exist_ok=True) + return path + + +def conditioning_identity(render_id, index): + # R49 assigns the definitive, numbered name at the first native checkpoint. + if int(index) == 0: + return uuid.uuid4().hex + if not str(render_id or "").strip(): + raise ValueError("Guard continuation requires its predecessor's checkpoint render identity") + return str(render_id) + + +def bind_checkpoint(output_directory, report, render_id): + """Bind the first segment's provisional measurements to R49's final name.""" + key = report["storage_key"] + if not isinstance(key, str) or not re.fullmatch(r"[0-9a-f]{24}", key): + raise ValueError("Invalid Continuity Guard storage key") + source = Path(output_directory) / "IAMCCS" / "continuity_guard" / key + destination = storage(output_directory, render_id) + if source == destination: + return + if int(report["segment_index"]) != 0: + raise ValueError("Guard continuation measurements do not match the checkpoint render identity") + for name in ("baseline.json", "segment_0000.json"): + path = source / name + if path.is_file(): + shutil.copy2(path, destination / name) + if not (destination / "baseline.json").is_file(): + raise ValueError("Guard first-segment baseline is missing before checkpoint binding") + write_report(destination / "checkpoint_identity.json", {"render_id": str(render_id), "provisional_storage_key": key}) + + +def write_report(path, data): + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(data, indent=2, allow_nan=False), encoding="utf-8") + temporary.replace(path) + + +def analysis_frames(images): + # One analysis size for all segments; at most 48 frames, with uniform timing. + ids = torch.linspace(0, images.shape[0] - 1, min(48, images.shape[0]), device=images.device).round().long() + x = images[ids, ..., :3].movedim(-1, 1).float() + x = F.interpolate(x, size=(144, 256), mode="area") + return (x[:, 0] * 0.2126 + x[:, 1] * 0.7152 + x[:, 2] * 0.0722).clamp(0, 1).cpu().numpy() + + +def measure(images): + import cv2 + gray = analysis_frames(images) + detail = [float(np.mean(np.abs(cv2.Laplacian(f, cv2.CV_32F)))) for f in gray] + light = gray.mean(axis=(1, 2)) + intervals = max(1, images.shape[0] - 1) / max(1, len(gray) - 1) + flow = [] + for a, b in zip(gray[:-1], gray[1:]): + field = cv2.calcOpticalFlowFarneback((a * 255).astype('uint8'), (b * 255).astype('uint8'), None, .5, 3, 15, 3, 5, 1.2, 0) + flow.append(float(np.median(np.linalg.norm(field, axis=-1))) / intervals) + # Native-resolution tiles prevent large textured regions from hiding local blur. + ids = torch.linspace(0, images.shape[0]-1, min(24, images.shape[0]), device=images.device).long() + native = images[ids, ..., :3].float().mean(-1).cpu().numpy() + tiles = [] + for row in range(4): + for col in range(4): + patch = native[:, row*native.shape[1]//4:(row+1)*native.shape[1]//4, + col*native.shape[2]//4:(col+1)*native.shape[2]//4] + values = [float(np.mean(abs(cv2.Laplacian(f, cv2.CV_32F)))) for f in patch] + tiles.append(float(np.median(values))) + return {"local_detail": tiles, "detail": float(np.median(detail)), "luminance": float(np.median(light)), + "flicker": float(np.median(np.abs(np.diff(light, n=2)))) if len(light) > 2 else 0.0, + "motion": float(np.median(flow)) if flow else 0.0, + "frames": int(images.shape[0]), "analysis": "luma_texture_optical_flow_v1", + "limitations": "No semantic identity, intent or lip-sync certification."} + + +def assess(current, baseline, config): + ratio = current["detail"] / max(baseline["detail"], 1e-6) + exposure = abs(current["luminance"] - baseline["luminance"]) + moving = current["motion"] / max(baseline["motion"], 0.02) + uncertain = exposure > 0.15 or moving < 0.4 or moving > 2.5 + local_before = baseline.get("local_detail", []) + local_now = current.get("local_detail", []) + local_ratios = [b/max(a, 1e-6) for a,b in zip(local_before, local_now)] + # 10% local texture loss is a candidate trigger, never proof of degradation. + affected = [i for i,r in enumerate(local_ratios) if r < .90 and local_before[i] > .005] + loss = ratio < config["detail_ratio"] or bool(affected) + return {"detail_ratio": ratio, "local_detail_ratios": local_ratios, "affected_tiles": affected, "exposure_change": exposure, "motion_ratio": moving, + "texture_loss_detected": bool(loss and not uncertain), + "review_recommended": bool(uncertain or loss)} + + +def process(config, sampled, raw_frames, *, directory, index, head_frames, visible_frames): + """Measure decoded frames without modifying video, audio or conditioning.""" + if effective_mode(config) != "monitor": + return sampled, raw_frames, {"status": "off", "repair_attempts": 0} + + visible = raw_frames[head_frames:head_frames + visible_frames] + current = measure(visible) + baseline_path = directory / "baseline.json" + report = {"schema": "iamccs.guard.report.v1", "segment_index": index, + "storage_key": directory.name, + "mode": config["mode"], "before": current, "repair_attempts": 0, + "audio_policy": "Original sampled audio preserved exactly", "status": "monitored"} + if index == 0: + write_report(baseline_path, current) + report["status"] = "baseline_recorded" + elif not baseline_path.is_file(): + raise ValueError("Guard baseline missing. Start this render from its first segment.") + else: + baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + verdict = assess(current, baseline, config) + report["assessment"] = verdict + if verdict["review_recommended"]: + report["status"] = "review_recommended" + write_report(directory / f"segment_{index:04d}.json", report) + sampled = {**sampled, "_iamccs_continuity_guard": report} + return sampled, raw_frames, report diff --git a/iamccs_minimax_h3_atomic_backend.py b/iamccs_minimax_h3_atomic_backend.py index b972ab2..146fe1c 100644 --- a/iamccs_minimax_h3_atomic_backend.py +++ b/iamccs_minimax_h3_atomic_backend.py @@ -1426,7 +1426,15 @@ def _secondary_lora_settings(shotplan: dict[str, Any]) -> dict[str, Any]: return settings if isinstance(settings, dict) else {"enabled": False} -def _apply_secondary_lora(model, shotplan: dict[str, Any], include_vfx=False): +def _secondary_lora_applies_to_segment(shotplan: dict[str, Any], chunk_index: int = 0) -> bool: + """Scope the optional slot to temporal segments, including external resumes.""" + settings = _secondary_lora_settings(shotplan) + return (not bool(settings.get("continuation_only", False)) + or int(chunk_index) > 0 + or _external_continuation_active(shotplan, int(chunk_index))) + + +def _apply_secondary_lora(model, shotplan: dict[str, Any], include_vfx=False, chunk_index=0): """Apply an independent model-only H3 LoRA without changing sampling. The second slot is deliberately separate from Turbo. It never selects a @@ -1447,6 +1455,8 @@ def _apply_secondary_lora(model, shotplan: dict[str, Any], include_vfx=False): return model, f"off (optional H3 LoRA unavailable: {missing})" return model, "off" + if not _secondary_lora_applies_to_segment(shotplan, chunk_index): + return model, "off (segment 1; second LoRA starts at segment 2)" lora_name = str(settings.get("lora_name", "") or "").strip() strength = float(settings.get("strength", 0.0) or 0.0) if not lora_name or abs(strength) <= 1e-8: @@ -1474,7 +1484,8 @@ def _apply_secondary_lora(model, shotplan: dict[str, Any], include_vfx=False): f"Could not apply secondary MiniMax H3 LoRA '{lora_name}'. " "Verify that it is compatible with the selected FL2VA/REF2VA base model." ) from exc - return patched, f"native model-only H3 LoRA {lora_name}@{strength:.2f}" + scope = "continuation-only" if settings.get("continuation_only", False) else "all-segments" + return patched, f"native model-only H3 LoRA {lora_name}@{strength:.2f} ({scope}, segment {int(chunk_index) + 1})" def _turbo_sampler(shotplan: dict[str, Any]): @@ -3070,6 +3081,11 @@ class IAMCCS_MiniMaxH3AtomicConditioningBackend: prefix_frames=control_prefix_frames, ) positive, refmod_report = _apply_h3_refmod(positive, shotplan) + guard_config = shotplan.get("continuity_guard", {}) + if guard_config.get("mode", "off") == "monitor" and shotboard_task == "fl2va_extended_av": + from .iamccs_h3_continuity_guard import conditioning_identity + guard_id = conditioning_identity(render_id, segment_index) + motion_state["continuity_guard_render_id"] = guard_id if legacy_actual_output_bridge: motion_report += "; legacy_actual_output_bridge_ignored" if motion_state["active"] and motion_state.get("strategy") == "reference_motion_carry": @@ -3269,7 +3285,7 @@ class IAMCCS_MiniMaxH3GenerationBackendV2: turbo_model, turbo_report = _apply_turbo_lora(model, shotplan) fasth3_model, fasth3_report = _apply_fasth3_dense_lora(turbo_model, shotplan) pdd_model, pdd_report = _apply_pdd_lora(fasth3_model, shotplan) - lora_model, secondary_lora_report = _apply_secondary_lora(pdd_model, shotplan, include_vfx=True) + lora_model, secondary_lora_report = _apply_secondary_lora(pdd_model, shotplan, include_vfx=True, chunk_index=chunk_index) if turbo_enabled: # Apply the selected Turbo LoRA without silently replacing any # authored sampler, scheduler or AV shift. Presets populate those @@ -3558,6 +3574,16 @@ class IAMCCS_MiniMaxH3GenerationBackendV2: cleanup_report = _clean_vram_before_decode() native_frames = comfy_nodes.VAEDecode().decode(vae=video_vae, samples=sampled)[0] + guard_report = {"status": "off"} + guard_config = shotplan.get("continuity_guard", {}) + if guard_config.get("mode", "off") == "monitor" and shotplan.get("task_mode") == "fl2va_extended_av": + from .iamccs_h3_continuity_guard import process as process_guard, storage as guard_storage + guard_directory = guard_storage(folder_paths.get_output_directory(), motion_state["continuity_guard_render_id"]) + sampled, native_frames, guard_report = process_guard( + guard_config, sampled, native_frames, directory=guard_directory, index=int(chunk_index), + head_frames=int(motion_state.get("trim_frames", 0)), + visible_frames=int(motion_state["export_frames"])) + LOG.info("IAMCCS Continuity Guard | chunk=%d | %s | report=%s", int(chunk_index)+1, guard_report["status"], guard_directory) native_audio = VAEDecodeAudio.execute(vae=audio_vae, samples=sampled)[0] if ( isinstance(sampled, dict) @@ -3977,6 +4003,7 @@ class IAMCCS_MiniMaxH3GenerationBackendV2: f"lipsync_lock={lipsync_lock_report} | " f"pre_sample_cleanup={conditioning_cleanup} | " f"terminal_closure={terminal_closure_report} | " + f"continuity_guard={guard_report['status']} | " f"pre_decode_cleanup={cleanup_report} | native_last_frame=captured | " f"resolution={resolution_contract} | " f"pianosequenza={pianosequenza_report} | " diff --git a/iamccs_minimax_h3_latent_upres_variant.py b/iamccs_minimax_h3_latent_upres_variant.py index 1ee3e47..a6b784c 100644 --- a/iamccs_minimax_h3_latent_upres_variant.py +++ b/iamccs_minimax_h3_latent_upres_variant.py @@ -214,7 +214,7 @@ class IAMCCS_MiniMaxH3LatentUpresSamplingR38: shift_video = float(sampling.get("shift_video", 12.0)) shift_audio = float(sampling.get("shift_audio", 3.0)) turbo_model, turbo_report = _apply_turbo_lora(model, plan) - lora_model, secondary_lora_report = _apply_secondary_lora(turbo_model, plan) + lora_model, secondary_lora_report = _apply_secondary_lora(turbo_model, plan, chunk_index=segment_index) shifted = MiniMaxH3SigmaShift.execute(model=lora_model, shift_video=shift_video, shift_audio=shift_audio)[0] if str(plan.get("acceleration", "native")).lower() == "comfy_kitchen": from comfy_extras.nodes_model_advanced import ModelAttentionBackend diff --git a/iamccs_minimax_h3_masked_loop_guided.py b/iamccs_minimax_h3_masked_loop_guided.py index bf7f531..43cfbf7 100644 --- a/iamccs_minimax_h3_masked_loop_guided.py +++ b/iamccs_minimax_h3_masked_loop_guided.py @@ -358,7 +358,7 @@ class IAMCCS_MiniMaxH3MaskedLoopGuidedSampler: turbo_model, turbo_report = _apply_turbo_lora(model, shotplan) fasth3_model, fasth3_report = _apply_fasth3_dense_lora(turbo_model, shotplan) pdd_model, pdd_report = _apply_pdd_lora(fasth3_model, shotplan) - lora_model, secondary_report = _apply_secondary_lora(pdd_model, shotplan) + lora_model, secondary_report = _apply_secondary_lora(pdd_model, shotplan, chunk_index=chunk_index) turbo = _turbo_settings(shotplan) turbo_name = str(turbo.get("lora_name", "") or "").strip() turbo_enabled = bool( diff --git a/iamccs_minimax_h3_open_refine.py b/iamccs_minimax_h3_open_refine.py new file mode 100644 index 0000000..63f835b --- /dev/null +++ b/iamccs_minimax_h3_open_refine.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Settings bridge for the stock-node Open-H3 delivery branch.""" + +import folder_paths + + +PREFIX = "h3_open_refine_" +SIGMAS = { + "gentle_3step": "0.8, 0.6857142857, 0.48, 0", + "reference_3step": "0.909375, 0.857143, 0.666667, 0", +} + + +def settings_schema(): + loras = ["", *folder_paths.get_filename_list("loras")] + upscalers = ["", *folder_paths.get_filename_list("latent_upscale_models")] if "latent_upscale_models" in folder_paths.folder_names_and_paths else [""] + refiner = next((name for name in loras if name.lower().endswith("minimax_h3_open_regenerate_2k_refiner_rank_384_bf16.safetensors")), "") + distill = next((name for name in loras if name.lower().endswith("minimax_h3_fl2v_lightx2v_turbo_4step_v0.1_comfy.safetensors")), "") + upscaler = next((name for name in upscalers if name.lower().endswith("minimax_h3_latent_upscaler_3d_fp16.safetensors")), "") + fields = { + "enabled": ("BOOLEAN", {"default": False, "display_name": "OPEN-H3 · ENABLE REFINEMENT", "tooltip": "One native generation followed by Open-H3. Replaces the other upscale routes; requires the Open-H3 workflow branch."}), + "scale": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 4.0, "step": 0.05, "display_name": "OPEN-H3 · SPATIAL SCALE", "tooltip": "Learned latent upscale. Output follows the native aspect ratio, rounded to the H3 grid. Full-resolution refinement can exceed low-VRAM capacity."}), + "model_name": (upscalers, {"default": upscaler, "display_name": "OPEN-H3 · LATENT UPSCALER"}), + "precision": (["fp16", "bf16", "fp32"], {"default": "fp16", "display_name": "OPEN-H3 · UPSCALER PRECISION"}), + "device": (["cuda", "rocm", "cpu"], {"default": "cuda", "display_name": "OPEN-H3 · UPSCALER DEVICE"}), + "temporal_chunking": ("BOOLEAN", {"default": True, "display_name": "OPEN-H3 · UPSCALER CHUNKING", "tooltip": "Chunks the latent upscaler only. The three-step H3 sampler still processes the complete target latent."}), + "lora_name": (loras, {"default": refiner, "display_name": "OPEN-H3 · REFINER LORA"}), + "strength": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01, "display_name": "OPEN-H3 · REFINER STRENGTH"}), + "distill_lora_name": (loras, {"default": distill, "display_name": "OPEN-H3 · DISTILLATION LORA", "tooltip": "Kijai's rank-384 file contains only the refiner. The supplied community recipe pairs it with LightX2V v0.1; it is not NVIDIA's complete rank-576 bundle. Leave blank when the selected refiner already includes distillation."}), + "distill_strength": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01, "display_name": "OPEN-H3 · DISTILLATION STRENGTH"}), + "sigma_preset": (["gentle_3step", "reference_3step", "custom"], {"default": "gentle_3step", "display_name": "OPEN-H3 · NOISE SCHEDULE", "tooltip": "Gentle starts at 0.8. Reference starts at 0.909375 and can change identity or performance. Neither preset guarantees preservation."}), + "custom_sigmas": ("STRING", {"default": SIGMAS["gentle_3step"], "multiline": False, "display_name": "OPEN-H3 · CUSTOM SIGMAS", "tooltip": "Used only with Custom. Comma-separated Euler sigmas ending in zero."}), + "seed": ("INT", {"default": 777, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": False, "display_name": "OPEN-H3 · REFINE SEED"}), + "prompt": ("STRING", {"default": "", "multiline": True, "display_name": "OPEN-H3 · REFINE PROMPT", "tooltip": "Blank uses the current shot prompt. Describe the existing action and appearance; new instructions can redraw the performance."}), + } + return {PREFIX + name: spec for name, spec in fields.items()} + + +def configuration(settings): + return {name[len(PREFIX):]: settings.get(name, spec[1]["default"]) for name, spec in settings_schema().items()} + + +class IAMCCS_MiniMaxH3OpenRefineControl: + @classmethod + def INPUT_TYPES(cls): + return {"required": {"cine_linx": ("IAMCCS_SUPERNODE_LINX",), "segment_index": ("INT", {"default": 0, "min": 0, "forceInput": True})}} + + # File names feed both legacy list-combos and V3 COMBO sockets. + RETURN_TYPES = ("BOOLEAN", "STRING", "FLOAT", "*", "COMBO", "COMBO", "BOOLEAN", "*", "FLOAT", "*", "FLOAT", "STRING", "INT", "BOOLEAN") + RETURN_NAMES = ("enabled", "prompt", "scale", "upscaler_name", "precision", "device", "temporal_chunking", "refiner_lora", "refiner_strength", "distill_lora", "distill_strength", "sigmas", "seed", "use_face_source") + FUNCTION = "resolve" + CATEGORY = "IAMCCS/MiniMax H3/Delivery" + + def resolve(self, cine_linx, segment_index): + # Atomic imports Shotboard, which imports this module's settings schema. + from .iamccs_minimax_h3_atomic_backend import _resolve_shotplan + + plan = _resolve_shotplan(cine_linx) + cfg = plan.get("open_h3_refine") or configuration({}) + enabled = bool(cfg["enabled"]) + if enabled and bool(plan.get("upscale_enabled")) and plan.get("upscale_mode", "off") != "off": + raise ValueError("Open-H3 replaces the other upscale routes. Select Open-H3 in Settings PRO Mode Assistant.") + if enabled and not cfg["lora_name"]: + raise ValueError("Select an Open-H3 refiner LoRA in Settings PRO Output.") + if enabled and not cfg["model_name"]: + raise ValueError("Select an H3 latent upscaler in Settings PRO Output.") + chunks = plan.get("chunks") or [] + chunk = chunks[segment_index] if 0 <= segment_index < len(chunks) else {} + prompt = cfg["prompt"] or chunk.get("prompt") or plan.get("global_prompt", "") + sigmas = cfg["custom_sigmas"] if cfg["sigma_preset"] == "custom" else SIGMAS[cfg["sigma_preset"]] + seed = (int(cfg["seed"]) + int(segment_index)) % (1 << 64) + distill_strength = float(cfg["distill_strength"]) if cfg["distill_lora_name"] else 0.0 + use_face_source = bool(plan.get("face_detailer_settings", {}).get("enabled", False)) and plan.get("task_mode") != "longvid_keyframe_continuous" + return (enabled, prompt, float(cfg["scale"]), cfg["model_name"], cfg["precision"], cfg["device"], bool(cfg["temporal_chunking"]), cfg["lora_name"], float(cfg["strength"]), cfg["distill_lora_name"], distill_strength, sigmas, seed, use_face_source) + + +NODE_CLASS_MAPPINGS = {"IAMCCS_MiniMaxH3OpenRefineControl": IAMCCS_MiniMaxH3OpenRefineControl} +NODE_DISPLAY_NAME_MAPPINGS = {"IAMCCS_MiniMaxH3OpenRefineControl": "MiniMax H3 · Open-H3 Refinement Settings"} diff --git a/iamccs_minimax_h3_seed_scout_variant.py b/iamccs_minimax_h3_seed_scout_variant.py index cdc5ecf..b9c021a 100644 --- a/iamccs_minimax_h3_seed_scout_variant.py +++ b/iamccs_minimax_h3_seed_scout_variant.py @@ -20,6 +20,7 @@ from typing import Any import folder_paths import torch +from comfy_execution.graph import ExecutionBlocker from .iamccs_minimax_h3_atomic_backend import ( H3_FPS, @@ -78,14 +79,14 @@ def _apply_sparse(model, enabled: bool, budget: float, denser_edges: bool): ) from exc -def _active_sampling_model(model, plan: dict[str, Any]): +def _active_sampling_model(model, plan: dict[str, Any], chunk_index=0): from comfy_extras.nodes_minimax_h3 import MiniMaxH3SigmaShift sampling = plan.get("sampling") if isinstance(plan.get("sampling"), dict) else {} shift_video = float(sampling.get("shift_video", 12.0)) shift_audio = float(sampling.get("shift_audio", 3.0)) turbo_model, turbo_report = _apply_turbo_lora(model, plan) - lora_model, secondary_report = _apply_secondary_lora(turbo_model, plan) + lora_model, secondary_report = _apply_secondary_lora(turbo_model, plan, chunk_index=chunk_index) turbo = _turbo_settings(plan) turbo_name = str(turbo.get("lora_name", "") or "").strip() compatible, _ = _h3_lora_compatibility(turbo_name) @@ -123,11 +124,11 @@ class IAMCCS_MiniMaxH3SeedCandidateR40: def INPUT_TYPES(cls): return { "required": { - "model": ("MODEL",), - "positive": ("CONDITIONING",), - "latent": ("LATENT",), + "model": ("MODEL", {"lazy": True}), + "positive": ("CONDITIONING", {"lazy": True}), + "latent": ("LATENT", {"lazy": True}), "cine_linx": (SUPERNODE_LINX_TYPE,), - "chunk_index": ("INT", {"forceInput": True}), + "chunk_index": ("INT", {"forceInput": True, "lazy": True}), "candidate_index": ("INT", {"default": 0, "min": 0, "max": 3, "step": 1}), } } @@ -137,7 +138,22 @@ class IAMCCS_MiniMaxH3SeedCandidateR40: FUNCTION = "sample" CATEGORY = CATEGORY - def sample(self, model, positive, latent, cine_linx, chunk_index, candidate_index): + @staticmethod + def _active(cine_linx, candidate_index): + prefix, _ = IAMCCS_MiniMaxH3ScoutDeliveryLazyRouterR43._selection(cine_linx) + settings = _scout_settings(_resolve_shotplan(cine_linx)) + count = max(1, min(4, int(settings.get("candidate_count", 1) or 1))) + return prefix == "scout" and 0 <= int(candidate_index) < count + + def check_lazy_status(self, cine_linx, candidate_index, **kwargs): + if not self._active(cine_linx, candidate_index): + return [] + return [name for name in ("model", "positive", "latent", "chunk_index") if kwargs.get(name) is None] + + def sample(self, model=None, positive=None, latent=None, cine_linx=None, chunk_index=0, candidate_index=0): + if not self._active(cine_linx, candidate_index): + blocker = ExecutionBlocker(None) + return blocker, blocker, 0, False, f"R40 candidate {int(candidate_index) + 1} bypassed" from comfy_extras.nodes_custom_sampler import ( BasicGuider, BasicScheduler, @@ -148,26 +164,17 @@ class IAMCCS_MiniMaxH3SeedCandidateR40: plan = _resolve_shotplan(cine_linx) scout = _scout_settings(plan) - enabled = bool(scout.get("enabled", False)) - count = max(1, min(4, int(scout.get("candidate_count", 1) or 1))) if enabled else 1 + count = max(1, min(4, int(scout.get("candidate_count", 1) or 1))) index = max(0, min(3, int(candidate_index))) sampling = plan.get("sampling") if isinstance(plan.get("sampling"), dict) else {} base_seed = int(sampling.get("seed", 0) or 0) chunk_stride = int(sampling.get("seed_stride", 1) or 1) scout_stride = max(1, int(scout.get("seed_stride", 1_000_003) or 1_000_003)) actual_seed = (base_seed + int(chunk_index) * chunk_stride + index * scout_stride) & 0xFFFFFFFFFFFFFFFF - if index >= count: - inactive_sampled = copy.copy(latent) - inactive_x0 = copy.copy(latent) - inactive_sampled["iamccs_r40_candidate_active"] = False - inactive_x0["iamccs_r40_candidate_active"] = False - report = f"R40 candidate {index + 1} inactive | configured candidates={count}" - return inactive_sampled, inactive_x0, actual_seed, False, report - _audit_lipsync_audio_lock(plan, latent, int(chunk_index)) cleanup_report = _release_conditioning_models(plan) active_model, turbo_enabled, turbo_report, secondary_report, acceleration_report, sparse_report = ( - _active_sampling_model(model, plan) + _active_sampling_model(model, plan, chunk_index=chunk_index) ) noise = RandomNoise.execute(noise_seed=actual_seed)[0] guider = BasicGuider.execute(model=active_model, conditioning=positive)[0] @@ -319,8 +326,8 @@ class IAMCCS_MiniMaxH3SeedPreviewR40: return { "required": { "latent": ("LATENT",), - "video_vae": ("VAE",), - "trim_frames": ("INT", {"forceInput": True}), + "video_vae": ("VAE", {"lazy": True}), + "trim_frames": ("INT", {"forceInput": True, "lazy": True}), "max_preview_frames": ("INT", {"default": 49, "min": 1, "max": 209, "step": 1}), "max_preview_width": ("INT", {"default": 512, "min": 128, "max": 1024, "step": 64}), } @@ -331,9 +338,14 @@ class IAMCCS_MiniMaxH3SeedPreviewR40: FUNCTION = "decode" CATEGORY = CATEGORY - def decode(self, latent, video_vae, trim_frames, max_preview_frames, max_preview_width): + def check_lazy_status(self, latent, **kwargs): if not bool(latent.get("iamccs_r40_candidate_active", False)): - return torch.zeros((1, 144, 256, 3), dtype=torch.float32), "R40 candidate inactive" + return [] + return [name for name in ("video_vae", "trim_frames") if kwargs.get(name) is None] + + def decode(self, latent, video_vae=None, trim_frames=0, max_preview_frames=49, max_preview_width=512): + if not bool(latent.get("iamccs_r40_candidate_active", False)): + return ExecutionBlocker(None), "R40 candidate inactive" import nodes as comfy_nodes frames = comfy_nodes.VAEDecode().decode(vae=video_vae, samples=latent)[0] @@ -563,7 +575,7 @@ class IAMCCS_MiniMaxH3LatentUpresSamplingR40: sampling = plan.get("sampling") if isinstance(plan.get("sampling"), dict) else {} seed = (int(shot_lab.get("stage2_seed", 0)) + int(segment_index) * int(shot_lab.get("stage2_seed_stride", 1))) & 0xFFFFFFFFFFFFFFFF base, turbo_report = _apply_turbo_lora(model, plan) - base, lora2_report = _apply_secondary_lora(base, plan) + base, lora2_report = _apply_secondary_lora(base, plan, chunk_index=segment_index) base, stage2_lora_report = _stage2_lora(base, shot_lab) shifted = MiniMaxH3SigmaShift.execute( model=base, diff --git a/iamccs_minimax_h3_shotboard.py b/iamccs_minimax_h3_shotboard.py index da8d15f..3d9aa12 100644 --- a/iamccs_minimax_h3_shotboard.py +++ b/iamccs_minimax_h3_shotboard.py @@ -29,6 +29,8 @@ from .iamccs_h3_face_swap import settings_schema as face_swap_schema, face_swap_ from .iamccs_h3_character_swap_lora import settings_schema as character_swap_schema, swap_plan from .iamccs_h3_vfx_edit import settings_schema as vfx_edit_schema from .iamccs_h3_omni import configure as configure_omni +from .iamccs_h3_continuity_guard import settings_schema as continuity_guard_schema, configuration as continuity_guard_configuration +from .iamccs_minimax_h3_open_refine import settings_schema as open_h3_refine_schema, configuration as open_h3_refine_configuration from .iamccs_minimax_h3_shotboard_core import ( H3_FPS, @@ -3485,6 +3487,7 @@ class IAMCCS_MiniMaxH3ShotPlanner: extended_av_boundary_polish_strength = EXTEND_BOUNDARY_POLISH_DEFAULT_STRENGTH # Append-only Settings/PRO controls. They are intentionally NOT planner widgets, # so historical Shotboard widget positions remain byte-for-byte compatible. + secondary_lora_continuation_only = False upscale_link_to_native = False upscale_link_factor = "640x384 -> 1280x768" import hashlib @@ -3647,6 +3650,7 @@ class IAMCCS_MiniMaxH3ShotPlanner: secondary_lora_enabled = saved_settings.get("secondary_lora_enabled", secondary_lora_enabled) secondary_lora_name = saved_settings.get("secondary_lora_name", secondary_lora_name) secondary_lora_strength = saved_settings.get("secondary_lora_strength", secondary_lora_strength) + secondary_lora_continuation_only = bool(saved_settings.get("secondary_lora_continuation_only", False)) pdd_lora_name = saved_settings.get("pdd_lora_name", pdd_lora_name) pdd_strength = saved_settings.get("pdd_strength", pdd_strength) h3_controlnet_enabled = saved_settings.get("h3_controlnet_enabled", h3_controlnet_enabled) @@ -4545,6 +4549,7 @@ class IAMCCS_MiniMaxH3ShotPlanner: "duplicate_of_turbo": bool(secondary_lora_duplicate), "lora_name": selected_secondary_lora, "strength": float(secondary_strength), + "continuation_only": bool(secondary_lora_continuation_only), "loader": "ComfyUI LoraLoaderModelOnly", "source": "shotboard", } @@ -4765,6 +4770,7 @@ class IAMCCS_MiniMaxH3ShotPlanner: "soft_video_curve": str(saved_settings.get("h3_continuation_soft_video_curve", "smoothstep") or "smoothstep"), "soft_audio_ms": max(0.0, min(100.0, _finite_float(saved_settings.get("h3_continuation_soft_audio_ms", 15.0), 15.0, 0.0, 100.0))), } + plan["continuity_guard"] = continuity_guard_configuration(saved_settings) plan["refmod_settings"] = { "schema": "iamccs.minimax_h3.refmod.v1", "enabled": bool(saved_settings.get("h3_refmod_enabled", False)), @@ -4802,6 +4808,7 @@ class IAMCCS_MiniMaxH3ShotPlanner: "route": "face_detailer_then_optional_upscale" if bool(face_detailer_enabled) else "native_then_optional_upscale", "source": "IAMCCS_ShotboardH3Settings" if _h3_settings_from_cine_linx(cine_linx) else "shotboard_defaults", } + plan["open_h3_refine"] = open_h3_refine_configuration(saved_settings) configure_omni(plan, saved_settings) chunk_frames = [int(chunk.get("frame_count", 0) or 0) for chunk in plan.get("chunks", [])] max_chunk_frames = max(chunk_frames, default=0) @@ -5571,6 +5578,10 @@ class IAMCCS_MiniMaxH3NativeCheckpointSave: ) trim_count = max(0, int(trim_head_frames or 0)) + guard_report = sampled_latent.get("_iamccs_continuity_guard") if isinstance(sampled_latent, dict) else None + if isinstance(guard_report, dict): + from .iamccs_h3_continuity_guard import bind_checkpoint + bind_checkpoint(folder_paths.get_output_directory(), guard_report, active_render_id) join_contract = _checkpoint_join_contract(cine_linx, current_segment, trim_count) motion_context_pretrimmed = bool(join_contract["context_pretrimmed"]) overlap_stitch = join_contract["mode"] == "decoded_overlap" @@ -7630,6 +7641,12 @@ class IAMCCS_ShotboardH3Settings: "h3_omni_enabled": ("BOOLEAN", {"default": False, "display_name": "REF2VA · FASTH3 OMNI 8 STEP", "tooltip": "Full reference-conditioned checkpoint, not a LoRA. Select the Omni loader. Native PDD32, Euler/simple, 8 steps, shifts 12/3. Other modes keep their own model route."}), + **continuity_guard_schema(), + "secondary_lora_continuation_only": ("BOOLEAN", { + "default": False, "display_name": "Second LoRA: from segment 2", + "tooltip": "Apply the second LoRA only from segment 2 onward. A saved-AV checkpoint resume already counts as continuation. OFF preserves all-segment behavior. This controls temporal segments, not upscale passes.", + }), + **open_h3_refine_schema(), })} RETURN_TYPES = (SUPERNODE_LINX_TYPE,) diff --git a/iamccs_prompter.py b/iamccs_prompter.py index c805afc..b451af4 100644 --- a/iamccs_prompter.py +++ b/iamccs_prompter.py @@ -893,12 +893,42 @@ def _normalise_ai_images(value: Any) -> list[dict[str, str]]: return images +def _authored_h3_dialogue_lines(text: str) -> list[str]: + """Protect exact utterances for referenced characters and standalone voices.""" + return list(dict.fromkeys(re.findall( + r"(?:\s*)?\(S\d+(?:\s*,\s*S\d+)*\)[^<>\r\n]{0,160}\[[^\]]+\][\s\S]*?(?:\s*<(?:scenetrans|cutoff)>)*", + str(text or ""), flags=re.IGNORECASE, + ))) + + +def _official_h3_tag_rules(task_mode: str) -> str: + """Shared by Global, selected-field rewrite, and compact local fallback.""" + if task_mode in {"v2va_face_swap_lora", "ref2va_vfx_edit"}: + return "Use this mode's native aligned-edit grammar; do not append full-reference labels to its specialized edit prompt." + return ( + "Official H3 tag authoring contract applies even when the rough request has no tags. " + "Format every supplied spoken or sung line as (S1) says [Language] exact words, " + "assigning stable (S1), (S2) and later speaker IDs by first actual vocal event, independently of subject numbering. " + "Keep voice, delivery and acting descriptions outside ; preserve the exact words and original language inside it. " + "Never invent dialogue. Use only for speech genuinely continuing across an authored cut, " + "and only for speech intentionally interrupted by the clip ending; they are not generic transition decoration. " + "For full-reference modes, define reusable people, objects or environments with stable labels. " + "Bind their provenance to actual or