minor bugs fixed
This commit is contained in:
@@ -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
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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'}
|
||||
@@ -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))
|
||||
|
||||
@@ -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,)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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>");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user