diff --git a/iamccs_cine_h3_bus.py b/iamccs_cine_h3_bus.py index 4ef0158..cbafc18 100644 --- a/iamccs_cine_h3_bus.py +++ b/iamccs_cine_h3_bus.py @@ -619,13 +619,17 @@ class IAMCCS_CineH3AudioBus: return (out_linx, *lanes, json.dumps(manifest, ensure_ascii=False, indent=2)) +from .iamccs_h3_previs import IAMCCS_H3PrevisControl + NODE_CLASS_MAPPINGS = { + "IAMCCS_H3PrevisControl": IAMCCS_H3PrevisControl, "IAMCCS_CineH3Input": IAMCCS_CineH3Input, "IAMCCS_CineH3FunControlInput": IAMCCS_CineH3FunControlInput, "IAMCCS_CineH3AudioBus": IAMCCS_CineH3AudioBus, } NODE_DISPLAY_NAME_MAPPINGS = { + "IAMCCS_H3PrevisControl": "IAMCCS H3 PREVIS · Camera + Subject Blocking", "IAMCCS_CineH3Input": "IAMCCS CineH3Input · Modular Bridge", "IAMCCS_CineH3FunControlInput": "IAMCCS Cine H3 Fun Control Input · Pose / Depth / Edge", "IAMCCS_CineH3AudioBus": "Cine H3 Audio Bus (Shotboard Lanes)", diff --git a/iamccs_h3_face_swap.py b/iamccs_h3_face_swap.py index 2e856d0..d27e1ff 100644 --- a/iamccs_h3_face_swap.py +++ b/iamccs_h3_face_swap.py @@ -1,6 +1,7 @@ """SAM3 tracked subject swap: lazy crop/inpaint/uncrop branch for universal H3.""" from __future__ import annotations +import hashlib import json import folder_paths @@ -11,6 +12,45 @@ FACE_SWAP_RESOURCE = "iamccs_h3_face_swap_source" FACE_SWAP_LATENT = "iamccs_h3_face_swap_crop" +def _image_signature(image): + """Short diagnostic identity for the exact reference tensor used by H3.""" + if not torch.is_tensor(image) or image.ndim != 4 or not len(image): + return "none" + sample = image[:1].detach().to(device="cpu", dtype=torch.float32).contiguous() + digest = hashlib.sha256(sample.numpy().tobytes()).hexdigest()[:12] + return f"sha256:{digest}:{sample.shape[2]}x{sample.shape[1]}" + + +def _fill_empty_mask_frames(masks): + """Fill SAM3 tracking dropouts from the nearest detected frame. + + SAM3 may acquire a subject only after several frames. In an inpaint + branch an empty leading mask means "preserve the source", which looked + like a deliberately delayed swap. Copying only completely empty masks + keeps every valid tracked mask intact while making the edit active from + frame zero and bridging isolated tracking losses. + """ + if not torch.is_tensor(masks) or masks.ndim < 3 or not len(masks): + return masks, {"active_before": 0, "first_active_before": None, "filled_frames": 0} + flat = masks.reshape(len(masks), -1) + active = torch.any(flat > 1e-6, dim=1) + active_indices = torch.nonzero(active, as_tuple=False).flatten().tolist() + if not active_indices: + return masks, {"active_before": 0, "first_active_before": None, "filled_frames": 0} + repaired = masks.clone() + empty_indices = torch.nonzero(~active, as_tuple=False).flatten().tolist() + for frame_index in empty_indices: + nearest = min(active_indices, key=lambda candidate: (abs(candidate - frame_index), candidate)) + repaired[frame_index] = masks[nearest] + return repaired, { + "active_before": len(active_indices), + "first_active_before": int(active_indices[0]), + "filled_frames": len(empty_indices), + "active_after": int(len(repaired)), + "first_active_after": 0, + } + + def settings_schema(): checkpoints = [name for name in folder_paths.get_filename_list("checkpoints") if "sam3" in name.lower()] sam3 = next( @@ -38,7 +78,15 @@ def settings_schema(): def face_swap_settings(named): - return {name.removeprefix("h3_faceswap_"): named.get(name, spec[1]["default"]) for name, spec in settings_schema().items()} + settings = { + name.removeprefix("h3_faceswap_"): named.get(name, spec[1]["default"]) + for name, spec in settings_schema().items() + } + # This field is intentionally declared append-only by the parent Settings + # schema, rather than inserted into settings_schema(), so old positional + # Settings/PRO widget arrays cannot shift. + settings["generate_new_audio"] = bool(named.get("h3_faceswap_generate_new_audio", False)) + return settings def validate_plan(plan): @@ -100,11 +148,15 @@ class IAMCCS_H3FaceSwapInput: raise ValueError("Select an installed SAM3 checkpoint in Face Swap settings, or connect source_mask.") if reference_mode == "two_view_birefnet_legacy" and not folder_paths.get_full_path("background_removal", config.get("birefnet_model", "")): raise ValueError("Two-view BiRefNet legacy mode requires its model in models/background_removal.") + reference_signature = _image_signature(reference_face) data = {"video": source_video, "fps": float(source_fps), "reference": reference_face, "reference_2": reference_face_2, "audio": source_audio, "mask": source_mask} + data["reference_signature"] = reference_signature return (build_stage_linx_payload(cine_linx, stage_name="H3 Face Swap input", stage_kind="minimax_h3_face_swap", - payload={"source_frames": len(source_video), "source_fps": source_fps}, - report="SAM3 Subject Swap source · lazy single-reference tracked branch", resources={FACE_SWAP_RESOURCE: data}),) + payload={"source_frames": len(source_video), "source_fps": source_fps, + "reference_signature": reference_signature}, + report=f"SAM3 Subject Swap source · lazy single-reference tracked branch · {reference_signature}", + resources={FACE_SWAP_RESOURCE: data}),) def _build_white_multiview_reference(reference_a, reference_b, model_name): @@ -174,6 +226,15 @@ def prepare_face_swap(model, clip, video_vae, audio_vae, cine_linx, segment_inde if not source: raise ValueError("FACE SWAP mode requires IAMCCS H3 Face Swap Input between Shotboard and the atomic backend.") chunk = _shotplan_chunk(plan, segment_index) + generate_new_audio = bool(config.get("generate_new_audio", False)) + source_audio_available = isinstance(source.get("audio"), dict) and torch.is_tensor(source["audio"].get("waveform")) + if not generate_new_audio and not source_audio_available: + raise ValueError( + "SAM3 Subject Swap defaults to SOURCE VIDEO AUDIO, but source_audio is not connected. " + "Connect the Load Video audio output to IAMCCS H3 Face Swap Input, or enable " + "FACE SWAP · GENERATE NEW AUDIO in IAMCCS Settings PRO." + ) + use_source_audio = source_audio_available and not generate_new_audio requested = _requested_frames(chunk) aligned = align_h3_frames(requested) v2v = plan.get("v2v", {}) @@ -201,6 +262,7 @@ def prepare_face_swap(model, clip, video_vae, audio_vae, cine_linx, segment_inde edge_grow=int(config.get("cleanup_edge_grow", 16)))[0] if not torch.any(masks > 0): raise ValueError("Face Swap mask is empty. Adjust the SAM3 prompt, object indices or threshold; no render was started.") + masks, mask_temporal_report = _fill_empty_mask_frames(masks) crops, crop_masks, boxes, *_ = _mvex("MVEx_SubjectCrop").execute(original_images=raw, masks=masks, mode={"mode": "tracked", "crop_scale": float(config.get("crop_scale", 1.75)), "padding": "firm", "prefer": "stillness", "aspect_ratio": 0.0, "seamless_loop": False}, divisible_by=32, upscale_megapixels=float(config.get("crop_megapixels", 0.5))) @@ -224,7 +286,7 @@ def prepare_face_swap(model, clip, video_vae, audio_vae, cine_linx, segment_inde prompt += " " + directed_prompt ref_audios = None sliced_source_audio = None - if plan.get("audio_mode") == "h3_custom_audio_drive" and isinstance(source.get("audio"), dict): + if use_source_audio: # Ref2VA must hear the exact same timeline slice that is later locked # into this chunk. Passing the full programme here makes chunk 2+ hear # the opening phonemes again even though the output audio is sliced. @@ -233,6 +295,7 @@ def prepare_face_swap(model, clip, video_vae, audio_vae, cine_linx, segment_inde start_seconds=start, requested_frames=requested, aligned_frames=aligned, + end_policy=v2v.get("source_end_policy", "hold_last_for_grid"), ) sliced_source_audio = { **sliced_source_audio, @@ -256,7 +319,7 @@ def prepare_face_swap(model, clip, video_vae, audio_vae, cine_linx, segment_inde audio_stream = {"samples": empty_av["samples"].unbind()[1]} latent = LTXVConcatAVLatent.execute(video_latent=video, audio_latent=audio_stream)[0] original_audio = None - if plan.get("audio_mode") == "h3_custom_audio_drive": + if use_source_audio: if sliced_source_audio is not None: original_audio = sliced_source_audio elif source.get("audio") is not None: @@ -265,6 +328,7 @@ def prepare_face_swap(model, clip, video_vae, audio_vae, cine_linx, segment_inde start_seconds=start, requested_frames=requested, aligned_frames=aligned, + end_policy=v2v.get("source_end_policy", "hold_last_for_grid"), ) original_audio = { **original_audio, @@ -282,8 +346,12 @@ def prepare_face_swap(model, clip, video_vae, audio_vae, cine_linx, segment_inde latent[FACE_SWAP_LATENT] = {"original": raw, "masks": crop_masks.cpu(), "boxes": boxes, "requested": requested, "audio": original_audio, "feather": int(config.get("feather", 16))} identity_report = "BiRefNet 2-view legacy card" if reference_mode == "two_view_birefnet_legacy" else "single Picture 1 reference" - report = f"SAM3 SUBJECT SWAP · tracked crop/inpaint/uncrop | SAM3 {config.get('mask_prompt', 'head')}@{float(config.get('threshold', 0.5)):.2f} max=1 interval=1 | {identity_report} | source={plan['width']}x{plan['height']} | crop={width}x{height} | frames={requested}/{aligned} | encoder={encoder_report}" - return (model, positive, latent, raw[:1], raw[-1:], json.dumps({"task": FACE_SWAP_MODE, "source": index_report}), + reference_signature = str(source.get("reference_signature") or _image_signature(source.get("reference"))) + audio_route = "source video audio · lip timing + preserved output" if use_source_audio else "new H3 generated audio · explicit opt-in" + report = f"SAM3 SUBJECT SWAP · tracked crop/inpaint/uncrop | SAM3 {config.get('mask_prompt', 'head')}@{float(config.get('threshold', 0.5)):.2f} max=1 interval=1 | {identity_report} {reference_signature} | audio={audio_route} | mask-first={mask_temporal_report.get('first_active_before')} fill={mask_temporal_report.get('filled_frames')} | source={plan['width']}x{plan['height']} | crop={width}x{height} | frames={requested}/{aligned} | encoder={encoder_report}" + return (model, positive, latent, raw[:1], raw[-1:], json.dumps({"task": FACE_SWAP_MODE, "source": index_report, + "reference_signature": reference_signature, "mask_temporal_repair": mask_temporal_report, + "audio_route": "source_video" if use_source_audio else "generated_new"}), prompt, int(segment_index), len(plan["chunks"]), 0, report, {"active": False}) diff --git a/iamccs_h3_previs.py b/iamccs_h3_previs.py new file mode 100644 index 0000000..eb571d9 --- /dev/null +++ b/iamccs_h3_previs.py @@ -0,0 +1,93 @@ +"""Previs depth producer for the existing R42/R43 H3 control transport.""" +import json +import math +import torch + + +def blocking_prompt(bindings_json): + data = json.loads(bindings_json) + if not isinstance(data, list) or len(data) > 32: + raise ValueError('Bindings must be a JSON list of at most 32 subjects.') + lines = ['Follow the supplied spatial control for camera perspective, parallax and blocking. ' + 'Preserve the designed appearance from the image references. ' + 'Use natural articulation inside the supplied coarse trajectories.'] + for item in data: + if not isinstance(item, dict): + raise ValueError('Each binding must contain proxy, subject and trajectory.') + values = [str(item.get(key, '')).strip() for key in ('proxy', 'subject', 'trajectory')] + if not all(values): + raise ValueError('Each binding requires proxy, subject and trajectory descriptions.') + lines.append(f'{values[1]} follows {values[2]}. Proxy label: {values[0]}. ' + 'The proxy defines blocking only; do not reproduce its primitive shape or material.') + return '\n'.join(lines) + + +class IAMCCS_H3PrevisControl: + @classmethod + def INPUT_TYPES(cls): + return {'required': { + 'cine_linx': ('IAMCCS_SUPERNODE_LINX',), + 'enabled': ('BOOLEAN', {'default': False}), + 'representation': (['direct_depth', 'rgb_proxy_to_depth'],), + 'source_fps': ('FLOAT', {'default': 24., 'min': 1., 'max': 240.}), + 'source_offset_seconds': ('FLOAT', {'default': 0., 'min': 0., 'max': 86400.}), + 'depth_polarity': (['near_white', 'near_black'],), + 'preprocess_resolution': ('INT', {'default': 512, 'min': 128, 'max': 2048, 'step': 64}), + 'bindings_json': ('STRING', {'multiline': True, 'default': '[{"proxy":"yellow cylinder","subject":"the protagonist in ","trajectory":"from screen left to centre, then stops on the foreground mark"}]'}), + }, 'optional': {'previs_video': ('IMAGE', {'lazy': True})}} + + RETURN_TYPES = ('IAMCCS_SUPERNODE_LINX', 'IMAGE', 'STRING', 'STRING') + RETURN_NAMES = ('cine_linx', 'exact_depth_preview', 'blocking_prompt', 'manifest') + FUNCTION = 'inject' + CATEGORY = 'IAMCCS/MiniMax H3/Previs' + + def check_lazy_status(self, cine_linx, enabled=False, previs_video=None, **kwargs): + return ['previs_video'] if enabled and previs_video is None else [] + + def inject(self, cine_linx, enabled, representation, source_fps, + source_offset_seconds, depth_polarity, preprocess_resolution, + bindings_json, previs_video=None): + if not enabled: + return cine_linx, None, '', json.dumps({'enabled': False}) + if not isinstance(cine_linx, dict): + raise ValueError('PREVIS requires the Settings/CineH3Input bus before Shotboard.') + if not torch.is_tensor(previs_video) or previs_video.ndim != 4 or len(previs_video) < 1: + raise ValueError('Connect decoded previs IMAGE frames; a filename is not an IMAGE batch.') + if not math.isfinite(source_fps) or source_fps <= 0 or not math.isfinite(source_offset_seconds) or source_offset_seconds < 0: + raise ValueError('Invalid previs FPS or source offset.') + prompt = blocking_prompt(bindings_json) + from .iamccs_cine_h3_bus import IAMCCS_CineH3FunControlInput + producer = IAMCCS_CineH3FunControlInput + if representation == 'rgb_proxy_to_depth': + depth = producer._preprocess(previs_video, 'depth_anything', preprocess_resolution) + elif representation == 'direct_depth': + if previs_video.shape[-1] != 3: + raise ValueError('Direct depth requires an RGB IMAGE batch containing grayscale depth.') + # Reject ID/color renders: they do not encode geometric distance. + if float((previs_video[..., 0] - previs_video[..., 1]).abs().max()) > .03 or float((previs_video[..., 1] - previs_video[..., 2]).abs().max()) > .03: + raise ValueError('Direct depth is not grayscale. Use RGB proxy to depth for colored primitives.') + depth = previs_video + else: + raise ValueError('Unknown previs representation.') + if not bool(torch.isfinite(depth).all()) or float(depth.min()) < 0 or float(depth.max()) > 1: + raise ValueError('Depth must contain finite normalized values in [0,1].') + if representation == 'direct_depth' and depth_polarity == 'near_black': + depth = 1 - depth + result = producer().inject(cine_linx, source_fps, control_video=depth) + out = result['result'][0] if isinstance(result, dict) else result[0] + manifest = {'schema': 'iamccs.h3.previs', 'version': 1, 'enabled': True, + 'representation': representation, 'source_fps': source_fps, + 'source_offset_seconds': source_offset_seconds, 'frames': len(depth), + 'camera_authority': True, 'geometry_authority': True, + 'identity_authority': False, 'proxy_rgb_to_model': False, + 'bindings': json.loads(bindings_json), + 'binding_contract': 'text direction, not deterministic object tracking', + 'depth_polarity': 'near_white', 'prompt': prompt} + out['resources']['iamccs_h3_previs_manifest'] = manifest + out['outputs']['iamccs_h3_previs_manifest'] = manifest + out['resources']['iamccs_minimax_h3_control_video_meta']['previs'] = manifest + return out, depth, prompt, json.dumps(manifest, ensure_ascii=False, indent=2) + + +NODE_CLASS_MAPPINGS = {'IAMCCS_H3PrevisControl': IAMCCS_H3PrevisControl} +NODE_DISPLAY_NAME_MAPPINGS = {'IAMCCS_H3PrevisControl': 'IAMCCS H3 PREVIS · Camera + Subject Blocking'} diff --git a/iamccs_minimax_h3_atomic_backend.py b/iamccs_minimax_h3_atomic_backend.py index 3f94799..2655ea9 100644 --- a/iamccs_minimax_h3_atomic_backend.py +++ b/iamccs_minimax_h3_atomic_backend.py @@ -1653,27 +1653,51 @@ def _clean_vram_before_decode() -> str: return f"cleanup warning: {exc}" -def _release_conditioning_models(shotplan: dict[str, Any]) -> str: +def _release_conditioning_models(shotplan: dict[str, Any], effective_task: str = "") -> str: """Strict barrier after conditioning and immediately before H3 sampling. Positive conditioning and the AV latent are already materialized when the - generation node runs. Qwen3-VL (and any conditioning-time VAE residency) + generation node runs. Qwen3-VL (and any conditioning-time VAE residency) can therefore be unloaded before the H3 model is requested. + + Local REF2VA + Fun ControlNet exception: + ComfyUI's MiniMax H3 Fun ControlNet wrapper builds its control latent lazily + on first diffusion forward and restores the model patchers that were active + around that VAE encode. Unloading them here can leave a dead/None patcher + in the restore list. Preserve model residency only for that exact branch. """ + fun_controlnet = ( + shotplan.get("fun_controlnet") + if isinstance(shotplan.get("fun_controlnet"), dict) + else {} + ) + ref2va_fun_controlnet = ( + bool(fun_controlnet.get("enabled", False)) + and str(effective_task or "").strip().lower().startswith("ref2va") + ) + try: import comfy.model_management as mm - mm.unload_all_models() - try: - mm.cleanup_models() - except Exception: - pass - mm.soft_empty_cache() - if torch.cuda.is_available(): - torch.cuda.empty_cache() + if ref2va_fun_controlnet: + # Surgical local fix: do NOT invalidate model patchers needed later + # by MiniMaxH3FunControlNetApply.prepare_control_latent(). + mm.soft_empty_cache() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + else: + mm.unload_all_models() + try: + mm.cleanup_models() + except Exception: + pass + mm.soft_empty_cache() + if torch.cuda.is_available(): + torch.cuda.empty_cache() except Exception as exc: LOG.warning("MiniMax H3 pre-sampler conditioning cleanup warning: %s", exc) return f"conditioning cleanup warning: {exc}" + gc.collect() if os.name == "nt": try: @@ -1681,12 +1705,20 @@ def _release_conditioning_models(shotplan: dict[str, Any]) -> str: handle = ctypes.windll.kernel32.GetCurrentProcess() ctypes.windll.psapi.EmptyWorkingSet(handle) - report = "conditioning models unloaded; CUDA cache cleared; Windows working set trimmed" + report = ( + "REF2VA+FunControlNet model patchers preserved; CUDA cache cleared; Windows working set trimmed" + if ref2va_fun_controlnet + else "conditioning models unloaded; CUDA cache cleared; Windows working set trimmed" + ) LOG.info("MiniMax H3 pre-sampler barrier: %s", report) return report except Exception as exc: LOG.warning("MiniMax H3 working-set trim warning: %s", exc) - report = "conditioning models unloaded; CUDA cache cleared" + report = ( + "REF2VA+FunControlNet model patchers preserved; CUDA cache cleared" + if ref2va_fun_controlnet + else "conditioning models unloaded; CUDA cache cleared" + ) LOG.info("MiniMax H3 pre-sampler barrier: %s", report) return report @@ -2868,7 +2900,7 @@ class IAMCCS_MiniMaxH3GenerationBackendV2: raise ValueError("Fused Fast H3 requires the visible profile shifts: video 12.0 and audio 3.0") actual_seed = chunk_seed(sampling, chunk_index, seed, seed_stride) seed_contract = sampling.get("seed_policy", "fixed_per_generation") - conditioning_cleanup = _release_conditioning_models(shotplan) + conditioning_cleanup = _release_conditioning_models(shotplan, _effective_task(cine_linx, chunk)) turbo = _turbo_settings(shotplan) turbo_requested = str(turbo.get("mode", "off") or "off").lower() != "off" and bool(turbo.get("enabled", True)) diff --git a/iamccs_minimax_h3_shotboard.py b/iamccs_minimax_h3_shotboard.py index 25aae57..73bd6a4 100644 --- a/iamccs_minimax_h3_shotboard.py +++ b/iamccs_minimax_h3_shotboard.py @@ -376,15 +376,13 @@ _H3_SETTINGS_CINELINX_OMITTED_FIELDS = frozenset(( _H3_SETTINGS_SEED_CONTROL_COMPAT_FIELD, )) _H3_SETTINGS_SHOTBOARD_AUTHORITY_FIELDS = frozenset(( - # Timeline extent and audio routing are authored Shotboard facts. A stale - # Settings/AudioBoard payload must never turn native H3 audio back into a - # previously published custom soundtrack. - "duration_seconds", "audio_mode", + # Audio routing remains an authored Shotboard fact. Duration is different: + # Shotboard owns it without external Settings; connected Settings/PRO owns + # it and the PRO UI mirrors it back into the visible board. + "audio_mode", )) _H3_SETTINGS_PRO_SHOTBOARD_OWNED_FIELDS = frozenset(( - # Only authored duration remains Shotboard-authoritative. The remaining - # generation controls exposed by the proven Settings node are mapped into - # Settings PRO below without changing their backend meaning. + # Duration is deliberately absent: connected Settings PRO is its master. *_H3_SETTINGS_SHOTBOARD_AUTHORITY_FIELDS, )) _H3_SETTINGS_LINX_SCHEMA = "iamccs.minimax_h3.settings_cine_linx" @@ -2476,7 +2474,14 @@ class IAMCCS_MiniMaxH3ShotPlanner: "v2v_source_range_policy": (["", "timeline_segment", "sequential_requested", "repeat_from_offset"], {"default": "timeline_segment"}), "v2v_source_offset_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 86400.0, "step": 0.01}), "v2v_source_fit": (["", "native_adapt", "canvas_pad", "canvas_crop", "stretch"], {"default": "canvas_pad"}), - "v2v_source_end_policy": (["", "hold_last_for_grid", "error"], {"default": "hold_last_for_grid"}), + "v2v_source_end_policy": (["", "hold_last_for_grid", "hold_last_visible", "error"], { + "default": "hold_last_for_grid", + "tooltip": ( + "HOLD LAST FOR GRID requires the complete visible source and freezes only H3's hidden 17k+5 tail (recommended). " + "HOLD LAST VISIBLE also extends a genuinely short source with its final frame and silence; continuity survives but lip-sync cannot continue beyond the real source. " + "ERROR requires real source frames even for the technical tail." + ), + }), "v2v_audio_pairing": (["", "pair_with_source_video", "standalone_reference", "off"], {"default": "pair_with_source_video"}), # FL2VA only. Stable planned keyframes remain the default; # the native AV option is an explicit experimental handoff @@ -2706,9 +2711,9 @@ class IAMCCS_MiniMaxH3ShotPlanner: # from the Shotboard snapshot: that would resurrect stale local Turbo, # model-family or delivery values and recreate a dual-truth regression. external_settings = _h3_settings_from_cine_linx(cine_linx) - # Mode and duration always come from the Shotboard widgets/timeline. - # Ignore stale values produced by older Settings nodes so a saved T2VA - # selection cannot turn LongVid Motion Context into a T2V plan. + # Mode stays protected from stale Settings payloads. Duration is + # accepted intentionally: without external Settings the Shotboard is + # truth; with Settings/PRO connected its named duration is the master. external_settings = { name: value for name, value in external_settings.items() if name not in _H3_SETTINGS_SHOTBOARD_AUTHORITY_FIELDS @@ -2719,6 +2724,11 @@ class IAMCCS_MiniMaxH3ShotPlanner: name: value for name, value in saved_settings.items() if name not in _H3_SETTINGS_SHOTBOARD_AUTHORITY_FIELDS } + # A timeline-embedded legacy settings snapshot is not a connected + # master. Its old duration must never override the live Shotboard + # control. Only an actual external Settings CineLinX may own it. + if not external_settings: + saved_settings.pop("duration_seconds", None) if saved_settings: duration_seconds = saved_settings.get("duration_seconds", duration_seconds) frame_rate = saved_settings.get("frame_rate", frame_rate) @@ -2922,7 +2932,7 @@ class IAMCCS_MiniMaxH3ShotPlanner: if v2v_source_fit not in {"native_adapt", "canvas_pad", "canvas_crop", "stretch"}: v2v_source_fit = "canvas_pad" v2v_source_end_policy = str(v2v_source_end_policy or "hold_last_for_grid") - if v2v_source_end_policy not in {"hold_last_for_grid", "error"}: + if v2v_source_end_policy not in {"hold_last_for_grid", "hold_last_visible", "error"}: v2v_source_end_policy = "hold_last_for_grid" v2v_audio_pairing = str(v2v_audio_pairing or "pair_with_source_video") if v2v_audio_pairing not in {"pair_with_source_video", "standalone_reference", "off"}: @@ -3168,6 +3178,11 @@ class IAMCCS_MiniMaxH3ShotPlanner: ), keyframe_joint_latent_new=bool(saved_settings.get("keyframe_joint_latent_new", False)), ) + if isinstance(plan, dict): + plan["duration_authority"] = ( + "settings_pro" if "duration_seconds" in external_settings else "shotboard" + ) + plan["duration_master_seconds"] = float(duration_seconds) longvid_guides_active = bool( isinstance(plan, dict) and str(plan.get("task_mode", "") or "").strip().lower() == "longvid_guides" @@ -6033,6 +6048,16 @@ class IAMCCS_ShotboardH3Settings: "display_name": "CONTINUATION · SOFT AUDIO MS", "tooltip": "Recommended: 15 ms equal-power de-click against the matching hidden audio context. Keep it short for dialogue and lipsync.", }), + # Append only: source-video audio is the safe/default identity-swap + # contract. Native H3 audio must be an explicit user choice. + "h3_faceswap_generate_new_audio": ("BOOLEAN", { + "default": False, + "display_name": "FACE SWAP · GENERATE NEW AUDIO", + "tooltip": ( + "OFF (recommended): use source_video audio as Ref2VA lip timing and preserve that exact audio in the output. " + "ON: ignore source audio for conditioning/output and generate a new native H3 audio stream." + ), + }), }} RETURN_TYPES = (SUPERNODE_LINX_TYPE,) diff --git a/iamccs_minimax_h3_shotboard_core.py b/iamccs_minimax_h3_shotboard_core.py index 4826b04..e8df18c 100644 --- a/iamccs_minimax_h3_shotboard_core.py +++ b/iamccs_minimax_h3_shotboard_core.py @@ -169,6 +169,13 @@ def _is_frame_timeline(timeline: dict[str, Any]) -> bool: def _duration_seconds(slot: dict[str, Any], timeline: dict[str, Any], fallback: float) -> float: + # Filmmaker timelines are frame-authored. ``length`` is the live value + # changed by trimming/stretching a box, while duration_seconds and older + # length_frames mirrors can remain serialized with the previous value. + # Prefer the frame truth so a 6-second edit cannot silently compile the + # former 124-frame/~5-second range. + if _is_frame_timeline(timeline) and slot.get("length") is not None: + return max(1.0 / H3_FPS, _float(slot.get("length"), fallback * H3_FPS) / H3_FPS) explicit = _first_value(slot, ("duration_seconds", "length_seconds", "duration")) if explicit is not None: return max(0.01, _float(explicit, fallback)) @@ -180,6 +187,8 @@ def _duration_seconds(slot: dict[str, Any], timeline: dict[str, Any], fallback: def _start_seconds(slot: dict[str, Any], timeline: dict[str, Any], fallback: float) -> float: + if _is_frame_timeline(timeline) and slot.get("start") is not None: + return max(0.0, _float(slot.get("start"), fallback * H3_FPS) / H3_FPS) explicit = _first_value(slot, ("start_seconds", "second", "time_seconds")) if explicit is not None: return max(0.0, _float(explicit, fallback)) @@ -189,30 +198,28 @@ def _start_seconds(slot: dict[str, Any], timeline: dict[str, Any], fallback: flo return max(0.0, fallback) -def _normalise_slots(timeline: dict[str, Any], duration_seconds: float, fallback_duration: float) -> list[dict[str, Any]]: +def _normalise_slots( + timeline: dict[str, Any], + duration_seconds: float, + fallback_duration: float, + preserve_image_anchors: bool = False, +) -> list[dict[str, Any]]: raw_rows = _timeline_rows(timeline) image_paths = _timeline_image_paths(timeline) slots: list[dict[str, Any]] = [] cursor = 0.0 + # ``duration_seconds`` is the programme boundary: it comes from the + # Shotboard alone, or from connected Settings/PRO after compilation. Rows + # are editorial content inside that boundary. This prevents a stale row + # length from silently restoring an older 6/30-second generation request. + duration_limit_frames = max( + H3_MIN_FRAMES, + int(round(max(0.01, _float(duration_seconds, fallback_duration)) * H3_FPS)), + ) for index, row in enumerate(raw_rows): row_type = _text(row.get("type", "image")).lower() if row_type in {"audio", "motion", "video"} or _bool(row.get("placeholder"), False): continue - duration = _duration_seconds(row, timeline, fallback_duration) - start = _start_seconds(row, timeline, cursor) - requested_frames = max(H3_MIN_FRAMES, int(round(duration * H3_FPS))) - if requested_frames > H3_MAX_TRAINED_FRAMES: - raise ValueError( - f"Il box '{_text(_first_value(row, ('label', 'name'))) or index + 1}' richiede " - f"{requested_frames} frame: riduci il trimming sulla timeline a massimo " - f"{H3_MAX_TRAINED_FRAMES} frame. Il planner non divide automaticamente i box." - ) - frame_count = align_h3_frames(requested_frames) - if frame_count > H3_MAX_TRAINED_FRAMES: - raise ValueError( - f"Il box {index + 1} diventa {frame_count} frame dopo l'allineamento H3 17k+5: " - f"riduci il trimming a massimo {H3_MAX_TRAINED_FRAMES} frame." - ) image = _slot_image(row) if not image and "imageFile" not in row: try: @@ -225,6 +232,32 @@ def _normalise_slots(timeline: dict[str, Any], duration_seconds: float, fallback row.get("use_keyframe", row.get("use_guide", True)), True, ) + protected_anchor = bool(preserve_image_anchors and image and use_keyframe) + duration = _duration_seconds(row, timeline, fallback_duration) + start = _start_seconds(row, timeline, cursor) + requested_frames = max(H3_MIN_FRAMES, int(round(duration * H3_FPS))) + if not protected_anchor: + start_frame = max(0, int(round(start * H3_FPS))) + remaining_frames = duration_limit_frames - start_frame + if remaining_frames <= 0: + continue + requested_frames = min(requested_frames, remaining_frames) + # H3 cannot compile a sub-five-frame fragment at the programme edge. + # Ignore the sliver rather than extending beyond the declared truth. + if requested_frames < H3_MIN_FRAMES: + continue + if requested_frames > H3_MAX_TRAINED_FRAMES: + raise ValueError( + f"Il box '{_text(_first_value(row, ('label', 'name'))) or index + 1}' richiede " + f"{requested_frames} frame: riduci il trimming sulla timeline a massimo " + f"{H3_MAX_TRAINED_FRAMES} frame. Il planner non divide automaticamente i box." + ) + frame_count = align_h3_frames(requested_frames) + if frame_count > H3_MAX_TRAINED_FRAMES: + raise ValueError( + f"Il box {index + 1} diventa {frame_count} frame dopo l'allineamento H3 17k+5: " + f"riduci il trimming a massimo {H3_MAX_TRAINED_FRAMES} frame." + ) slot = { "id": _text(row.get("id")) or f"shot_{index + 1}", "label": _text(_first_value(row, ("label", "name"))) or f"Shot {index + 1:02d}", @@ -241,7 +274,7 @@ def _normalise_slots(timeline: dict[str, Any], duration_seconds: float, fallback "use_keyframe": bool(image and use_keyframe), } slots.append(slot) - cursor = max(cursor, start + frame_count / H3_FPS) + cursor = max(cursor, start + requested_frames / H3_FPS) slots.sort(key=lambda item: (float(item["start_seconds"]), str(item["id"]))) for index, slot in enumerate(slots): @@ -2027,7 +2060,22 @@ def build_shotplan( return plan fallback_duration = min(H3_MAX_TRAINED_FRAMES / H3_FPS, max(H3_MIN_FRAMES / H3_FPS, 10.0)) - slots = _normalise_slots(timeline, duration_seconds, fallback_duration) + raw_visual_rows = [ + row for row in _timeline_rows(timeline) + if _text(row.get("type", "image")).lower() not in {"audio", "motion", "video", "text"} + and not _bool(row.get("placeholder"), False) + and _slot_image(row) + and _bool(row.get("use_keyframe", row.get("use_guide", True)), True) + ] + preserve_flf_anchors = requested_task_mode in { + "flf", "fflf", "fl2va", "longvid_continuous_guided", "long_continuous_guided", + } or (requested_task_mode in {"auto", "auto_from_timeline"} and len(raw_visual_rows) >= 2) + slots = _normalise_slots( + timeline, + duration_seconds, + fallback_duration, + preserve_image_anchors=preserve_flf_anchors, + ) lipsync_requested = requested_task_mode in {"ref2vid_lipsync", "lipsync_ref2vid"} lipsync_audio_rows = _timeline_audio_rows(timeline) if lipsync_requested else [] # A LipSync performance can use one CineInfoH3 image connected outside the @@ -2075,8 +2123,9 @@ def build_shotplan( or (auto_task_mode and len(image_slots) >= 2) ) if flf_anchor_mode: - timeline_duration = _float(timeline.get("duration_seconds"), duration_seconds) - slots = _normalise_flf_bridge_slots(timeline, slots, timeline_duration) + # The compiled duration argument is already resolved from Shotboard or + # connected Settings PRO. Never let a stale timeline mirror override it. + slots = _normalise_flf_bridge_slots(timeline, slots, duration_seconds) i2v_hard_cut_mode = bool(explicit_i2v_mode and len(image_slots) > 1) # Ref2VA does not accept the previous chunk's final frame as temporal # conditioning. Multiple timeline slots are independent reference-guided diff --git a/iamccs_minimax_h3_v2v_backend.py b/iamccs_minimax_h3_v2v_backend.py index df5da03..eba8c2e 100644 --- a/iamccs_minimax_h3_v2v_backend.py +++ b/iamccs_minimax_h3_v2v_backend.py @@ -47,7 +47,7 @@ SOURCE_FIT_POLICIES = ("native_adapt", "canvas_pad", "canvas_crop", "stretch") SOURCE_FIT_OVERRIDES = ("from_shotboard",) + SOURCE_FIT_POLICIES AUDIO_PAIRING_POLICIES = ("pair_with_source_video", "standalone_reference", "off") AUDIO_PAIRING_OVERRIDES = ("from_shotboard",) + AUDIO_PAIRING_POLICIES -SOURCE_END_POLICIES = ("hold_last_for_grid", "error") +SOURCE_END_POLICIES = ("hold_last_for_grid", "hold_last_visible", "error") SOURCE_END_OVERRIDES = ("from_shotboard",) + SOURCE_END_POLICIES REF_IMAGE_SIZE_POLICIES = ("match", "max") REF_IMAGE_SIZE_OVERRIDES = ("from_shotboard",) + REF_IMAGE_SIZE_POLICIES @@ -321,13 +321,18 @@ def _frame_indices( positions = start_seconds * source_fps + torch.arange(aligned_frames, dtype=torch.float64) * (source_fps / H3_FPS) indices = torch.floor(positions + 0.5).to(dtype=torch.long) requested_max = int(indices[requested_frames - 1].item()) - if requested_max >= source_frames: + visible_overflow = indices[:requested_frames] >= source_frames + visible_overflow_count = int(visible_overflow.sum().item()) + if requested_max >= source_frames and end_policy != "hold_last_visible": available_seconds = source_frames / source_fps needed_seconds = start_seconds + requested_frames / H3_FPS raise ValueError( "MiniMax H3 V2V source is shorter than the requested visible range: " f"available={available_seconds:.3f}s, requested_end={needed_seconds:.3f}s, " - f"source_fps={source_fps:.3f}. Shorten the Shotboard segment or supply a longer source." + f"source_start={start_seconds:.3f}s, requested_frames={requested_frames}, " + f"aligned_frames={aligned_frames}, source_frames={source_frames}, source_fps={source_fps:.3f}. " + "Shorten the Shotboard/Settings PRO master duration, supply a longer source, or explicitly select " + "hold_last_visible (the padded range cannot preserve source lip-sync)." ) overflow = indices >= source_frames overflow_count = int(overflow.sum().item()) @@ -342,7 +347,7 @@ def _frame_indices( # action frames of chunk N+1. The strict `error` policy intentionally keeps # real tail addressing when the caller explicitly asks for it. grid_tail_hold_frames = 0 - if end_policy == "hold_last_for_grid" and aligned_frames > requested_frames: + if end_policy in {"hold_last_for_grid", "hold_last_visible"} and aligned_frames > requested_frames: grid_tail_hold_frames = aligned_frames - requested_frames indices[requested_frames:] = indices[requested_frames - 1] indices.clamp_(0, source_frames - 1) @@ -350,6 +355,7 @@ def _frame_indices( "first_source_index": int(indices[0].item()), "last_source_index": int(indices[-1].item()), "grid_tail_hold_frames": grid_tail_hold_frames, + "visible_last_frame_hold_frames": visible_overflow_count, "source_tail_overflow_frames": overflow_count, "source_fps": source_fps, "target_fps": H3_FPS, @@ -398,6 +404,7 @@ def _slice_audio( start_seconds: float, requested_frames: int, aligned_frames: int, + end_policy: str = "hold_last_for_grid", ) -> dict[str, Any] | None: if not isinstance(audio, Mapping) or not torch.is_tensor(audio.get("waveform")): return None @@ -408,16 +415,22 @@ def _slice_audio( start = max(0, int(round(float(start_seconds) * sample_rate))) visible_samples = max(1, int(round(requested_frames / H3_FPS * sample_rate))) aligned_samples = max(visible_samples, int(round(aligned_frames / H3_FPS * sample_rate))) - if start + visible_samples > int(waveform.shape[-1]): + source_samples = int(waveform.shape[-1]) + available_visible_samples = max(0, min(visible_samples, source_samples - start)) + missing_visible_samples = visible_samples - available_visible_samples + if missing_visible_samples and end_policy != "hold_last_visible": raise ValueError( "MiniMax H3 V2V source audio is shorter than the requested visible segment: " - f"need samples {start}:{start + visible_samples}, have {int(waveform.shape[-1])}." + f"need samples {start}:{start + visible_samples}, have {source_samples}. " + "Shorten the Shotboard/Settings PRO duration or select hold_last_visible to pad missing audio with silence." ) # Only the requested programme range may read real source samples. The # 17k+5-only tail is conditioning padding and must be silence; otherwise # the beginning of the next chunk (and possibly its speech) leaks backward # into the current REF2VA/custom-audio conditioning window. - sliced = waveform[..., start : start + visible_samples] + sliced = waveform[..., start : min(start + visible_samples, source_samples)] + if int(sliced.shape[-1]) < visible_samples: + sliced = F.pad(sliced, (0, visible_samples - int(sliced.shape[-1]))) if aligned_samples > visible_samples: sliced = F.pad(sliced, (0, aligned_samples - visible_samples)) return { @@ -428,6 +441,7 @@ def _slice_audio( "iamccs_requested_frames": int(requested_frames), "iamccs_aligned_frames": int(aligned_frames), "iamccs_fps": H3_FPS, + "iamccs_source_visible_pad_samples": int(missing_visible_samples), } @@ -762,6 +776,7 @@ class IAMCCS_MiniMaxH3V2VConditioningR22: start_seconds=source_start, requested_frames=requested, aligned_frames=aligned, + end_policy=config["source_end_policy"], ) references = [resources.get(f"{RESOURCE_PREFIX}reference_image_{index}") for index in range(1, 5)] roles = list(attached.get("reference_roles") or [])[:4] diff --git a/tests/test_h3_duration_truth_and_v2v_end_policy.py b/tests/test_h3_duration_truth_and_v2v_end_policy.py new file mode 100644 index 0000000..484a0ef --- /dev/null +++ b/tests/test_h3_duration_truth_and_v2v_end_policy.py @@ -0,0 +1,168 @@ +import importlib.util +from pathlib import Path +import sys +import types +import unittest + +import torch + + +ROOT = Path(__file__).parents[1] + + +def _load_core(): + spec = importlib.util.spec_from_file_location("iamccs_h3_duration_core_under_test", ROOT / "iamccs_minimax_h3_shotboard_core.py") + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def _load_v2v(core): + package_name = "iamccs_h3_duration_test_package" + package = types.ModuleType(package_name) + package.__path__ = [str(ROOT)] + sys.modules[package_name] = package + sys.modules[f"{package_name}.iamccs_minimax_h3_shotboard_core"] = core + + atomic = types.ModuleType(f"{package_name}.iamccs_minimax_h3_atomic_backend") + atomic._resolve_shotplan = lambda value: value + atomic._run_h3_conditioning_with_cpu_fallback = lambda *args, **kwargs: None + sys.modules[atomic.__name__] = atomic + + linx = types.ModuleType(f"{package_name}.iamccs_supernodes_linx") + linx.build_stage_linx_payload = lambda *args, **kwargs: {} + sys.modules[linx.__name__] = linx + + spec = importlib.util.spec_from_file_location( + f"{package_name}.iamccs_minimax_h3_v2v_backend", + ROOT / "iamccs_minimax_h3_v2v_backend.py", + ) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +CORE = _load_core() +V2V = _load_v2v(CORE) + + +class DurationTruthRegressionTests(unittest.TestCase): + def test_live_frame_length_wins_over_stale_serialized_seconds(self): + result = CORE.build_shotplan( + timeline_data={ + "schema": "iamccs.cine.filmmaker_timeline", + "frame_rate": 24, + "duration_seconds": 6, + "rows": [{ + "id": "edited_range", + "type": "text", + "start": 0, + "length": 144, + "duration_seconds": 124 / 24, + "use_guide": False, + }], + }, + global_prompt="test", + duration_seconds=6, + task_mode="v2va_face_swap", + width=864, + height=480, + ) + self.assertEqual(result["slots"][0]["requested_frame_count"], 144) + + def test_master_duration_clips_stale_row_before_h3_alignment(self): + result = CORE.build_shotplan( + timeline_data={ + "fps": 24, + "duration_seconds": 6, + "rows": [{ + "id": "stale_six_second_row", + "type": "image", + "start": 0, + "length": 144, + "imageFile": "guide.png", + "use_guide": True, + }], + }, + global_prompt="test", + duration_seconds=5, + task_mode="i2va", + width=960, + height=544, + ) + self.assertEqual(result["slots"][0]["requested_frame_count"], 120) + self.assertEqual(result["slots"][0]["frame_count"], 124) + + def test_master_duration_keeps_flf_terminal_image_anchor(self): + result = CORE.build_shotplan( + timeline_data={ + "fps": 24, + "duration_seconds": 6, + "rows": [ + {"id": "a", "type": "image", "start": 0, "length": 12, "imageFile": "a.png", "use_guide": True}, + {"id": "b", "type": "image", "start": 120, "length": 12, "imageFile": "b.png", "use_guide": True}, + ], + }, + global_prompt="test", + duration_seconds=5, + task_mode="fl2va", + width=960, + height=544, + ) + self.assertEqual(len(result["chunks"]), 1) + self.assertEqual(result["chunks"][0]["requested_frame_count"], 120) + self.assertEqual(result["chunks"][0]["last_image"], "b.png") + + def test_recommended_policy_holds_only_the_alignment_tail(self): + indices, report = V2V._frame_indices( + source_frames=124, + source_fps=24, + start_seconds=0, + requested_frames=120, + aligned_frames=124, + end_policy="hold_last_for_grid", + ) + self.assertEqual(int(indices[-1]), 119) + self.assertEqual(report["grid_tail_hold_frames"], 4) + self.assertEqual(report["visible_last_frame_hold_frames"], 0) + + def test_recommended_policy_rejects_a_genuinely_short_visible_source(self): + with self.assertRaisesRegex(ValueError, "requested_frames=120"): + V2V._frame_indices( + source_frames=100, + source_fps=24, + start_seconds=0, + requested_frames=120, + aligned_frames=124, + end_policy="hold_last_for_grid", + ) + + def test_tolerant_policy_declares_video_hold_and_audio_silence(self): + indices, report = V2V._frame_indices( + source_frames=100, + source_fps=24, + start_seconds=0, + requested_frames=120, + aligned_frames=124, + end_policy="hold_last_visible", + ) + self.assertEqual(int(indices[-1]), 99) + self.assertEqual(report["visible_last_frame_hold_frames"], 20) + + audio = {"waveform": torch.ones((1, 2, 1000)), "sample_rate": 240} + padded = V2V._slice_audio( + audio, + start_seconds=0, + requested_frames=120, + aligned_frames=124, + end_policy="hold_last_visible", + ) + self.assertEqual(int(padded["waveform"].shape[-1]), 1240) + self.assertEqual(padded["iamccs_source_visible_pad_samples"], 200) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_h3_face_swap_mask_repair.py b/tests/test_h3_face_swap_mask_repair.py new file mode 100644 index 0000000..2949cf4 --- /dev/null +++ b/tests/test_h3_face_swap_mask_repair.py @@ -0,0 +1,52 @@ +import importlib.util +from pathlib import Path +import sys +import types +import unittest + +import torch + + +ROOT = Path(__file__).parents[1] + + +def _load_face_swap(): + if "folder_paths" not in sys.modules: + folder_paths = types.ModuleType("folder_paths") + folder_paths.get_filename_list = lambda _kind: [] + folder_paths.get_full_path = lambda *_args: None + sys.modules["folder_paths"] = folder_paths + spec = importlib.util.spec_from_file_location("iamccs_h3_face_swap_under_test", ROOT / "iamccs_h3_face_swap.py") + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +FACE_SWAP = _load_face_swap() + + +class FaceSwapMaskRepairTests(unittest.TestCase): + def test_new_audio_is_explicit_opt_in(self): + self.assertFalse(FACE_SWAP.face_swap_settings({})["generate_new_audio"]) + self.assertTrue(FACE_SWAP.face_swap_settings({"h3_faceswap_generate_new_audio": True})["generate_new_audio"]) + + def test_leading_empty_tracking_frames_are_filled_from_first_detection(self): + masks = torch.zeros((6, 4, 4), dtype=torch.float32) + masks[2:, 1:3, 1:3] = 1.0 + repaired, report = FACE_SWAP._fill_empty_mask_frames(masks) + self.assertTrue(torch.equal(repaired[0], masks[2])) + self.assertTrue(torch.equal(repaired[1], masks[2])) + self.assertEqual(report["first_active_before"], 2) + self.assertEqual(report["first_active_after"], 0) + self.assertEqual(report["filled_frames"], 2) + + def test_valid_masks_are_not_rewritten(self): + masks = torch.ones((3, 2, 2), dtype=torch.float32) + repaired, report = FACE_SWAP._fill_empty_mask_frames(masks) + self.assertTrue(torch.equal(repaired, masks)) + self.assertEqual(report["filled_frames"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/iamccs_h3_settings_pro_ui.js b/web/iamccs_h3_settings_pro_ui.js index 300cffe..057286e 100644 --- a/web/iamccs_h3_settings_pro_ui.js +++ b/web/iamccs_h3_settings_pro_ui.js @@ -6,9 +6,9 @@ import { rigMedia } from './iamccs_h3_rig.js'; const NODE_TYPE = "IAMCCS_ShotboardH3SettingsPro"; const SHOTBOARD_TYPE = "IAMCCS_MiniMaxH3ShotPlanner"; const BRIDGE_TYPES = new Set(["IAMCCS_CineH3Input", "IAMCCS_CineH3FunControlInput", "IAMCCS_MiniMaxH3FunControlInput"]); -const SHOTBOARD_OWNED = new Set(["duration_seconds", "task_mode"]); +const SHOTBOARD_OWNED = new Set(["task_mode"]); const AUTO_IMPORT_BLOCKED = new Set([ - "global_prompt", "timeline_data", "image_paths", "duration_seconds", "frame_rate", "audio_mode", + "global_prompt", "timeline_data", "image_paths", "frame_rate", "audio_mode", "guide_policy", "min_guide_gap_seconds", "max_guides", "default_force", "promptrelay_epsilon", "ltx_round_mode", "image_width", "image_height", "image_resize_method", "image_multiple_of", "img_compression", "seed_control_after_generate_compat", "h3_advisor_state", @@ -37,14 +37,14 @@ const GROUPS = [ { id: "continuation", label: "CONTINUATION", title: "AV latent checkpoints", fields: ["h3_continuation_enabled", "h3_continuation_save_enabled", "h3_continuation_checkpoint", "h3_continuation_context_frames", "h3_continuation_handover_mode", "h3_continuation_manual_tail_frames", "h3_continuation_visual_handover", "h3_continuation_run_and_gun_enabled", "h3_continuation_run_and_gun_join", "h3_continuation_soft_video_frames", "h3_continuation_soft_video_curve", "h3_continuation_soft_audio_ms"] }, { id: "refmod", label: "REFMOD", title: "Reference latent library", fields: ["h3_refmod_enabled", "h3_refmod_name", "h3_refmod_strength", "h3_refmod_retention", "h3_refmod_max_tokens"] }, { id: "assistant", label: "MODE ASSISTANT", title: "Guided setup", assistant: true, fields: [] }, - { id: "overview", label: "1 · NATIVE", title: "Native H3 canvas", fields: ["width", "height", "upscale_link_to_native", "upscale_link_factor", "reference_resize_policy", "reference_resize_megapixels", "reference_resize_filter", "prompt_mapping"] }, + { id: "overview", label: "1 · NATIVE", title: "Native H3 canvas and programme duration", fields: ["duration_seconds", "width", "height", "upscale_link_to_native", "upscale_link_factor", "reference_resize_policy", "reference_resize_megapixels", "reference_resize_filter", "prompt_mapping"] }, { id: "audio", label: "2 · AUDIO", title: "Audio authority", fields: ["audio_mode", "reference_audio_role", "voice_reference_picture_index"] }, { id: "memory", label: "3 · MEMORY", title: "VRAM preset", fields: ["performance_profile", "motion_context_window_frames", "text_encoder_device", "h3_exact_profile", "h3_exact_chunk_rows", "h3_exact_precision_mode", "h3_exact_qkv_streaming", "h3_exact_attention_memory", "h3_clipproj_profile", "h3_clipproj_load_mode", "vram_clean_before_decode"] }, { id: "sampling", label: "4 · SAMPLE", title: "Native H3 sampling", fields: ["seed", "seed_policy", "seed_stride", "steps", "sampler_name", "scheduler", "denoise", "shift_video", "shift_audio"] }, { id: "speed", label: "5 · SPEED", title: "Acceleration recipe", fields: ["acceleration", "turbo_mode", "turbo_lora_name", "turbo_strength", "turbo_sampler_mode", "fused_turbo_model_name", "fused_turbo_sigma_preset", "pdd_lora_name", "pdd_strength", "secondary_lora_enabled", "secondary_lora_name", "secondary_lora_strength", "ref_image_size", "sol_conditioning", "spectrum_profile", "h3_sla_sparsity", "h3_sla_dense_last_steps"] }, { id: "direction", label: "6 · DIRECT", title: "Mode-specific contract", fields: ["reference_role_1", "reference_role_2", "reference_role_3", "reference_role_4", "reference_video_role", "v2v_guide_mode", "v2v_source_range_policy", "v2v_source_offset_seconds", "v2v_source_fit", "v2v_source_end_policy", "v2v_audio_pairing", "flf_join_mode", "flf_overlap_frames", "flf_continuity_mode", "flf_continuity_tail_frames", "flf_continuity_audio", "longvid_guide_window_policy", "keyframe_joint_latent_new", "longvid_terminal_endpoint_mode", "longvid_pianosequenza_2stage_enabled"] }, // IAMCCS_LONGVID_ENDPOINT_STRATEGIES_V1 { id: "control", label: "CONTROLNET", title: "H3 Fun ControlNet", contextual: "control", fields: ["h3_controlnet_enabled", "h3_controlnet_name", "h3_controlnet_kind", "h3_controlnet_strength", "h3_controlnet_start_percent", "h3_controlnet_end_percent", "h3_controlnet_frame_scope", "h3_controlnet_end_policy"] }, - { id: "face", label: "SAM3 SWAP", title: "SAM3 Subject Swap", contextual: "face", fields: ["h3_faceswap_sam_model", "h3_faceswap_birefnet_model", "h3_faceswap_mask_prompt", "h3_faceswap_threshold", "h3_faceswap_objects", "h3_faceswap_cleanup_threshold", "h3_faceswap_cleanup_shrink", "h3_faceswap_cleanup_min_frames", "h3_faceswap_cleanup_edge_grow", "h3_faceswap_crop_scale", "h3_faceswap_crop_megapixels", "h3_faceswap_grow_spatial", "h3_faceswap_grow_temporal", "h3_faceswap_feather"] }, + { id: "face", label: "SAM3 SWAP", title: "SAM3 Subject Swap", contextual: "face", fields: ["h3_faceswap_generate_new_audio", "h3_faceswap_sam_model", "h3_faceswap_birefnet_model", "h3_faceswap_mask_prompt", "h3_faceswap_threshold", "h3_faceswap_objects", "h3_faceswap_cleanup_threshold", "h3_faceswap_cleanup_shrink", "h3_faceswap_cleanup_min_frames", "h3_faceswap_cleanup_edge_grow", "h3_faceswap_crop_scale", "h3_faceswap_crop_megapixels", "h3_faceswap_grow_spatial", "h3_faceswap_grow_temporal", "h3_faceswap_feather"] }, { id: "face_refine", label: "FACE REFINE", title: "Face Refinement", fields: ["face_detailer_enabled", "face_detailer_profile", "face_detailer_use_sam_mask"] }, { id: "scout", label: "7 · SCOUT", title: "Candidate seed scout", fields: ["h3_r40_seed_scout_enabled", "h3_r40_candidate_count", "h3_r40_seed_stride", "h3_r40_preview_max_frames", "h3_r40_sparse_enabled", "h3_r40_sparse_video_budget", "h3_r40_sparse_denser_edges"] }, { id: "finish", label: "8 · OUTPUT", title: "Delivery", fields: ["upscale_mode", "upscale_enabled", "upscale_width", "upscale_height", "h3_pixel_tiled_method", "h3_pixel_tiled_model_name", "h3_pixel_tiled_tile_size", "h3_pixel_tiled_overlap", "upscale_prompt", "upscale_sage", "upscale_seed_offset", "wan_upscale_denoise", "ltx_seam_safe", "ltx_detailer_enabled", "ltx_detailer_lora_name", "ltx_detailer_strength", "ltx_4k_enabled", "ltx_4k_quality", "ltx_looper_temporal_tile_size", "ltx_looper_temporal_overlap", "ltx_looper_guiding_strength", "ltx_looper_overlap_strength", "ltx_looper_cond_image_strength", "ltx_looper_horizontal_tiles", "ltx_looper_vertical_tiles", "ltx_looper_spatial_overlap", "h3_upres_model_name", "h3_upres_precision", "h3_upres_device", "h3_upres_keep_models_resident", "h3_upres_steps", "h3_upres_denoise", "h3_upres_sampler", "h3_upres_scheduler", "h3_upres_temporal_chunk", "h3_upres_temporal_overlap", "h3_upres_anchor_strength", "h3_upres_tile_width", "h3_upres_tile_height", "h3_upres_overlap_width", "h3_upres_overlap_height", "h3_upres_fade_width", "h3_upres_fade_height", "h3_upres_min_tile_size", "h3_upres_overlap_mode", "h3_upres_overlap_blend", "h3_upres_rtx_enabled", "h3_upres_rtx_quality", "h3_upres_pixel_groups", "h3_upres_window_frames", "h3_upres_window_overlap", "h3_upres_pixel_method"] }, @@ -89,7 +89,7 @@ const FUNCTIONAL_LAYOUT = { ], }; const MODE_CHOICES = [ - ["AUTO · READ CURRENT SHOTBOARD", "auto_from_shotboard", "Import the board's current generation settings once; Settings PRO then becomes the technical master while Shotboard keeps prompts, guides, images, timeline, duration and audio truth."], + ["AUTO · READ CURRENT SHOTBOARD", "auto_from_shotboard", "Import the board's current generation settings once; Settings PRO then becomes the technical master, including duration, and syncs that duration back to Shotboard. Prompts, guides, media and audio remain Shotboard truth."], ["T2VA · TEXT ONLY", "t2va", "One native H3 shot from prompt only."], ["I2VA · OPENING IMAGE", "i2va", "One image per shot; multiple boxes are independent hard cuts."], ["FL2VA · STABLE KEYFRAMES", "fl2va_stable", "A→B, B→C with authored shared keyframes."], @@ -548,10 +548,35 @@ function setShotboardAudio(node, audioMode) { document.dispatchEvent(new CustomEvent("iamccs:h3-settings-changed", { detail: { source_node_id: node.id, audio_mode: audioMode } })); node._iamccsSettingsProRefresh?.(); return true; } +function setShotboardDuration(node, durationSeconds) { + const duration = Math.max(0.01, Number(durationSeconds) || 0.01); + const board = linkedShotboard(node); + node.properties ||= {}; + node.properties.iamccs_settings_master = true; + node.properties.iamccs_auto_from_shotboard_active = false; + setValue(node, "duration_seconds", duration, false); + if (board) { + setValue(board, "duration_seconds", duration, false); + const timelineWidget = widget(board, "timeline_data"); + if (timelineWidget) { + let data = {}; try { data = JSON.parse(String(timelineWidget.value || "{}")); } catch {} + data.duration_seconds = duration; + data.duration_authority = "settings_pro"; + if (data.timeline && typeof data.timeline === "object") data.timeline.duration_seconds = duration; + if (data.h3_saved_settings && typeof data.h3_saved_settings === "object") data.h3_saved_settings.duration_seconds = duration; + setValue(board, "timeline_data", JSON.stringify(data), false); + } + } + document.dispatchEvent(new CustomEvent("iamccs:h3-settings-changed", { + detail: { source_node_id: node.id, field: "duration_seconds", duration_seconds: duration, authority: "settings_pro" }, + })); + node._iamccsSettingsProRefresh?.(); + return true; +} function mirrorShotboardAuthority(node) { const board = linkedShotboard(node); if (!board) return; const names = node.properties?.iamccs_settings_master - ? ["duration_seconds", "frame_rate", "audio_mode"] + ? ["frame_rate", "audio_mode"] : ["task_mode", "duration_seconds", "frame_rate", "audio_mode"]; for (const name of names) { const source = widget(board, name), target = widget(node, name); @@ -841,6 +866,7 @@ function applyRecipe(node, recipe) { set("h3_controlnet_enabled", true); const asset = firstChoice(node, "h3_controlnet_name", () => true); if (asset) set("h3_controlnet_name", asset); } else if (recipe === "face") { set("face_detailer_enabled", false); set("upscale_enabled", false); + set("h3_faceswap_generate_new_audio", false); set("audio_mode", "h3_custom_audio_drive"); } else if (recipe === "face-refine") { const enabled = !Boolean(widget(node, "face_detailer_enabled")?.value); set("face_detailer_enabled", enabled); @@ -1174,7 +1200,14 @@ function mount(node) { } control.dataset.field = name; control.onchange = () => { const value = control.type === "checkbox" ? control.checked : control.type === "number" ? Number(control.value) : control.value; - if (name === "audio_mode") setShotboardAudio(node, value); + if (name === "duration_seconds") setShotboardDuration(node, value); + else if (name === "audio_mode") setShotboardAudio(node, value); + else if (name === "h3_faceswap_generate_new_audio") { + setValue(node, name, value, false); + // Keep the generic AUDIO panel legible, while the Face Swap backend + // still enforces this explicit switch as its final route authority. + setShotboardAudio(node, value ? "h3_native_generated" : "h3_custom_audio_drive"); + } else if (name === "performance_profile") applyMemoryProfileChoice(node, value); else if (name === "acceleration") applyAccelerationChoice(node, value); else setValue(node, name, value); @@ -1217,6 +1250,13 @@ function mount(node) { if (upscaleMode === "h3_fast_latent_2pass") issues.push(["warn", "Learned 3D lift saves Stage-1 render time, not Stage-2 VRAM. The target-resolution H3 refine still runs; on 12 GB start with a short 124F clip and a modest target canvas. H3 2-stage SAFE DELIVERY skips the HIGH H3 forward."]); } if (modeContext(mode) === "face" && !String(widget(node, "h3_faceswap_sam_model")?.value || "")) issues.push(["error", "SAM3 Subject Swap requires an installed SAM3 checkpoint, unless a complete source mask is connected to the swap input."]); + if (modeContext(mode) === "face") { + const generatesAudio = Boolean(widget(node, "h3_faceswap_generate_new_audio")?.value); + const swapInput = connectedNodes(node).find((candidate) => /H3FaceSwapInput/i.test(nodeClass(candidate))); + const sourceAudio = (swapInput?.inputs || []).find((input) => String(input?.name || "") === "source_audio"); + if (!generatesAudio && sourceAudio?.link == null) issues.push(["error", "SOURCE VIDEO AUDIO is the Face Swap default, but source_audio is not connected. Connect the Load Video audio output, or explicitly enable FACE SWAP · GENERATE NEW AUDIO."]); + if (generatesAudio) issues.push(["warn", "FACE SWAP · GENERATE NEW AUDIO is ON: source-video audio is intentionally ignored and H3 creates a new audio stream."]); + } issues.push(...selectedH3AssetCompatibility(node, mode)); const longvidGuidesMode = String(mode || "").toLowerCase() === "longvid_guides"; const endpoint = String(widget(node, "longvid_terminal_endpoint_mode")?.value || "hard_image").toLowerCase(); @@ -1234,6 +1274,9 @@ function mount(node) { const pixels = Number(widget(node,"width")?.value || 0) * Number(widget(node,"height")?.value || 0); if (pixels > 640 * 384 && selectedGuideWindowStrategy(node) === "guide_macro_362") issues.push(["warn", "High-resolution LongVid is still using the 362F macro window. Use 209F+22F or 124F+22F Adaptive Guide Windows for continuity-first testing."]); } + if (String(widget(node, "v2v_source_end_policy")?.value || "") === "hold_last_visible") { + issues.push(["warn", "V2V short-source tolerance is active: missing visible video is held on the final source frame and missing source audio is padded with silence. Lip-sync cannot continue beyond the real source end."]); + } const memoryContracts = { vram8: [124,2048], vram12: [209,4096], vram16: [294,8192], vram24: [362,16384], }; @@ -1244,7 +1287,7 @@ function mount(node) { function renderRail(mode) { const rail = q(".h3p-rail"); rail.replaceChildren(); const groups = visibleGroups(mode); if (!groups.some((group) => group.id === active)) active = "assistant"; groups.forEach((group) => { const button = document.createElement("button"); button.className = `h3p-tab${active === group.id ? " active" : ""}`; button.textContent = group.label; button.onclick = () => { active = group.id; node.properties ||= {}; node.properties.iamccs_h3_settings_pro_active_section = active; app.graph?.change?.(); refresh(); }; rail.append(button); }); - const owner = document.createElement("div"); owner.className = "h3p-owner"; owner.innerHTML = "OWNERSHIP LOCKShotboard: timeline, prompts, guides, images/video, duration, FPS and audio lanes.

Settings PRO: generation mode, canvas, sampling, memory, acceleration, continuity and delivery. AUTO imports once; it is not a live two-way sync."; rail.append(owner); + const owner = document.createElement("div"); owner.className = "h3p-owner"; owner.innerHTML = "OWNERSHIP LOCKShotboard: prompts, guides, images/video, FPS and audio lanes. Duration is Shotboard truth alone; when Settings PRO is connected as master, its duration is synced into Shotboard and becomes generation truth.

Settings PRO: generation mode, duration when master, canvas, sampling, memory, acceleration, continuity and delivery. AUTO imports once; it is not a live two-way sync."; rail.append(owner); } function recipeGroup(parent) { if (parent?.classList?.contains("h3p-memory-recipes")) return "memory"; @@ -1581,7 +1624,7 @@ function mount(node) { : contextualGate && !contextualGate.available ? `Optional branch is not connected. Values are preserved but muted. ${contextualGate.reason}` : group.contextual ? `Branch connected; controls compile for Shotboard mode ${mode}.` : "Only controls relevant to this render layer are shown."; - q(".h3p-context").innerHTML = `Current Shotboard authority: ${mode}. Mode, media, prompts, duration and FPS remain stored in Shotboard.`; + q(".h3p-context").innerHTML = `Current pipeline: ${mode}. Media, prompts and FPS remain stored in Shotboard. Duration is Shotboard truth alone, or Settings PRO truth synchronized into Shotboard while PRO is master.`; const recipes = q(".h3p-recipes"); recipes.replaceChildren(); if (group.assistant) { renderAssistant(mode); return; } if (active === "speed") { const help = document.createElement("p"); @@ -1707,7 +1750,7 @@ function mount(node) { } function renderTruth(mode) { const assetIssues = selectedH3AssetCompatibility(node, mode); - const values = [["Generation authority", node.properties?.iamccs_settings_master ? "SETTINGS PRO" : "SHOTBOARD / INITIAL"], ["Mode", `${mode} · Settings PRO when connected`], ["Editorial authority", "SHOTBOARD · prompts, guides, media, timeline and audio"], ["Asset compatibility", assetIssues.some(([kind]) => kind === "error") ? "INCOMPATIBLE" : assetIssues.length ? "REVIEW" : "COMPATIBLE"], ["VRAM preset", friendly(widget(node, "performance_profile")?.value)], ["Memory contract", `${widget(node,"motion_context_window_frames")?.value ?? "—"} frames · ${widget(node,"h3_exact_chunk_rows")?.value ?? "—"} rows`], ["Guide window", String(mode) === "longvid_guides" ? selectedGuideWindowStrategy(node) : "N/A"], ["Acceleration engine", friendly(widget(node, "acceleration")?.value)], ["Turbo LoRA", String(widget(node, "turbo_mode")?.value || "off") === "off" && !["fasth3_dense_6step", "h3_sla"].includes(String(widget(node, "acceleration")?.value || "")) ? "OFF" : (widget(node, "turbo_lora_name")?.value || "MISSING")], ["PDD LoRA", widget(node,"pdd_lora_name")?.value || "OFF"], ["Fused model", widget(node, "fused_turbo_model_name")?.value || "OFF"], ["Effective sampling", `${widget(node, "steps")?.value ?? "—"} steps · ${widget(node, "sampler_name")?.value ?? "—"} · ${widget(node, "scheduler")?.value ?? "—"}`], ["Canvas", `${widget(node, "width")?.value ?? "—"} × ${widget(node, "height")?.value ?? "—"}`], ["ControlNet", widget(node, "h3_controlnet_name")?.value || "OFF"], ["Delivery", widget(node, "upscale_enabled")?.value ? widget(node, "upscale_mode")?.value : "NATIVE"]]; + const values = [["Generation authority", node.properties?.iamccs_settings_master ? "SETTINGS PRO" : "SHOTBOARD / INITIAL"], ["Duration authority", node.properties?.iamccs_settings_master ? `SETTINGS PRO · ${widget(node,"duration_seconds")?.value ?? "—"} s · synced to Shotboard` : `SHOTBOARD · ${widget(node,"duration_seconds")?.value ?? "—"} s`], ["Mode", `${mode} · Settings PRO when connected`], ["Editorial authority", "SHOTBOARD · prompts, guides, media, timeline and audio"], ["Asset compatibility", assetIssues.some(([kind]) => kind === "error") ? "INCOMPATIBLE" : assetIssues.length ? "REVIEW" : "COMPATIBLE"], ["VRAM preset", friendly(widget(node, "performance_profile")?.value)], ["Memory contract", `${widget(node,"motion_context_window_frames")?.value ?? "—"} frames · ${widget(node,"h3_exact_chunk_rows")?.value ?? "—"} rows`], ["Guide window", String(mode) === "longvid_guides" ? selectedGuideWindowStrategy(node) : "N/A"], ["Acceleration engine", friendly(widget(node, "acceleration")?.value)], ["Turbo LoRA", String(widget(node, "turbo_mode")?.value || "off") === "off" && !["fasth3_dense_6step", "h3_sla"].includes(String(widget(node, "acceleration")?.value || "")) ? "OFF" : (widget(node, "turbo_lora_name")?.value || "MISSING")], ["PDD LoRA", widget(node,"pdd_lora_name")?.value || "OFF"], ["Fused model", widget(node, "fused_turbo_model_name")?.value || "OFF"], ["Effective sampling", `${widget(node, "steps")?.value ?? "—"} steps · ${widget(node, "sampler_name")?.value ?? "—"} · ${widget(node, "scheduler")?.value ?? "—"}`], ["Canvas", `${widget(node, "width")?.value ?? "—"} × ${widget(node, "height")?.value ?? "—"}`], ["ControlNet", widget(node, "h3_controlnet_name")?.value || "OFF"], ["Delivery", widget(node, "upscale_enabled")?.value ? widget(node, "upscale_mode")?.value : "NATIVE"]]; const list = q(".h3p-truth-list"); list.replaceChildren(); values.forEach(([label, value]) => { const row = document.createElement("div"); row.className = "h3p-truth-row"; const caption = document.createElement("span"), content = document.createElement("b"); caption.textContent = label; content.textContent = String(value ?? "—"); row.append(caption, content); list.append(row); }); const issueList = warnings(mode), health = q(".h3p-health"); health.className = `h3p-health ${issueList.some(([kind]) => kind === "error") ? "error" : issueList.some(([kind]) => kind === "warn") ? "warn" : ""}`; health.innerHTML = issueList.map(([, message]) => `• ${message}`).join("
"); } diff --git a/web/iamccs_minimax_h3_shotboard_ui.js b/web/iamccs_minimax_h3_shotboard_ui.js index 708cade..6e194cf 100644 --- a/web/iamccs_minimax_h3_shotboard_ui.js +++ b/web/iamccs_minimax_h3_shotboard_ui.js @@ -5087,17 +5087,49 @@ function installShotboardPromptMagnifiers(root) { if (!/prompt/.test(hint) || /private note|not sent to promptrelay/.test(hint)) continue; area.dataset.iamccsMagnifierReady = "1"; const wrap = document.createElement("div"); - wrap.style.cssText = "position:relative;min-width:0;width:100%;"; + // Timeline prompt textareas are absolutely positioned inside a + // segment. Transfer their geometry to the wrapper so the lens and + // the editor share the same bounded rectangle. A full-width + // wrapper here overflows the segment's right edge. + const isAbsolute = area.style.position === "absolute"; + wrap.style.cssText = "position:relative;min-width:0;width:100%;box-sizing:border-box;"; + if (isAbsolute) { + wrap.style.position = "absolute"; + for (const key of ["left", "right", "top", "bottom", "height"]) { + if (area.style[key]) wrap.style[key] = area.style[key]; + } + // A very short timeline slot can be narrower than its usual + // left rail. Keep the editor inside that slot as it shrinks. + if (area.style.left && area.style.right) { + wrap.style.left = `min(${area.style.left}, 20%)`; + } + wrap.style.width = "auto"; + area.style.position = "relative"; + area.style.left = "auto"; + area.style.right = "auto"; + area.style.top = "auto"; + area.style.bottom = "auto"; + area.style.height = "100%"; + } else if (area.style.height === "100%") { + wrap.style.height = "100%"; + } area.parentNode.insertBefore(wrap, area); wrap.append(area); area.style.width = "100%"; + area.style.boxSizing = "border-box"; + // Reserve a narrow gutter so prompt text never sits beneath the toggle. + area.style.paddingRight = "22px"; const lens = document.createElement("button"); lens.type = "button"; lens.textContent = "⌕"; lens.title = "Ingrandisci questo prompt · editor 2×"; lens.setAttribute("aria-label", "Ingrandisci il prompt"); - lens.style.cssText = "position:absolute;right:8px;top:7px;z-index:2;width:25px;height:24px;border:1px solid #b98e55;border-radius:5px;background:#261d15;color:#f8d69c;cursor:pointer;font-size:19px;line-height:16px;"; + lens.setAttribute("aria-pressed", "false"); + lens.style.cssText = "position:absolute;right:3px;bottom:3px;z-index:2;width:16px;max-width:calc(100% - 6px);height:16px;padding:0;border:1px solid #b98e55;border-radius:3px;background:#261d15;color:#f8d69c;cursor:pointer;font-size:12px;line-height:13px;text-align:center;"; + lens.onpointerdown = (event) => event.stopPropagation(); lens.onclick = (event) => { event.preventDefault(); event.stopPropagation(); + lens.setAttribute("aria-pressed", "true"); + lens.style.background = "#674522"; const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;z-index:100000;background:rgba(3,5,9,.88);display:grid;place-items:center;"; const panel = document.createElement("div"); @@ -5107,7 +5139,7 @@ function installShotboardPromptMagnifiers(root) { const editor = document.createElement("textarea"); editor.value = area.value; editor.placeholder = area.placeholder; editor.style.cssText = "flex:1;width:100%;min-height:0;padding:16px;background:#080e15;color:#f2e9d9;border:1px solid #526271;border-radius:7px;resize:none;line-height:1.45;font-size:24px;"; editor.oninput = () => { area.value = editor.value; area.dispatchEvent(new Event("input", {bubbles:true})); }; - const close = () => { editor.oninput(); overlay.remove(); area.focus(); area.setSelectionRange(editor.selectionStart, editor.selectionEnd); }; + const close = () => { editor.oninput(); overlay.remove(); lens.setAttribute("aria-pressed", "false"); lens.style.background = "#261d15"; area.focus(); area.setSelectionRange(editor.selectionStart, editor.selectionEnd); }; done.onclick = close; overlay.onclick = (e) => { if (e.target === overlay) close(); }; overlay.onkeydown = (e) => { if (e.key === "Escape") { e.preventDefault(); close(); } }; @@ -11076,7 +11108,7 @@ function renderShotboardV3(node) { promptLabel.textContent = "Global prompt"; promptLabel.style.cssText = "min-width:0;flex:1;"; const durationQuickSlot = document.createElement("div"); - durationQuickSlot.title = "Total MiniMax Shotboard duration. Timeline trims remain the authority for each H3 chunk."; + durationQuickSlot.title = "Total programme duration. This is generation truth without Settings PRO; when Settings PRO is master, its duration is synchronized here. Timeline rows are clipped to this boundary."; durationQuickSlot.style.cssText = `flex:0 0 auto;display:flex;align-items:center;min-width:260px;min-height:32px;padding:3px 7px;border:1px solid #F0B458;border-radius:7px;background:linear-gradient(145deg,rgba(151,92,26,.86),rgba(61,44,31,.94));box-shadow:inset 0 1px 0 rgba(255,255,255,.20),0 0 0 1px rgba(240,180,88,.16),0 3px 10px rgba(0,0,0,.32);`; const durationVisibleBar = document.createElement("div"); durationVisibleBar.setAttribute("role", "group"); @@ -11851,9 +11883,10 @@ function renderShotboardV3(node) { { value: "stretch", label: "Stretch" }, { value: "native_adapt", label: "Native / H3 adapt" }, ]); - addWidgetChoiceSetting("Grid tail", "v2v_source_end_policy", [ - { value: "hold_last_for_grid", label: "Hold only 17k+5 tail" }, - { value: "error", label: "Strict source end" }, + addWidgetChoiceSetting("Source end", "v2v_source_end_policy", [ + { value: "hold_last_for_grid", label: "Recommended · visible strict + hold 17k+5 tail" }, + { value: "hold_last_visible", label: "Tolerant · hold short visible source + silence" }, + { value: "error", label: "Fully strict · require technical tail too" }, ]); addWidgetChoiceSetting("Effective source audio", "v2v_audio_pairing", [ { value: "pair_with_source_video", label: "Pair with