minor bugs fixed

This commit is contained in:
IAMCCS
2026-09-23 18:32:54 +02:00
parent d54dbbca3c
commit 702f8ae692
11 changed files with 659 additions and 77 deletions
+4
View File
@@ -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)",
+75 -7
View File
@@ -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})
+93
View File
@@ -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 <Picture 1>","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'}
+45 -13
View File
@@ -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))
+37 -12
View File
@@ -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,)
+69 -20
View File
@@ -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
+22 -7
View File
@@ -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]
@@ -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()
+52
View File
@@ -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()
+53 -10
View File
@@ -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 = "<strong>OWNERSHIP LOCK</strong>Shotboard: timeline, prompts, guides, images/video, duration, FPS and audio lanes.<br><br>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 = "<strong>OWNERSHIP LOCK</strong>Shotboard: 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.<br><br>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 = `<b>Current Shotboard authority:</b> ${mode}. Mode, media, prompts, duration and FPS remain stored in Shotboard.`;
q(".h3p-context").innerHTML = `<b>Current pipeline:</b> ${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("<br>");
}
+41 -8
View File
@@ -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 <Video 1>" },
@@ -20647,7 +20680,7 @@ function h3SettingsUiLabel(name) {
flf_continuity_mode: "Continuity mode", flf_continuity_tail_frames: "AV tail · frames / seconds",
flf_continuity_audio: "Continue audio", voice_reference_picture_index: "Voice character",
v2v_guide_mode: "Guide stack", v2v_source_range_policy: "Source ranges", v2v_source_offset_seconds: "Source offset (s)",
v2v_source_fit: "Source fit", v2v_source_end_policy: "Grid tail", v2v_audio_pairing: "Source audio",
v2v_source_fit: "Source fit", v2v_source_end_policy: "Source end", v2v_audio_pairing: "Source audio",
ltx_looper_temporal_tile_size: "Looper temporal tile", ltx_looper_temporal_overlap: "Looper temporal overlap",
ltx_looper_guiding_strength: "Looper guide strength", ltx_looper_overlap_strength: "Looper continuity",
ltx_looper_cond_image_strength: "Looper condition image", ltx_looper_horizontal_tiles: "Looper horizontal tiles",