release 1.4.7

This commit is contained in:
IAMCCS
2026-05-31 20:43:57 +02:00
parent 6fcbe65045
commit 2e3e02298f
33 changed files with 15283 additions and 303 deletions
+2
View File
@@ -1,5 +1,7 @@
# IAMCCS Nodes - Changelog
## 🆕 2026-05-31 - version 1.4.7 —Add utilities and new functions and bugs fixed
## 🆕 2026-05-20 - version 1.4.6 — Shotboard planner v2 and v3 added
## 🆕 2026-05-12 - version 1.4.5 — Cine nodes added
+2
View File
@@ -9,6 +9,8 @@
### Category: ComfyUI Custom Nodes
### Main Feature: Fix for LoRA loading in native WANAnimate workflows + general nodes 4 ComfyUI
## Version: 1.4.7 (New functions, utilities and bug fixed)
## Version: 1.4.6 (Shotboard planner v2 and v3 added)
Version: 1.4.5 (Cine nodes added)
+194 -41
View File
@@ -259,6 +259,29 @@ from .iamccs_audio_extender import (
IAMCCS_AudioTimelineGate,
)
from .iamccs_cine_audio_dialogue import (
IAMCCS_CineSpeech1PromptCompiler,
IAMCCS_CineVideoToWooshInputs,
IAMCCS_CineTimelineAudioMixer,
IAMCCS_AudioBoardArranger,
IAMCCS_CineDialogueLineRouter,
IAMCCS_CineInfo3,
IAMCCS_BoardMaker_DialogueFoley,
IAMCCS_CineSpeechLength,
IAMCCS_CineDialogueDurationPlanner,
IAMCCS_CineAudioDurationProbe,
IAMCCS_CineDialogueTimingReconciler,
IAMCCS_CineWooshFoleyChunkPlanner,
IAMCCS_CineFinalAudioMixer,
IAMCCS_CineEmotionButtons,
IAMCCS_CineDialoguePromptKit,
)
# [1.4.7] au_tools excluded from this release — will be re-enabled in 1.4.8
# from .au_tools.audio_bus_out import IAMCCS_BusOut
# from .au_tools.audio_board_mixer import IAMCCS_AudioBoardMixer
# from .au_tools.audio_control_efx import IAMCCS_ControlAudEfx
# from .au_tools.dialogue_script_planner import IAMCCS_DialogueScriptPlanner
from .iamccs_ltx2_segment_queue import (
IAMCCS_LTX2_BlendLatentBridge,
IAMCCS_LTX2_LastFrameBridgeLoad,
@@ -512,6 +535,26 @@ NODE_CLASS_MAPPINGS = {
"IAMCCS_AudioExtender": IAMCCS_AudioExtender,
"IAMCCS_AudioTimelineAssembler": IAMCCS_AudioTimelineAssembler,
"IAMCCS_AudioTimelineGate": IAMCCS_AudioTimelineGate,
"IAMCCS_BoardMaker_DialogueFoley": IAMCCS_BoardMaker_DialogueFoley,
"IAMCCS_CineInfo3": IAMCCS_CineInfo3,
"IAMCCS_CineDialogueLineRouter": IAMCCS_CineDialogueLineRouter,
"IAMCCS_CineTimelineAudioMixer": IAMCCS_CineTimelineAudioMixer,
"IAMCCS_AudioBoardArranger": IAMCCS_AudioBoardArranger,
# [1.4.7] au_tools excluded — re-enable in 1.4.8
# "IAMCCS_BusOut": IAMCCS_BusOut,
# "IAMCCS_AudioBoardMixer": IAMCCS_AudioBoardMixer,
# "IAMCCS_ControlAudEfx": IAMCCS_ControlAudEfx,
# "IAMCCS_DialogueScriptPlanner": IAMCCS_DialogueScriptPlanner,
"IAMCCS_CineVideoToWooshInputs": IAMCCS_CineVideoToWooshInputs,
"IAMCCS_CineSpeech1PromptCompiler": IAMCCS_CineSpeech1PromptCompiler,
"IAMCCS_CineSpeechLength": IAMCCS_CineSpeechLength,
"IAMCCS_CineDialogueDurationPlanner": IAMCCS_CineDialogueDurationPlanner,
"IAMCCS_CineAudioDurationProbe": IAMCCS_CineAudioDurationProbe,
"IAMCCS_CineDialogueTimingReconciler": IAMCCS_CineDialogueTimingReconciler,
"IAMCCS_CineWooshFoleyChunkPlanner": IAMCCS_CineWooshFoleyChunkPlanner,
"IAMCCS_CineFinalAudioMixer": IAMCCS_CineFinalAudioMixer,
"IAMCCS_CineEmotionButtons": IAMCCS_CineEmotionButtons,
"IAMCCS_CineDialoguePromptKit": IAMCCS_CineDialoguePromptKit,
"IAMCCS_LTX2_LastFrameBridgeSave": IAMCCS_LTX2_LastFrameBridgeSave,
"IAMCCS_LTX2_BlendLatentBridge": IAMCCS_LTX2_BlendLatentBridge,
"IAMCCS_LTX2_LastFrameBridgeLoad": IAMCCS_LTX2_LastFrameBridgeLoad,
@@ -775,6 +818,26 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_AudioExtender": "Audio Extender (segment + overlap)",
"IAMCCS_AudioTimelineAssembler": "Audio Timeline Assembler (full track)",
"IAMCCS_AudioTimelineGate": "Audio Timeline Gate (continue/stop)",
"IAMCCS_BoardMaker_DialogueFoley": "IAMCCS BoardMaker Dialogue Foley",
"IAMCCS_CineInfo3": "IAMCCS Cine Info 3",
"IAMCCS_CineDialogueLineRouter": "IAMCCS Dialogue Line Router",
"IAMCCS_CineTimelineAudioMixer": "IAMCCS Timeline Audio Mixer",
"IAMCCS_AudioBoardArranger": "IAMCCS AudioBoard Arranger",
# [1.4.7] au_tools excluded — re-enable in 1.4.8
# "IAMCCS_BusOut": "IAMCCS BusOut",
# "IAMCCS_AudioBoardMixer": "IAMCCS AudioBoard Mixer",
# "IAMCCS_ControlAudEfx": "IAMCCS ControlAudEfx",
# "IAMCCS_DialogueScriptPlanner": "IAMCCS DialogueScript Planner",
"IAMCCS_CineVideoToWooshInputs": "IAMCCS Video To Woosh Inputs",
"IAMCCS_CineSpeech1PromptCompiler": "IAMCCS Speech1 Prompt Compiler",
"IAMCCS_CineSpeechLength": "IAMCCS Speech Length Calculator",
"IAMCCS_CineDialogueDurationPlanner": "IAMCCS Dialogue Duration Planner",
"IAMCCS_CineAudioDurationProbe": "IAMCCS Audio Duration Probe",
"IAMCCS_CineDialogueTimingReconciler": "IAMCCS Dialogue Timing Reconciler",
"IAMCCS_CineWooshFoleyChunkPlanner": "IAMCCS Woosh Foley Chunk Planner",
"IAMCCS_CineFinalAudioMixer": "IAMCCS Final Audio Mixer",
"IAMCCS_CineEmotionButtons": "IAMCCS Emotion Buttons",
"IAMCCS_CineDialoguePromptKit": "IAMCCS Dialogue Prompt Kit",
"IAMCCS_LTX2_LastFrameBridgeSave": "LTX-2 Last Frame Bridge Save 🖼️💾",
"IAMCCS_LTX2_BlendLatentBridge": "LTX-2 Blend Latent Bridge 🎚️",
"IAMCCS_LTX2_LastFrameBridgeLoad": "LTX-2 Last Frame Bridge Load 🖼️",
@@ -793,6 +856,28 @@ NODE_DISPLAY_NAME_MAPPINGS = {
}
# [1.4.7] cine_wan_edition_beta excluded from this release — will be re-enabled in 1.4.8
# try:
# from .cine_wan_edition_beta import (
# NODE_CLASS_MAPPINGS as _IAMCCS_WAN_BETA_NODE_CLASS_MAPPINGS,
# NODE_DISPLAY_NAME_MAPPINGS as _IAMCCS_WAN_BETA_NODE_DISPLAY_NAME_MAPPINGS,
# )
# NODE_CLASS_MAPPINGS.update(_IAMCCS_WAN_BETA_NODE_CLASS_MAPPINGS)
# NODE_DISPLAY_NAME_MAPPINGS.update(_IAMCCS_WAN_BETA_NODE_DISPLAY_NAME_MAPPINGS)
# except Exception as _iamccs_wan_beta_error:
# print(f"[IAMCCS WAN BETA] optional module not loaded: {_iamccs_wan_beta_error}")
# [1.4.7] cine_multigeneration excluded from this release — will be re-enabled in 1.4.8
# try:
# from .cine_multigeneration import (
# NODE_CLASS_MAPPINGS as _IAMCCS_MULTIGENERATION_NODE_CLASS_MAPPINGS,
# NODE_DISPLAY_NAME_MAPPINGS as _IAMCCS_MULTIGENERATION_NODE_DISPLAY_NAME_MAPPINGS,
# )
# NODE_CLASS_MAPPINGS.update(_IAMCCS_MULTIGENERATION_NODE_CLASS_MAPPINGS)
# NODE_DISPLAY_NAME_MAPPINGS.update(_IAMCCS_MULTIGENERATION_NODE_DISPLAY_NAME_MAPPINGS)
# except Exception as _iamccs_multigeneration_error:
# print(f"[IAMCCS Multigeneration] optional module not loaded: {_iamccs_multigeneration_error}")
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
@@ -857,7 +942,11 @@ def setup_api_routes() -> None:
return web.Response(status=400, text="Unsupported image extension")
if not os.path.exists(path) or not os.path.isfile(path):
return web.Response(status=404, text="Image not found")
return web.FileResponse(path)
response = web.FileResponse(path)
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
except Exception as e:
return web.Response(status=500, text=str(e))
@@ -882,13 +971,46 @@ def setup_api_routes() -> None:
except Exception:
return int(default)
def _inside_dir(path, root):
try:
rel = os.path.relpath(os.path.abspath(path), os.path.abspath(root))
return rel != os.pardir and not rel.startswith(os.pardir + os.sep) and not os.path.isabs(rel)
except Exception:
return False
def _input_relative(path, input_root):
try:
if not _inside_dir(path, input_root):
return None
return os.path.relpath(os.path.abspath(path), os.path.abspath(input_root)).replace(os.sep, "/")
except Exception:
return None
def _safe_paste(canvas, layer, x, y):
x = int(round(x))
y = int(round(y))
src_l = max(0, -x)
src_t = max(0, -y)
dst_l = max(0, x)
dst_t = max(0, y)
w = min(layer.size[0] - src_l, canvas.size[0] - dst_l)
h = min(layer.size[1] - src_t, canvas.size[1] - dst_t)
if w <= 0 or h <= 0:
return
crop = layer.crop((src_l, src_t, src_l + w, src_t + h))
if crop.mode == "RGBA":
canvas.paste(crop.convert("RGB"), (dst_l, dst_t), crop.getchannel("A"))
else:
canvas.paste(crop.convert("RGB"), (dst_l, dst_t))
data = await request.json()
source = str(data.get("path") or data.get("source_path") or "").strip()
if not source:
return web.json_response({"error": "Missing source path"}, status=400)
input_dir = folder_paths.get_input_directory()
if os.path.isabs(source):
source_is_absolute = os.path.isabs(source)
if source_is_absolute:
source_path = os.path.abspath(os.path.expanduser(source))
else:
source_path = os.path.abspath(os.path.join(input_dir, source.replace("/", os.sep)))
@@ -913,16 +1035,16 @@ def setup_api_routes() -> None:
"lanczos": Image.Resampling.LANCZOS,
}.get(resample_name, Image.Resampling.LANCZOS)
crop_box = None
transform_mode = "composite"
with Image.open(source_path) as im:
im = ImageOps.exif_transpose(im).convert("RGB")
if abs(rotation) > 0.001:
fill = tuple(int(v) for v in im.resize((1, 1), Image.Resampling.BILINEAR).getpixel((0, 0)))
im = im.rotate(-rotation, resample=Image.Resampling.BICUBIC, expand=True, fillcolor=fill)
im = ImageOps.exif_transpose(im).convert("RGBA")
src_w, src_h = im.size
crop_box = None
fill = tuple(int(v) for v in im.convert("RGB").resize((1, 1), Image.Resampling.BILINEAR).getpixel((0, 0)))
preview_crop_box = None
raw_crop_box = data.get("crop_box")
if isinstance(raw_crop_box, (list, tuple)) and len(raw_crop_box) >= 4:
if abs(rotation) < 0.001 and isinstance(raw_crop_box, (list, tuple)) and len(raw_crop_box) >= 4:
try:
l = max(0.0, min(float(src_w - 1), float(raw_crop_box[0])))
t = max(0.0, min(float(src_h - 1), float(raw_crop_box[1])))
@@ -935,48 +1057,75 @@ def setup_api_routes() -> None:
preview_crop_box = (left_i, top_i, right_i, bottom_i)
except Exception:
preview_crop_box = None
if preview_crop_box is not None:
crop_box = preview_crop_box
out = im.crop(crop_box).resize((target_w, target_h), resampling)
elif fit_mode == "contain":
bg = Image.new("RGB", (target_w, target_h), tuple(int(v) for v in im.resize((1, 1), Image.Resampling.BILINEAR).getpixel((0, 0))))
fitted = ImageOps.contain(im, (target_w, target_h), method=resampling)
paste_x = int(round((target_w - fitted.size[0]) / 2.0))
paste_y = int(round((target_h - fitted.size[1]) / 2.0))
bg.paste(fitted, (paste_x, paste_y))
out = bg
out = im.crop(crop_box).convert("RGB").resize((target_w, target_h), resampling)
transform_mode = "ui_preview_crop"
else:
aspect = target_w / float(target_h)
if src_w / float(src_h) >= aspect:
base_h = src_h
base_w = base_h * aspect
else:
base_w = src_w
base_h = base_w / aspect
crop_w = max(1.0, min(src_w, base_w / zoom))
crop_h = max(1.0, min(src_h, base_h / zoom))
max_shift_x = max(0.0, (src_w - crop_w) / 2.0)
max_shift_y = max(0.0, (src_h - crop_h) / 2.0)
center_x = src_w / 2.0 + pan_x * max_shift_x
center_y = src_h / 2.0 + pan_y * max_shift_y
left = max(0.0, min(src_w - crop_w, center_x - crop_w / 2.0))
top = max(0.0, min(src_h - crop_h, center_y - crop_h / 2.0))
crop_box = (int(round(left)), int(round(top)), int(round(left + crop_w)), int(round(top + crop_h)))
out = im.crop(crop_box).resize((target_w, target_h), resampling)
fit_scale = min(target_w / float(src_w), target_h / float(src_h)) if fit_mode == "contain" else max(target_w / float(src_w), target_h / float(src_h))
scale = max(0.0001, fit_scale * zoom)
display_w = max(1, int(round(src_w * scale)))
display_h = max(1, int(round(src_h * scale)))
max_shift_x = max(0.0, (display_w - target_w) / 2.0)
max_shift_y = max(0.0, (display_h - target_h) / 2.0)
left = (target_w - display_w) / 2.0 + pan_x * max_shift_x
top = (target_h - display_h) / 2.0 + pan_y * max_shift_y
layer = im.resize((display_w, display_h), resampling)
if abs(rotation) > 0.001:
center_x = left + display_w / 2.0
center_y = top + display_h / 2.0
layer = layer.rotate(-rotation, resample=Image.Resampling.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0))
left = center_x - layer.size[0] / 2.0
top = center_y - layer.size[1] / 2.0
out = Image.new("RGB", (target_w, target_h), fill)
_safe_paste(out, layer, left, top)
crop_box = {
"display_left": left,
"display_top": top,
"display_width": layer.size[0],
"display_height": layer.size[1],
"source_width": src_w,
"source_height": src_h,
}
source_dir = os.path.dirname(source_path)
fallback_dir = os.path.join(input_dir, "IAMCCS_newimages")
source_subdir = os.path.dirname(source.replace("/", os.sep)) if not source_is_absolute else ""
if source_dir and os.path.isdir(source_dir):
out_dir = source_dir
elif source_subdir:
out_dir = os.path.join(input_dir, source_subdir)
else:
out_dir = fallback_dir
try:
os.makedirs(out_dir, exist_ok=True)
test_path = os.path.join(out_dir, ".iamccs_write_test")
with open(test_path, "w", encoding="utf-8") as test_file:
test_file.write("ok")
try:
os.remove(test_path)
except Exception:
pass
except Exception:
out_dir = fallback_dir
os.makedirs(out_dir, exist_ok=True)
out_dir = os.path.join(input_dir, "IAMCCS_newimages")
os.makedirs(out_dir, exist_ok=True)
stem = os.path.splitext(os.path.basename(source_path))[0]
stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", stem).strip("._")[:60] or "cine_ref"
filename = f"{stem}_cinecrop_{int(time.time() * 1000)}.png"
out_path = os.path.join(out_dir, filename)
filename = f"{stem}_cineedit_{int(time.time() * 1000)}.png"
out_path = os.path.abspath(os.path.join(out_dir, filename))
out.save(out_path, "PNG", optimize=True)
rel_path = "IAMCCS_newimages/" + filename
rel_path = _input_relative(out_path, input_dir)
ui_path = out_path
metadata = {
"source_path": source_path,
"edited_path": out_path,
"path": out_path,
"display_path": out_path,
"relative_path": rel_path,
"project_adjacent": os.path.abspath(out_dir) == os.path.abspath(source_dir),
"transform": {
"width": target_w,
"height": target_h,
@@ -986,8 +1135,9 @@ def setup_api_routes() -> None:
"pan_y": pan_y,
"rotation": rotation,
"resample": resample_name,
"crop_box_after_rotation": crop_box,
"crop_box_source": data.get("crop_box_source") or ("ui_preview" if preview_crop_box is not None else "backend_formula"),
"mode": transform_mode,
"crop_box": crop_box,
"crop_box_source": data.get("crop_box_source") or ("ui_preview" if transform_mode == "ui_preview_crop" else "backend_composite"),
},
}
with open(out_path + ".json", "w", encoding="utf-8") as meta_file:
@@ -995,8 +1145,11 @@ def setup_api_routes() -> None:
return web.json_response({
"ok": True,
"path": rel_path,
"path": out_path,
"display_path": out_path,
"relative_path": rel_path,
"absolute_path": out_path,
"cache_bust": int(time.time() * 1000),
"metadata": metadata,
})
except Exception as e:
+21
View File
@@ -0,0 +1,21 @@
from .audio_board_arranger import IAMCCS_AudioBoardArranger
from .audio_bus_out import IAMCCS_BusOut
from .audio_board_mixer import IAMCCS_AudioBoardMixer
from .audio_control_efx import IAMCCS_ControlAudEfx
from .dialogue_script_planner import IAMCCS_DialogueScriptPlanner
NODE_CLASS_MAPPINGS = {
"IAMCCS_AudioBoardArranger": IAMCCS_AudioBoardArranger,
"IAMCCS_BusOut": IAMCCS_BusOut,
"IAMCCS_AudioBoardMixer": IAMCCS_AudioBoardMixer,
"IAMCCS_ControlAudEfx": IAMCCS_ControlAudEfx,
"IAMCCS_DialogueScriptPlanner": IAMCCS_DialogueScriptPlanner,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_AudioBoardArranger": "IAMCCS AudioBoard Arranger",
"IAMCCS_BusOut": "IAMCCS BusOut",
"IAMCCS_AudioBoardMixer": "IAMCCS AudioBoard Mixer",
"IAMCCS_ControlAudEfx": "IAMCCS ControlAudEfx",
"IAMCCS_DialogueScriptPlanner": "IAMCCS DialogueScript Planner",
}
Binary file not shown.
Binary file not shown.
+405
View File
@@ -0,0 +1,405 @@
from __future__ import annotations
import copy
import json
from typing import Any, Dict, List
SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX"
def _safe_json_loads(value: Any, fallback: Any) -> Any:
if isinstance(value, (dict, list)):
return value
try:
return json.loads(str(value or ""))
except Exception:
return fallback
def _json_report(data: Dict[str, Any]) -> str:
return json.dumps(data, indent=2, ensure_ascii=False)
def _clone_linx(cine_linx: Any) -> Dict[str, Any]:
return copy.deepcopy(cine_linx) if isinstance(cine_linx, dict) else {
"type": SUPERNODE_LINX_TYPE,
"mode": "iamccs_audio_board_arranger",
"resources": {},
"outputs": {},
"chain": [],
"stages": [],
}
def _resources(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = cine_linx.setdefault("resources", {})
if not isinstance(resources, dict):
resources = {}
cine_linx["resources"] = resources
return resources
def _outputs(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
outputs = cine_linx.setdefault("outputs", {})
if not isinstance(outputs, dict):
outputs = {}
cine_linx["outputs"] = outputs
return outputs
def _payload(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = _resources(cine_linx)
payload = resources.get("cine_payload")
if not isinstance(payload, dict):
payload = {}
resources["cine_payload"] = payload
return payload
def _refresh_linx_index(cine_linx: Dict[str, Any]) -> None:
resources = _resources(cine_linx)
cine_linx["resource_keys"] = sorted(resources.keys())
cine_linx["resource_types"] = {key: type(value).__name__ for key, value in resources.items()}
def _safe_float(value: Any, fallback: float = 0.0) -> float:
try:
return float(value)
except Exception:
return float(fallback)
def _safe_int(value: Any, fallback: int = 0) -> int:
try:
return int(round(float(value)))
except Exception:
return int(fallback)
class IAMCCS_AudioBoardArranger:
"""DAW-style audio lane arranger that writes Shotboard V3 audioSegments through cine_linx."""
DEFAULT_DATA = json.dumps({
"schema": "iamccs.audio_board_arranger",
"schema_version": 1,
"audioSegments": [],
"audioTrackCount": 3,
"masterAudioGain": 1.0,
"masterAudioNormalize": False,
"masterBus": {
"limiter": True,
"ceilingDb": -1.0,
"compressor": 0.0,
"width": 1.0,
"reverbSend": 0.0,
"delaySend": 0.0,
},
"audioBusMode": "all_tracks",
"onlyFirstTrack": False,
"audioSyncMode": "timeline_audio",
"duration_seconds": 26.0,
"frame_rate": 24.0,
"status": {"edits": []},
"view": {"timeZoom": 1.0, "trackHeight": 64},
}, indent=2)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"arranger_data": ("STRING", {
"default": cls.DEFAULT_DATA,
"multiline": True,
"tooltip": "Edited by the IAMCCS AudioBoardArranger UI. Uses Shotboard V3 audioSegments schema.",
}),
"frame_rate": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}),
"sync_policy": ([
"audio_lanes_drive_custom_audio",
"metadata_only_report",
], {"default": "audio_lanes_drive_custom_audio"}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
},
}
RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "STRING")
RETURN_NAMES = ("cine_linx", "audio_timeline_json", "report")
FUNCTION = "arrange"
CATEGORY = "IAMCCS/Cine/Audio"
@staticmethod
def _segments(data: Any) -> List[Dict[str, Any]]:
raw = data.get("audioSegments", []) if isinstance(data, dict) else []
if not isinstance(raw, list):
return []
out: List[Dict[str, Any]] = []
for index, item in enumerate(raw):
if not isinstance(item, dict):
continue
seg = dict(item)
seg["id"] = str(seg.get("id") or f"aud_{index + 1:03d}")
seg["type"] = "audio"
seg["start"] = max(0, _safe_int(seg.get("start", 0), 0))
seg["length"] = max(1, _safe_int(seg.get("length", seg.get("audioDurationFrames", 1)), 1))
seg["track"] = max(0, _safe_int(seg.get("track", 0), 0))
seg["trimStart"] = max(0, _safe_int(seg.get("trimStart", 0), 0))
seg["audioDurationFrames"] = max(seg["length"], _safe_int(seg.get("audioDurationFrames", seg["length"]), seg["length"]))
seg["gain"] = max(0.0, min(4.0, _safe_float(seg.get("gain", seg.get("volume", 1.0)), 1.0)))
seg["pan"] = max(-1.0, min(1.0, _safe_float(seg.get("pan", 0.0), 0.0)))
seg["fadeInFrames"] = max(0, _safe_int(seg.get("fadeInFrames", 0), 0))
seg["fadeOutFrames"] = max(0, _safe_int(seg.get("fadeOutFrames", 0), 0))
seg["normalizeAudio"] = bool(seg.get("normalizeAudio", False))
seg["mute"] = bool(seg.get("mute", False))
seg["solo"] = bool(seg.get("solo", False))
seg["purpose"] = str(seg.get("purpose", "dialogue_or_music") or "dialogue_or_music")
out.append(seg)
return sorted(out, key=lambda item: (int(item.get("track", 0)), int(item.get("start", 0))))
@staticmethod
def _merge_audio_into_timeline(timeline_text: Any, data: Dict[str, Any], fps: float) -> str:
timeline = _safe_json_loads(timeline_text, {})
if not isinstance(timeline, dict):
timeline = {}
timeline.setdefault("schema", "iamccs.cine.filmmaker_timeline")
timeline["schema_version"] = max(2, _safe_int(timeline.get("schema_version", 2), 2))
segments = IAMCCS_AudioBoardArranger._segments(data)
timeline["audioSegments"] = segments
timeline["audioTrackCount"] = max(1, _safe_int(data.get("audioTrackCount", 3), 3))
timeline["masterAudioGain"] = max(0.0, min(2.0, _safe_float(data.get("masterAudioGain", 1.0), 1.0)))
timeline["masterAudioNormalize"] = bool(data.get("masterAudioNormalize", False))
timeline["masterBus"] = data.get("masterBus") if isinstance(data.get("masterBus"), dict) else {}
timeline["trackSettings"] = data.get("trackSettings") if isinstance(data.get("trackSettings"), list) else []
timeline["audioBusMode"] = str(data.get("audioBusMode", "all_tracks") or "all_tracks")
timeline["onlyFirstTrack"] = bool(data.get("onlyFirstTrack", False))
timeline["audioSyncMode"] = str(data.get("audioSyncMode", "timeline_audio") or "timeline_audio")
timeline["use_custom_audio"] = any(str(seg.get("audioFile", "")).strip() or str(seg.get("audioB64", "")).strip() for seg in segments)
timeline["frame_rate"] = _safe_float(timeline.get("frame_rate", data.get("frame_rate", fps)), fps)
max_end = max([_safe_int(seg.get("start", 0), 0) + _safe_int(seg.get("length", 1), 1) for seg in segments] or [0])
visual_segments = timeline.get("segments", [])
visual_end = max([
_safe_int(seg.get("start", seg.get("frame", 0)), 0) + _safe_int(seg.get("length", seg.get("len", 1)), 1)
for seg in visual_segments
if isinstance(seg, dict) and str(seg.get("type", "image") or "image").lower() != "audio"
] or [0]) if isinstance(visual_segments, list) else 0
current_duration = _safe_float(timeline.get("duration_seconds", data.get("duration_seconds", 0.0)), 0.0)
if max_end > 0:
segment_duration = max(max_end, visual_end) / max(1.0, float(fps))
if current_duration > 0 and current_duration <= segment_duration * 1.35:
timeline["duration_seconds"] = max(current_duration, segment_duration)
else:
timeline["duration_seconds"] = segment_duration
audio_data = {
"audioSegments": segments,
"audioTrackCount": timeline["audioTrackCount"],
"use_custom_audio": timeline["use_custom_audio"],
"masterAudioGain": timeline["masterAudioGain"],
"masterAudioNormalize": timeline["masterAudioNormalize"],
"masterBus": timeline["masterBus"],
"trackSettings": timeline["trackSettings"],
"audioBusMode": timeline["audioBusMode"],
"onlyFirstTrack": timeline["onlyFirstTrack"],
"audioSyncMode": timeline["audioSyncMode"],
}
timeline["audio_data"] = json.dumps(audio_data, ensure_ascii=False)
return json.dumps(timeline, ensure_ascii=False, indent=2)
def arrange(self, arranger_data, frame_rate, sync_policy, cine_linx=None):
data = _safe_json_loads(arranger_data, {})
if not isinstance(data, dict):
data = _safe_json_loads(self.DEFAULT_DATA, {})
fps = max(1.0, float(frame_rate))
source_segments = self._segments(data)
source_track_count = max(1, _safe_int(data.get("audioTrackCount", 3), 3))
track_settings = data.get("trackSettings") if isinstance(data.get("trackSettings"), list) else []
bus_mode_raw = str(data.get("audioBusMode", "all_tracks") or "all_tracks").strip().lower()
only_first = bool(data.get("onlyFirstTrack", False)) or bus_mode_raw in {"only_first", "shotboard_only_first", "first_track", "first"}
bus_mode = "all_tracks"
shotboard_mode = "only_first" if only_first else "all_tracks"
segments = source_segments
shotboard_segments = [seg for seg in source_segments if _safe_int(seg.get("track", 0), 0) == 0] if only_first else source_segments
export_track_count = source_track_count
shotboard_track_count = 1 if only_first else source_track_count
export_track_settings = copy.deepcopy(track_settings)
shotboard_track_settings = copy.deepcopy(track_settings[:1]) if only_first else copy.deepcopy(track_settings)
full_data = copy.deepcopy(data)
full_data["audioSegments"] = source_segments
full_data["audioTrackCount"] = source_track_count
full_data["audioBusMode"] = "only_first" if only_first else "all_tracks"
full_data["onlyFirstTrack"] = only_first
data = copy.deepcopy(data)
data["audioSegments"] = segments
data["audioTrackCount"] = export_track_count
data["trackSettings"] = export_track_settings
data["audioBusMode"] = bus_mode
data["onlyFirstTrack"] = False
data["frame_rate"] = fps
shotboard_data = copy.deepcopy(data)
shotboard_data["audioSegments"] = shotboard_segments
shotboard_data["audioTrackCount"] = shotboard_track_count
shotboard_data["trackSettings"] = shotboard_track_settings
shotboard_data["audioBusMode"] = "shotboard_only_first" if only_first else "all_tracks"
shotboard_data["onlyFirstTrack"] = only_first
audio_timeline = {
"audioSegments": segments,
"audioTrackCount": export_track_count,
"masterAudioGain": max(0.0, min(2.0, _safe_float(data.get("masterAudioGain", 1.0), 1.0))),
"masterAudioNormalize": bool(data.get("masterAudioNormalize", False)),
"masterBus": data.get("masterBus") if isinstance(data.get("masterBus"), dict) else {},
"trackSettings": export_track_settings,
"audioBusMode": bus_mode,
"onlyFirstTrack": False,
"shotboardAudioSegments": shotboard_segments,
"shotboardAudioTrackCount": shotboard_track_count,
"shotboardAudioMode": shotboard_mode,
"audioSyncMode": str(data.get("audioSyncMode", "timeline_audio") or "timeline_audio"),
}
shotboard_audio_timeline = copy.deepcopy(audio_timeline)
shotboard_audio_timeline["audioSegments"] = shotboard_segments
shotboard_audio_timeline["audioTrackCount"] = shotboard_track_count
shotboard_audio_timeline["trackSettings"] = shotboard_track_settings
shotboard_audio_timeline["audioBusMode"] = "shotboard_only_first" if only_first else "all_tracks"
shotboard_audio_timeline["onlyFirstTrack"] = only_first
audio_timeline_json = json.dumps(shotboard_audio_timeline, ensure_ascii=False, indent=2)
bus_audio_timeline_json = json.dumps(audio_timeline, ensure_ascii=False, indent=2)
effect_graph = {
"schema": "iamccs.audio_effect_graph",
"schema_version": 1,
"clips": [{
"id": str(seg.get("id", "")),
"track": _safe_int(seg.get("track", 0), 0),
"start": _safe_int(seg.get("start", 0), 0),
"length": _safe_int(seg.get("length", 1), 1),
"linkedVisualId": str(seg.get("linkedVisualId", "") or ""),
"effects": {
"gain": _safe_float(seg.get("gain", 1.0), 1.0),
"pan": _safe_float(seg.get("pan", 0.0), 0.0),
"hpfHz": _safe_float(seg.get("hpfHz", 0.0), 0.0),
"lpfHz": _safe_float(seg.get("lpfHz", 22000.0), 22000.0),
"eqLowDb": _safe_float(seg.get("eqLowDb", 0.0), 0.0),
"eqMidDb": _safe_float(seg.get("eqMidDb", 0.0), 0.0),
"eqHighDb": _safe_float(seg.get("eqHighDb", 0.0), 0.0),
"compressor": _safe_float(seg.get("compressor", 0.0), 0.0),
"noiseGateDb": _safe_float(seg.get("noiseGateDb", -60.0), -60.0),
"ducking": _safe_float(seg.get("ducking", 0.0), 0.0),
"reverbSend": _safe_float(seg.get("reverbSend", 0.0), 0.0),
"delaySend": _safe_float(seg.get("delaySend", 0.0), 0.0),
"stereoWidth": _safe_float(seg.get("stereoWidth", 1.0), 1.0),
"transient": _safe_float(seg.get("transient", 0.0), 0.0),
"denoise": _safe_float(seg.get("denoise", 0.0), 0.0),
},
} for seg in segments],
"masterBus": audio_timeline["masterBus"],
"trackSettings": export_track_settings,
"busMode": bus_mode,
}
effect_graph_json = json.dumps(effect_graph, ensure_ascii=False, indent=2)
has_media = any(str(seg.get("audioFile", "")).strip() or str(seg.get("audioB64", "")).strip() for seg in segments)
shotboard_has_media = any(str(seg.get("audioFile", "")).strip() or str(seg.get("audioB64", "")).strip() for seg in shotboard_segments)
source_max_end = max([_safe_int(seg.get("start", 0), 0) + _safe_int(seg.get("length", 1), 1) for seg in segments] or [0])
shotboard_max_end = max([_safe_int(seg.get("start", 0), 0) + _safe_int(seg.get("length", 1), 1) for seg in shotboard_segments] or [0])
duration_frames = shotboard_max_end or source_max_end
duration_s = (duration_frames / fps) if duration_frames else max(0.0, _safe_float(data.get("duration_seconds", 0.0), 0.0))
out_linx = _clone_linx(cine_linx)
resources = _resources(out_linx)
outputs = _outputs(out_linx)
payload = _payload(out_linx)
upstream_timeline = resources.get("cine_board_timeline_data") or payload.get("timeline_data") or outputs.get("timeline_data")
merged_timeline = self._merge_audio_into_timeline(upstream_timeline, shotboard_data, fps) if upstream_timeline else ""
resources.update({
"cine_audio_timeline_json": audio_timeline_json,
"cine_audio_bus_timeline_json": bus_audio_timeline_json,
"cine_audio_tracks": {
"source": "IAMCCS_AudioBoardArranger",
"segments": segments,
"all_segments": source_segments,
"shotboard_segments": shotboard_segments,
"source_end_frames": int(source_max_end),
"shotboard_end_frames": int(shotboard_max_end),
"duration_frames": int(duration_frames),
"track_count": audio_timeline["audioTrackCount"],
"source_track_count": source_track_count,
"shotboard_track_count": shotboard_track_count,
"selected_tracks": list(range(max(1, source_track_count))),
"shotboard_selected_tracks": [0] if only_first else list(range(max(1, source_track_count))),
"bus_mode": bus_mode,
"shotboard_bus_mode": shotboard_mode,
"master_gain": audio_timeline["masterAudioGain"],
"master_normalize": audio_timeline["masterAudioNormalize"],
"master_bus": audio_timeline["masterBus"],
"track_settings": export_track_settings,
"all_track_settings": copy.deepcopy(track_settings),
"shotboard_track_settings": shotboard_track_settings,
},
"cine_audio_layers": {
"arranger": full_data,
"bus": data,
"export": shotboard_data,
"policy": str(sync_policy),
"bus_mode": bus_mode,
"shotboard_bus_mode": shotboard_mode,
},
"cine_audio_effect_graph_json": effect_graph_json,
"cine_use_custom_audio": bool(shotboard_has_media and str(sync_policy) == "audio_lanes_drive_custom_audio"),
})
if duration_s > 0:
resources["cine_duration_seconds"] = float(duration_s)
if duration_frames > 0:
resources["cine_max_frames"] = int(duration_frames)
payload["max_frames"] = int(duration_frames)
payload["duration_seconds"] = float(duration_s)
if merged_timeline:
resources["cine_board_timeline_data"] = merged_timeline
outputs["timeline_data"] = merged_timeline
payload["timeline_data"] = merged_timeline
payload.update({
"audio_board_arranger": True,
"audioSegments": shotboard_segments,
"audioTrackCount": shotboard_track_count,
"trackSettings": shotboard_track_settings,
"audioBusMode": "shotboard_only_first" if only_first else "all_tracks",
"onlyFirstTrack": only_first,
"use_custom_audio": bool(shotboard_has_media and str(sync_policy) == "audio_lanes_drive_custom_audio"),
"audioSyncMode": audio_timeline["audioSyncMode"],
})
outputs.update({
"audio_timeline_json": audio_timeline_json,
"audio_bus_timeline_json": bus_audio_timeline_json,
"audio_effect_graph_json": effect_graph_json,
"duration_seconds": float(duration_s),
"max_frames": int(duration_frames) if duration_frames > 0 else 0,
})
out_linx["type"] = SUPERNODE_LINX_TYPE
out_linx["mode"] = "iamccs_audio_board_arranger"
out_linx.setdefault("chain", []).append({"role": "audio_arranger", "name": "IAMCCS_AudioBoardArranger"})
_refresh_linx_index(out_linx)
report = _json_report({
"node": "IAMCCS_AudioBoardArranger",
"segments": len(segments),
"source_segments": len(source_segments),
"shotboard_segments": len(shotboard_segments),
"tracks": audio_timeline["audioTrackCount"],
"source_tracks": source_track_count,
"shotboard_tracks": shotboard_track_count,
"bus_mode": bus_mode,
"shotboard_bus_mode": shotboard_mode,
"has_media": bool(has_media),
"duration_seconds": float(duration_s),
"sync_policy": str(sync_policy),
"truth": "External/generated audio that should drive video is exported as Shotboard V3 audioSegments/custom audio through cine_linx.",
})
resources["cine_report"] = report
outputs["report"] = report
return out_linx, audio_timeline_json, report
NODE_CLASS_MAPPINGS = {
"IAMCCS_AudioBoardArranger": IAMCCS_AudioBoardArranger,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_AudioBoardArranger": "IAMCCS AudioBoard Arranger",
}
+224
View File
@@ -0,0 +1,224 @@
from __future__ import annotations
import copy
import json
from typing import Any, Dict, List
SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX"
def _safe_json_loads(value: Any, fallback: Any) -> Any:
if isinstance(value, (dict, list)):
return value
try:
return json.loads(str(value or ""))
except Exception:
return fallback
def _safe_float(value: Any, fallback: float = 0.0) -> float:
try:
return float(value)
except Exception:
return float(fallback)
def _safe_int(value: Any, fallback: int = 0) -> int:
try:
return int(round(float(value)))
except Exception:
return int(fallback)
def _clone_linx(cine_linx: Any) -> Dict[str, Any]:
return copy.deepcopy(cine_linx) if isinstance(cine_linx, dict) else {
"type": SUPERNODE_LINX_TYPE,
"mode": "iamccs_audio_board_mixer",
"resources": {},
"outputs": {},
"chain": [],
"stages": [],
}
def _resources(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = cine_linx.setdefault("resources", {})
if not isinstance(resources, dict):
resources = {}
cine_linx["resources"] = resources
return resources
def _outputs(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
outputs = cine_linx.setdefault("outputs", {})
if not isinstance(outputs, dict):
outputs = {}
cine_linx["outputs"] = outputs
return outputs
def _payload(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = _resources(cine_linx)
payload = resources.get("cine_payload")
if not isinstance(payload, dict):
payload = {}
resources["cine_payload"] = payload
return payload
def _refresh_linx_index(cine_linx: Dict[str, Any]) -> None:
resources = _resources(cine_linx)
cine_linx["resource_keys"] = sorted(resources.keys())
cine_linx["resource_types"] = {key: type(value).__name__ for key, value in resources.items()}
def _segments(raw: Any) -> List[Dict[str, Any]]:
if not isinstance(raw, list):
return []
out: List[Dict[str, Any]] = []
for index, item in enumerate(raw):
if not isinstance(item, dict):
continue
seg = copy.deepcopy(item)
seg["id"] = str(seg.get("id") or f"mix_seg_{index + 1:03d}")
seg["track"] = max(0, _safe_int(seg.get("track", 0), 0))
seg["start"] = max(0, _safe_int(seg.get("start", 0), 0))
seg["length"] = max(1, _safe_int(seg.get("length", seg.get("audioDurationFrames", 1)), 1))
seg["gain"] = max(0.0, min(4.0, _safe_float(seg.get("gain", seg.get("volume", 1.0)), 1.0)))
seg["pan"] = max(-1.0, min(1.0, _safe_float(seg.get("pan", 0.0), 0.0)))
out.append(seg)
return sorted(out, key=lambda seg: (int(seg.get("track", 0)), int(seg.get("start", 0))))
def _default_track_settings(count: int, existing: Any) -> List[Dict[str, Any]]:
settings = existing if isinstance(existing, list) else []
out: List[Dict[str, Any]] = []
for index in range(max(1, int(count))):
base = copy.deepcopy(settings[index]) if index < len(settings) and isinstance(settings[index], dict) else {}
base.setdefault("name", f"A{index + 1}")
base["volume"] = max(0.0, min(2.0, _safe_float(base.get("volume", 1.0), 1.0)))
base["pan"] = max(-1.0, min(1.0, _safe_float(base.get("pan", 0.0), 0.0)))
base["mute"] = bool(base.get("mute", False))
base["solo"] = bool(base.get("solo", False))
chain = base.get("effectChain")
base["effectChain"] = chain if isinstance(chain, list) else []
out.append(base)
return out
class IAMCCS_AudioBoardMixer:
"""Extended AudioBoard mixer surface synchronized through cine_linx."""
DEFAULT_DATA = json.dumps({
"schema": "iamccs.audio_board_mixer",
"schema_version": 1,
"mirrorTrackSettings": [],
"masterBus": {
"limiter": True,
"compressor": 0.0,
"ceilingDb": -1.0,
"width": 1.0,
"reverbSend": 0.0,
"delaySend": 0.0,
},
"view": {"style": "reaper_channel_strips", "meter_mode": "peak_rms"},
}, indent=2)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mixer_data": ("STRING", {
"default": cls.DEFAULT_DATA,
"multiline": True,
"tooltip": "Edited by the IAMCCS AudioBoardMixer UI. Mirrors AudioBoard track volume, pan, inserts and master bus state.",
}),
"sync_policy": ([
"mixer_overrides_cine_linx",
"read_only_monitor",
], {"default": "mixer_overrides_cine_linx"}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
},
}
RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "STRING")
RETURN_NAMES = ("cine_linx", "mixer_json", "report")
FUNCTION = "mix"
CATEGORY = "IAMCCS/Cine/Audio"
def mix(self, mixer_data, sync_policy="mixer_overrides_cine_linx", cine_linx=None):
linx = _clone_linx(cine_linx)
resources = _resources(linx)
outputs = _outputs(linx)
payload = _payload(linx)
mixer = _safe_json_loads(mixer_data, {})
if not isinstance(mixer, dict):
mixer = _safe_json_loads(self.DEFAULT_DATA, {})
audio_tracks = resources.get("cine_audio_tracks") if isinstance(resources.get("cine_audio_tracks"), dict) else {}
source_segments = _segments(audio_tracks.get("all_segments"))
if not source_segments:
source_segments = _segments(audio_tracks.get("segments"))
if not source_segments:
source_segments = _segments(payload.get("audioSegments"))
source_settings = (
mixer.get("mirrorTrackSettings")
or mixer.get("trackSettings")
or audio_tracks.get("trackSettings")
or payload.get("trackSettings")
)
track_count = max(
1,
_safe_int(mixer.get("audioTrackCount"), 0),
_safe_int(audio_tracks.get("track_count"), 0),
_safe_int(payload.get("audioTrackCount"), 0),
max([_safe_int(seg.get("track", 0), 0) + 1 for seg in source_segments] or [1]),
)
track_settings = _default_track_settings(track_count, source_settings)
master_bus = copy.deepcopy(audio_tracks.get("master_bus") if isinstance(audio_tracks.get("master_bus"), dict) else {})
master_bus.update(copy.deepcopy(mixer.get("masterBus") if isinstance(mixer.get("masterBus"), dict) else {}))
master_gain = max(0.0, min(2.0, _safe_float(mixer.get("masterAudioGain", payload.get("masterAudioGain", 1.0)), 1.0)))
manifest = {
"schema": "iamccs.audio_board_mixer.manifest",
"schema_version": 1,
"source": "IAMCCS_AudioBoardMixer",
"sync_policy": str(sync_policy or "mixer_overrides_cine_linx"),
"track_count": track_count,
"masterAudioGain": master_gain,
"masterBus": master_bus,
"trackSettings": track_settings,
"segments": source_segments,
"truth": "Mixer mirrors AudioBoard track volume/pan/inserts and writes them back into cine_linx metadata for downstream BusOut/save stages.",
}
if sync_policy != "read_only_monitor":
audio_tracks["trackSettings"] = track_settings
audio_tracks["master_bus"] = master_bus
audio_tracks["mixer_manifest"] = manifest
resources["cine_audio_tracks"] = audio_tracks
resources["cine_audio_mixer"] = manifest
payload["trackSettings"] = track_settings
payload["masterBus"] = master_bus
payload["masterAudioGain"] = master_gain
payload["audioMixer"] = manifest
outputs["audio_mixer_json"] = json.dumps(manifest, ensure_ascii=False)
if isinstance(linx.get("chain"), list):
linx["chain"].append({"node": "IAMCCS_AudioBoardMixer", "track_count": track_count, "sync_policy": sync_policy})
_refresh_linx_index(linx)
report = {
"node": "IAMCCS_AudioBoardMixer",
"sync_policy": sync_policy,
"track_count": track_count,
"segments": len(source_segments),
"has_master_bus": bool(master_bus),
"writes_cine_linx": sync_policy != "read_only_monitor",
}
return linx, json.dumps(manifest, indent=2, ensure_ascii=False), json.dumps(report, indent=2, ensure_ascii=False)
@@ -0,0 +1,264 @@
from __future__ import annotations
import copy
import json
from typing import Any, Dict, List
SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX"
MAX_TRACK_OUTS = 5
def _safe_json_loads(value: Any, fallback: Any) -> Any:
if isinstance(value, (dict, list)):
return value
try:
return json.loads(str(value or ""))
except Exception:
return fallback
def _json_dump(data: Any) -> str:
return json.dumps(data, indent=2, ensure_ascii=False)
def _clone_linx(cine_linx: Any) -> Dict[str, Any]:
return copy.deepcopy(cine_linx) if isinstance(cine_linx, dict) else {
"type": SUPERNODE_LINX_TYPE,
"mode": "iamccs_bus_out",
"resources": {},
"outputs": {},
"chain": [],
"stages": [],
}
def _resources(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = cine_linx.setdefault("resources", {})
if not isinstance(resources, dict):
resources = {}
cine_linx["resources"] = resources
return resources
def _outputs(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
outputs = cine_linx.setdefault("outputs", {})
if not isinstance(outputs, dict):
outputs = {}
cine_linx["outputs"] = outputs
return outputs
def _payload(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = _resources(cine_linx)
payload = resources.get("cine_payload")
if not isinstance(payload, dict):
payload = {}
resources["cine_payload"] = payload
return payload
def _refresh_linx_index(cine_linx: Dict[str, Any]) -> None:
resources = _resources(cine_linx)
cine_linx["resource_keys"] = sorted(resources.keys())
cine_linx["resource_types"] = {key: type(value).__name__ for key, value in resources.items()}
def _safe_int(value: Any, fallback: int = 0) -> int:
try:
return int(round(float(value)))
except Exception:
return int(fallback)
def _safe_float(value: Any, fallback: float = 0.0) -> float:
try:
return float(value)
except Exception:
return float(fallback)
def _segments(raw: Any) -> List[Dict[str, Any]]:
if not isinstance(raw, list):
return []
out: List[Dict[str, Any]] = []
for index, item in enumerate(raw):
if not isinstance(item, dict):
continue
seg = copy.deepcopy(item)
seg["id"] = str(seg.get("id") or f"bus_seg_{index + 1:03d}")
seg["track"] = max(0, _safe_int(seg.get("track", 0), 0))
seg["start"] = max(0, _safe_int(seg.get("start", 0), 0))
seg["length"] = max(1, _safe_int(seg.get("length", seg.get("audioDurationFrames", 1)), 1))
seg["gain"] = max(0.0, min(4.0, _safe_float(seg.get("gain", seg.get("volume", 1.0)), 1.0)))
seg["mute"] = bool(seg.get("mute", False))
seg["solo"] = bool(seg.get("solo", False))
out.append(seg)
return sorted(out, key=lambda seg: (int(seg.get("track", 0)), int(seg.get("start", 0))))
def _settings_at(settings: Any, track_index: int) -> Dict[str, Any]:
if isinstance(settings, list) and 0 <= track_index < len(settings) and isinstance(settings[track_index], dict):
return copy.deepcopy(settings[track_index])
return {}
def _max_end(segments: List[Dict[str, Any]]) -> int:
return max([_safe_int(seg.get("start", 0), 0) + _safe_int(seg.get("length", 1), 1) for seg in segments] or [0])
class IAMCCS_BusOut:
"""Expose the AudioBoardArranger master bus and up to five stem tracks from cine_linx."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mode": ([
"from_arranger_bus",
"master_plus_stems_max_5",
"all_lanes_force",
], {"default": "from_arranger_bus"}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
},
}
RETURN_TYPES = (
SUPERNODE_LINX_TYPE,
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
)
RETURN_NAMES = (
"cine_linx",
"master_out_json",
"track_1_json",
"track_2_json",
"track_3_json",
"track_4_json",
"track_5_json",
"bus_manifest_json",
"report",
)
FUNCTION = "bus_out"
CATEGORY = "IAMCCS/Cine/Audio"
def bus_out(self, mode, cine_linx=None):
out_linx = _clone_linx(cine_linx)
resources = _resources(out_linx)
outputs = _outputs(out_linx)
payload = _payload(out_linx)
audio_tracks = resources.get("cine_audio_tracks") if isinstance(resources.get("cine_audio_tracks"), dict) else {}
source_segments = _segments(audio_tracks.get("all_segments") if isinstance(audio_tracks, dict) else payload.get("audioSegments"))
if not source_segments:
source_segments = _segments(audio_tracks.get("segments") if isinstance(audio_tracks, dict) else payload.get("audioSegments"))
if not source_segments:
source_segments = _segments(payload.get("audioSegments"))
master_bus = audio_tracks.get("master_bus") if isinstance(audio_tracks.get("master_bus"), dict) else payload.get("masterBus", {})
if not isinstance(master_bus, dict):
master_bus = {}
track_settings = audio_tracks.get("all_track_settings") if isinstance(audio_tracks.get("all_track_settings"), list) else audio_tracks.get("track_settings")
if not isinstance(track_settings, list):
track_settings = payload.get("trackSettings", [])
if not isinstance(track_settings, list):
track_settings = []
shotboard_bus_mode = str(audio_tracks.get("shotboard_bus_mode") or audio_tracks.get("bus_mode") or payload.get("audioBusMode") or "all_tracks")
bus_mode = "all_lanes"
selected_segments = source_segments
track_outs: List[Dict[str, Any]] = []
track_jsons: List[str] = []
for track_index in range(MAX_TRACK_OUTS):
track_segments = [seg for seg in selected_segments if _safe_int(seg.get("track", 0), 0) == track_index]
track_out = {
"schema": "iamccs.audio_bus_out.track",
"schema_version": 1,
"source": "IAMCCS_BusOut",
"track_index": track_index,
"track_name": f"A{track_index + 1}",
"segments": track_segments,
"effects": _settings_at(track_settings, track_index),
"duration_frames": _max_end(track_segments),
"has_media": any(str(seg.get("audioFile", "")).strip() or str(seg.get("audioB64", "")).strip() for seg in track_segments),
}
track_outs.append(track_out)
track_jsons.append(_json_dump(track_out))
master_out = {
"schema": "iamccs.audio_bus_out.master",
"schema_version": 1,
"source": "IAMCCS_BusOut",
"mode": str(mode),
"bus_mode": bus_mode,
"shotboard_bus_mode": shotboard_bus_mode,
"segments": selected_segments,
"masterBus": copy.deepcopy(master_bus),
"trackSettings": copy.deepcopy(track_settings[:MAX_TRACK_OUTS]),
"duration_frames": _max_end(selected_segments),
"track_count": MAX_TRACK_OUTS,
"has_media": any(str(seg.get("audioFile", "")).strip() or str(seg.get("audioB64", "")).strip() for seg in selected_segments),
}
manifest = {
"schema": "iamccs.audio_bus_out.manifest",
"schema_version": 1,
"source": "IAMCCS_BusOut",
"mode": str(mode),
"bus_mode": bus_mode,
"shotboard_bus_mode": shotboard_bus_mode,
"master": master_out,
"tracks": track_outs,
}
master_json = _json_dump(master_out)
manifest_json = _json_dump(manifest)
report = _json_dump({
"node": "IAMCCS_BusOut",
"mode": str(mode),
"bus_mode": bus_mode,
"shotboard_bus_mode": shotboard_bus_mode,
"master_segments": len(selected_segments),
"track_segments": [len(track.get("segments", [])) for track in track_outs],
"max_track_outputs": MAX_TRACK_OUTS,
"truth": "Splits AudioBoardArranger cine_linx audio metadata into master out plus up to five stem track JSON outputs.",
})
resources["cine_audio_bus_out"] = manifest
resources["cine_audio_master_out_json"] = master_json
resources["cine_audio_track_out_jsons"] = track_jsons
outputs["audio_master_out_json"] = master_json
for index, track_json in enumerate(track_jsons, start=1):
outputs[f"audio_track_{index}_json"] = track_json
outputs["audio_bus_manifest_json"] = manifest_json
outputs["report"] = report
payload["audio_bus_out"] = manifest
out_linx["type"] = SUPERNODE_LINX_TYPE
out_linx["mode"] = "iamccs_bus_out"
out_linx.setdefault("chain", []).append({"role": "audio_bus_out", "name": "IAMCCS_BusOut"})
_refresh_linx_index(out_linx)
return (
out_linx,
master_json,
track_jsons[0],
track_jsons[1],
track_jsons[2],
track_jsons[3],
track_jsons[4],
manifest_json,
report,
)
NODE_CLASS_MAPPINGS = {
"IAMCCS_BusOut": IAMCCS_BusOut,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_BusOut": "IAMCCS BusOut",
}
+334
View File
@@ -0,0 +1,334 @@
from __future__ import annotations
import copy
import json
from typing import Any, Dict, List
SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX"
MAX_TRACK_OUTS = 5
def _safe_json_loads(value: Any, fallback: Any) -> Any:
if isinstance(value, (dict, list)):
return value
try:
return json.loads(str(value or ""))
except Exception:
return fallback
def _json_dump(data: Any) -> str:
return json.dumps(data, indent=2, ensure_ascii=False)
def _clone_linx(cine_linx: Any) -> Dict[str, Any]:
return copy.deepcopy(cine_linx) if isinstance(cine_linx, dict) else {
"type": SUPERNODE_LINX_TYPE,
"mode": "iamccs_bus_out",
"resources": {},
"outputs": {},
"chain": [],
"stages": [],
}
def _resources(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = cine_linx.setdefault("resources", {})
if not isinstance(resources, dict):
resources = {}
cine_linx["resources"] = resources
return resources
def _outputs(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
outputs = cine_linx.setdefault("outputs", {})
if not isinstance(outputs, dict):
outputs = {}
cine_linx["outputs"] = outputs
return outputs
def _payload(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = _resources(cine_linx)
payload = resources.get("cine_payload")
if not isinstance(payload, dict):
payload = {}
resources["cine_payload"] = payload
return payload
def _refresh_linx_index(cine_linx: Dict[str, Any]) -> None:
resources = _resources(cine_linx)
cine_linx["resource_keys"] = sorted(resources.keys())
cine_linx["resource_types"] = {key: type(value).__name__ for key, value in resources.items()}
def _safe_int(value: Any, fallback: int = 0) -> int:
try:
return int(round(float(value)))
except Exception:
return int(fallback)
def _safe_float(value: Any, fallback: float = 0.0) -> float:
try:
return float(value)
except Exception:
return float(fallback)
def _segments(raw: Any) -> List[Dict[str, Any]]:
if not isinstance(raw, list):
return []
out: List[Dict[str, Any]] = []
for index, item in enumerate(raw):
if not isinstance(item, dict):
continue
seg = copy.deepcopy(item)
seg["id"] = str(seg.get("id") or f"bus_seg_{index + 1:03d}")
seg["track"] = max(0, _safe_int(seg.get("track", 0), 0))
seg["start"] = max(0, _safe_int(seg.get("start", 0), 0))
seg["length"] = max(1, _safe_int(seg.get("length", seg.get("audioDurationFrames", 1)), 1))
seg["gain"] = max(0.0, min(4.0, _safe_float(seg.get("gain", seg.get("volume", 1.0)), 1.0)))
seg["mute"] = bool(seg.get("mute", False))
seg["solo"] = bool(seg.get("solo", False))
out.append(seg)
return sorted(out, key=lambda seg: (int(seg.get("track", 0)), int(seg.get("start", 0))))
def _settings_at(settings: Any, track_index: int) -> Dict[str, Any]:
if isinstance(settings, list) and 0 <= track_index < len(settings) and isinstance(settings[track_index], dict):
return copy.deepcopy(settings[track_index])
return {}
def _max_end(segments: List[Dict[str, Any]]) -> int:
return max([_safe_int(seg.get("start", 0), 0) + _safe_int(seg.get("length", 1), 1) for seg in segments] or [0])
def _generation_index_from_segments(segments: List[Dict[str, Any]]) -> Dict[str, Any]:
indexed_segments = [
copy.deepcopy(seg)
for seg in segments
if bool(seg.get("multiGenerationClip")) or str(seg.get("timelineId", "") or "").startswith("T")
]
groups: Dict[int, List[Dict[str, Any]]] = {}
for fallback, seg in enumerate(sorted(indexed_segments, key=lambda item: (
_safe_int(item.get("multiTakeIndex", item.get("take_index", 1)), 1),
_safe_int(item.get("track", 0), 0),
_safe_int(item.get("sourceGlobalStart", item.get("globalStart", item.get("start", 0))), 0),
))):
take_index = max(1, _safe_int(seg.get("multiTakeIndex", seg.get("take_index", fallback + 1)), fallback + 1))
groups.setdefault(take_index, []).append(seg)
takes: List[Dict[str, Any]] = []
for order, take_index in enumerate(sorted(groups), start=1):
group = groups[take_index]
track_index = _safe_int(group[0].get("track", order - 1), order - 1)
timeline_id = str(group[0].get("timelineId") or f"T{take_index:02d}")
start = min([_safe_int(seg.get("sourceGlobalStart", seg.get("globalStart", seg.get("start", 0))), 0) for seg in group] or [0])
end = max([
_safe_int(seg.get(
"sourceGlobalEnd",
seg.get("globalEnd", _safe_int(seg.get("sourceGlobalStart", seg.get("globalStart", seg.get("start", 0))), 0) + _safe_int(seg.get("length", 1), 1))
), 0)
for seg in group
] or [0])
local_start = min([_safe_int(seg.get("start", 0), 0) for seg in group] or [0])
takes.append({
"timeline_id": timeline_id,
"take_index": int(order),
"source_take_index": int(take_index),
"track_index": int(track_index),
"track_name": f"A{track_index + 1}",
"mapping": f"{timeline_id.replace('T0', 'T')} - A{track_index + 1}",
"audio_chunk_ids": [str(seg.get("id", "")) for seg in group],
"source_segment_ids": sorted({str(seg.get("sourceSegmentId", "")) for seg in group if str(seg.get("sourceSegmentId", ""))}),
"start_frames": int(start),
"end_frames": int(end),
"local_start_frames": int(local_start),
"duration_frames": int(max(0, end - start)),
"segments": group,
})
return {
"schema": "iamccs.audio_bus_out.generation_index",
"schema_version": 1,
"source": "IAMCCS_BusOut",
"take_count": len(takes),
"lane_policy": "timeline_take_to_audio_lane",
"mapping": [take["mapping"] for take in takes],
"takes": takes,
"truth": "Each multigeneration take is indexed to its respective AudioBoard lane: T1-A1, T2-A2, T3-A3 when the arranger template starts at A1.",
}
class IAMCCS_BusOut:
"""Expose the AudioBoardArranger master bus and up to five stem tracks from cine_linx."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mode": ([
"from_arranger_bus",
"master_plus_stems_max_5",
"all_lanes_force",
], {"default": "from_arranger_bus"}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
},
}
RETURN_TYPES = (
SUPERNODE_LINX_TYPE,
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
)
RETURN_NAMES = (
"cine_linx",
"master_out_json",
"track_1_json",
"track_2_json",
"track_3_json",
"track_4_json",
"track_5_json",
"bus_manifest_json",
"report",
)
FUNCTION = "bus_out"
CATEGORY = "IAMCCS/Cine/Audio"
def bus_out(self, mode, cine_linx=None):
out_linx = _clone_linx(cine_linx)
resources = _resources(out_linx)
outputs = _outputs(out_linx)
payload = _payload(out_linx)
audio_tracks = resources.get("cine_audio_tracks") if isinstance(resources.get("cine_audio_tracks"), dict) else {}
source_segments = _segments(audio_tracks.get("all_segments") if isinstance(audio_tracks, dict) else payload.get("audioSegments"))
if not source_segments:
source_segments = _segments(audio_tracks.get("segments") if isinstance(audio_tracks, dict) else payload.get("audioSegments"))
if not source_segments:
source_segments = _segments(payload.get("audioSegments"))
master_bus = audio_tracks.get("master_bus") if isinstance(audio_tracks.get("master_bus"), dict) else payload.get("masterBus", {})
if not isinstance(master_bus, dict):
master_bus = {}
effect_graph = resources.get("cine_audio_effect_graph_json") or outputs.get("audio_effect_graph_json") or payload.get("audio_effect_graph_json") or ""
track_settings = audio_tracks.get("all_track_settings") if isinstance(audio_tracks.get("all_track_settings"), list) else audio_tracks.get("track_settings")
if not isinstance(track_settings, list):
track_settings = payload.get("trackSettings", [])
if not isinstance(track_settings, list):
track_settings = []
shotboard_bus_mode = str(audio_tracks.get("shotboard_bus_mode") or audio_tracks.get("bus_mode") or payload.get("audioBusMode") or "all_tracks")
bus_mode = "all_lanes"
selected_segments = source_segments
track_outs: List[Dict[str, Any]] = []
track_jsons: List[str] = []
for track_index in range(MAX_TRACK_OUTS):
track_segments = [seg for seg in selected_segments if _safe_int(seg.get("track", 0), 0) == track_index]
track_out = {
"schema": "iamccs.audio_bus_out.track",
"schema_version": 1,
"source": "IAMCCS_BusOut",
"track_index": track_index,
"track_name": f"A{track_index + 1}",
"segments": track_segments,
"effects": _settings_at(track_settings, track_index),
"effect_graph_json": effect_graph,
"duration_frames": _max_end(track_segments),
"has_media": any(str(seg.get("audioFile", "")).strip() or str(seg.get("audioB64", "")).strip() for seg in track_segments),
}
track_outs.append(track_out)
track_jsons.append(_json_dump(track_out))
master_out = {
"schema": "iamccs.audio_bus_out.master",
"schema_version": 1,
"source": "IAMCCS_BusOut",
"mode": str(mode),
"bus_mode": bus_mode,
"shotboard_bus_mode": shotboard_bus_mode,
"segments": selected_segments,
"masterBus": copy.deepcopy(master_bus),
"trackSettings": copy.deepcopy(track_settings[:MAX_TRACK_OUTS]),
"effect_graph_json": effect_graph,
"duration_frames": _max_end(selected_segments),
"track_count": MAX_TRACK_OUTS,
"has_media": any(str(seg.get("audioFile", "")).strip() or str(seg.get("audioB64", "")).strip() for seg in selected_segments),
}
manifest = {
"schema": "iamccs.audio_bus_out.manifest",
"schema_version": 1,
"source": "IAMCCS_BusOut",
"mode": str(mode),
"bus_mode": bus_mode,
"shotboard_bus_mode": shotboard_bus_mode,
"master": master_out,
"tracks": track_outs,
"effect_graph_json": effect_graph,
}
generation_index = _generation_index_from_segments(selected_segments)
manifest["generation_index"] = generation_index
master_json = _json_dump(master_out)
manifest_json = _json_dump(manifest)
report = _json_dump({
"node": "IAMCCS_BusOut",
"mode": str(mode),
"bus_mode": bus_mode,
"shotboard_bus_mode": shotboard_bus_mode,
"master_segments": len(selected_segments),
"track_segments": [len(track.get("segments", [])) for track in track_outs],
"generation_take_count": generation_index.get("take_count", 0),
"generation_mapping": generation_index.get("mapping", []),
"max_track_outputs": MAX_TRACK_OUTS,
"truth": "Splits AudioBoardArranger cine_linx audio metadata into master out plus up to five stem track JSON outputs.",
})
resources["cine_audio_bus_out"] = manifest
if effect_graph:
resources["cine_audio_bus_effect_graph_json"] = effect_graph
resources["cine_audio_master_out_json"] = master_json
resources["cine_audio_track_out_jsons"] = track_jsons
resources["cine_audio_generation_index"] = generation_index
resources["cine_audio_generation_index_json"] = _json_dump(generation_index)
outputs["audio_master_out_json"] = master_json
for index, track_json in enumerate(track_jsons, start=1):
outputs[f"audio_track_{index}_json"] = track_json
outputs["audio_bus_manifest_json"] = manifest_json
outputs["audio_generation_index_json"] = _json_dump(generation_index)
outputs["report"] = report
payload["audio_bus_out"] = manifest
payload["audio_generation_index"] = generation_index
out_linx["type"] = SUPERNODE_LINX_TYPE
out_linx["mode"] = "iamccs_bus_out"
out_linx.setdefault("chain", []).append({"role": "audio_bus_out", "name": "IAMCCS_BusOut"})
_refresh_linx_index(out_linx)
return (
out_linx,
master_json,
track_jsons[0],
track_jsons[1],
track_jsons[2],
track_jsons[3],
track_jsons[4],
manifest_json,
report,
)
NODE_CLASS_MAPPINGS = {
"IAMCCS_BusOut": IAMCCS_BusOut,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_BusOut": "IAMCCS BusOut",
}
+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
import copy
import json
from typing import Any, Dict, List
SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX"
def _safe_json_loads(value: Any, fallback: Any) -> Any:
if isinstance(value, (dict, list)):
return value
try:
return json.loads(str(value or ""))
except Exception:
return fallback
def _clone_linx(cine_linx: Any) -> Dict[str, Any]:
return copy.deepcopy(cine_linx) if isinstance(cine_linx, dict) else {
"type": SUPERNODE_LINX_TYPE,
"mode": "iamccs_control_aud_efx",
"resources": {},
"outputs": {},
"chain": [],
"stages": [],
}
def _resources(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = cine_linx.setdefault("resources", {})
if not isinstance(resources, dict):
resources = {}
cine_linx["resources"] = resources
return resources
def _outputs(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
outputs = cine_linx.setdefault("outputs", {})
if not isinstance(outputs, dict):
outputs = {}
cine_linx["outputs"] = outputs
return outputs
def _payload(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = _resources(cine_linx)
payload = resources.get("cine_payload")
if not isinstance(payload, dict):
payload = {}
resources["cine_payload"] = payload
return payload
def _refresh_linx_index(cine_linx: Dict[str, Any]) -> None:
resources = _resources(cine_linx)
cine_linx["resource_keys"] = sorted(resources.keys())
cine_linx["resource_types"] = {key: type(value).__name__ for key, value in resources.items()}
def _safe_float(value: Any, fallback: float = 0.0) -> float:
try:
return float(value)
except Exception:
return float(fallback)
def _effect_graph_from_segments(segments: List[Dict[str, Any]], master_bus: Dict[str, Any]) -> Dict[str, Any]:
clips = []
for seg in segments:
if not isinstance(seg, dict):
continue
clips.append({
"id": str(seg.get("id", "")),
"name": str(seg.get("name") or seg.get("fileName") or "audio"),
"track": int(_safe_float(seg.get("track", 0), 0)),
"start": int(round(_safe_float(seg.get("start", 0), 0))),
"length": int(round(_safe_float(seg.get("length", 1), 1))),
"trimStart": int(round(_safe_float(seg.get("trimStart", 0), 0))),
"audioFile": str(seg.get("audioFile", "")),
"audioUploadType": str(seg.get("audioUploadType", "input") or "input"),
"linkedVisualId": str(seg.get("linkedVisualId", "")),
"effects": {
"gain": _safe_float(seg.get("gain", 1.0), 1.0),
"pan": _safe_float(seg.get("pan", 0.0), 0.0),
"hpfHz": _safe_float(seg.get("hpfHz", 0.0), 0.0),
"lpfHz": _safe_float(seg.get("lpfHz", 22000.0), 22000.0),
"eqLowDb": _safe_float(seg.get("eqLowDb", 0.0), 0.0),
"eqMidDb": _safe_float(seg.get("eqMidDb", 0.0), 0.0),
"eqHighDb": _safe_float(seg.get("eqHighDb", 0.0), 0.0),
"compressor": _safe_float(seg.get("compressor", 0.0), 0.0),
"noiseGateDb": _safe_float(seg.get("noiseGateDb", -60.0), -60.0),
"ducking": _safe_float(seg.get("ducking", 0.0), 0.0),
"reverbSend": _safe_float(seg.get("reverbSend", 0.0), 0.0),
"delaySend": _safe_float(seg.get("delaySend", 0.0), 0.0),
"stereoWidth": _safe_float(seg.get("stereoWidth", 1.0), 1.0),
"transient": _safe_float(seg.get("transient", 0.0), 0.0),
"denoise": _safe_float(seg.get("denoise", 0.0), 0.0),
},
})
return {
"schema": "iamccs.audio_effect_graph",
"schema_version": 1,
"clips": clips,
"masterBus": master_bus if isinstance(master_bus, dict) else {},
}
class IAMCCS_ControlAudEfx:
"""Realtime control/inspection companion for AudioBoardArranger effect graphs."""
DEFAULT_DATA = json.dumps({
"schema": "iamccs.control_aud_efx",
"schema_version": 1,
"selectedClipId": "",
"view": "eq_wave_spectrum",
"metering": {"fftSize": 2048, "smoothing": 0.72},
}, indent=2)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"control_data": ("STRING", {
"default": cls.DEFAULT_DATA,
"multiline": True,
"tooltip": "Edited by the IAMCCS ControlAudEfx UI. Connect cine_linx from AudioBoardArranger.",
}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
"cine_linx_from_arranger": (SUPERNODE_LINX_TYPE,),
},
}
RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "STRING", "STRING")
RETURN_NAMES = ("cine_linx", "effect_graph_json", "selected_clip_json", "report")
FUNCTION = "control"
CATEGORY = "IAMCCS/Cine/Audio"
def control(self, control_data, cine_linx=None, cine_linx_from_arranger=None):
data = _safe_json_loads(control_data, {})
if not isinstance(data, dict):
data = _safe_json_loads(self.DEFAULT_DATA, {})
out_linx = _clone_linx(cine_linx_from_arranger if isinstance(cine_linx_from_arranger, dict) else cine_linx)
resources = _resources(out_linx)
outputs = _outputs(out_linx)
payload = _payload(out_linx)
audio_tracks = resources.get("cine_audio_tracks") if isinstance(resources.get("cine_audio_tracks"), dict) else {}
arranger = audio_tracks.get("segments") if isinstance(audio_tracks.get("segments"), list) else payload.get("audioSegments")
segments = arranger if isinstance(arranger, list) else []
master_bus = audio_tracks.get("master_bus") if isinstance(audio_tracks.get("master_bus"), dict) else payload.get("masterBus", {})
effect_graph = _effect_graph_from_segments(segments, master_bus if isinstance(master_bus, dict) else {})
selected_id = str(data.get("selectedClipId") or payload.get("selectedClipId") or "")
selected = next((clip for clip in effect_graph["clips"] if clip.get("id") == selected_id), effect_graph["clips"][0] if effect_graph["clips"] else {})
effect_graph_json = json.dumps(effect_graph, ensure_ascii=False, indent=2)
selected_clip_json = json.dumps(selected, ensure_ascii=False, indent=2)
resources["cine_audio_effect_graph_json"] = effect_graph_json
resources["cine_audio_control_efx"] = data
outputs["audio_effect_graph_json"] = effect_graph_json
outputs["selected_audio_clip_json"] = selected_clip_json
payload["audio_effect_graph"] = effect_graph
out_linx["type"] = SUPERNODE_LINX_TYPE
out_linx["mode"] = "iamccs_control_aud_efx"
out_linx.setdefault("chain", []).append({"role": "audio_effect_control", "name": "IAMCCS_ControlAudEfx"})
_refresh_linx_index(out_linx)
report = json.dumps({
"node": "IAMCCS_ControlAudEfx",
"clips": len(effect_graph["clips"]),
"selected_clip": selected.get("id", ""),
"truth": "Visualizes and exports the AudioBoardArranger DSP/effect graph without changing Shotboard timing.",
}, ensure_ascii=False, indent=2)
outputs["report"] = report
resources["cine_report"] = report
return out_linx, effect_graph_json, selected_clip_json, report
NODE_CLASS_MAPPINGS = {
"IAMCCS_ControlAudEfx": IAMCCS_ControlAudEfx,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_ControlAudEfx": "IAMCCS ControlAudEfx",
}
+384
View File
@@ -0,0 +1,384 @@
from __future__ import annotations
import copy
import json
import re
from typing import Any, Dict, List, Tuple
SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX"
DEFAULT_DIALOGUE = {
"schema": "iamccs.dialogue_script_planner",
"schema_version": 1,
"settings": {
"engine_profile": "stepaudio_editx",
"timeline_mode": "speaker_stems_for_overlap",
"default_line_seconds": 2.6,
"default_gap_seconds": 0.15,
},
"speakers": [
{
"id": "A",
"name": "Alice",
"voice": "voices_examples/female/female_02.wav",
"reference_text": "The examination and testimony of the experts, enabled the commission to conclude, that five shots may have been fired.",
"emotion_ref": "happy",
},
{
"id": "B",
"name": "Bob",
"voice": "voices_examples/Clint_Eastwood CC3 (enhanced2).wav",
"reference_text": "You know, after 55 years of doing it, you kind of get an idea of when you're on key and when you're off key.",
"emotion_ref": "serious",
},
],
"lines": [
{
"id": "line_001",
"speaker": "Alice",
"start": 0.0,
"duration": 2.6,
"overlap_after": 0.25,
"emotion": "happy",
"style": "warm",
"text": "I thought the room would be empty by now.",
},
{
"id": "line_002",
"speaker": "Bob",
"start": 2.35,
"duration": 2.7,
"overlap_after": 0.0,
"emotion": "calm",
"style": "serious",
"text": "It is never empty when somebody is still listening.",
},
],
}
def _safe_json(value: Any, fallback: Any) -> Any:
if isinstance(value, (dict, list)):
return copy.deepcopy(value)
try:
return json.loads(str(value or ""))
except Exception:
return copy.deepcopy(fallback)
def _float(value: Any, fallback: float = 0.0) -> float:
try:
parsed = float(value)
if parsed != parsed:
return fallback
return parsed
except Exception:
return fallback
def _clean_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def _srt_time(seconds: float) -> str:
seconds = max(0.0, float(seconds))
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
whole = int(seconds % 60)
millis = int(round((seconds - int(seconds)) * 1000))
if millis >= 1000:
whole += 1
millis -= 1000
if whole >= 60:
minutes += 1
whole -= 60
if minutes >= 60:
hours += 1
minutes -= 60
return f"{hours:02d}:{minutes:02d}:{whole:02d},{millis:03d}"
def _line_to_srt(index: int, start: float, end: float, text: str) -> str:
end = max(start + 0.04, end)
return f"{index}\n{_srt_time(start)} --> {_srt_time(end)}\n{text}\n"
def _speaker_key(value: Any) -> str:
return re.sub(r"[^a-z0-9]+", "_", str(value or "speaker").strip().lower()).strip("_") or "speaker"
def _speaker_lookup(speakers: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
lookup: Dict[str, Dict[str, Any]] = {}
for speaker in speakers:
name = str(speaker.get("name") or speaker.get("id") or "").strip()
sid = str(speaker.get("id") or name).strip()
if name:
lookup[_speaker_key(name)] = speaker
if sid:
lookup[_speaker_key(sid)] = speaker
return lookup
def _format_line_for_engine(line: Dict[str, Any], speaker: Dict[str, Any], engine_profile: str) -> str:
text = _clean_text(line.get("text"))
if not text:
return ""
name = str(speaker.get("name") or line.get("speaker") or "Speaker").strip()
emotion = str(line.get("emotion") or "").strip()
style = str(line.get("style") or "").strip()
para = str(line.get("paralinguistic") or "").strip()
if engine_profile == "stepaudio_editx":
tags: List[str] = []
if para and para != "none":
tags.append(f"[{para}]")
if emotion and emotion != "none":
tags.append(f"<emotion:{emotion}>")
if style and style != "none":
tags.append(f"<style:{style}>")
return f"[{name}] {text} {' '.join(tags)}".strip()
if engine_profile == "indextts2":
emotion_ref = str(line.get("emotion_ref") or speaker.get("emotion_ref") or emotion or "").strip()
tag = f"[{name}:{emotion_ref}]" if emotion_ref and emotion_ref != "none" else f"[{name}]"
return f"{tag} {text}"
if engine_profile == "chatterbox":
return f"[{name}] {text}"
return f"{name}: {text}"
def _normalize_lines(data: Dict[str, Any], default_line_seconds: float, default_gap_seconds: float) -> List[Dict[str, Any]]:
raw_lines = data.get("lines", [])
if not isinstance(raw_lines, list):
raw_lines = []
lines: List[Dict[str, Any]] = []
cursor = 0.0
for index, item in enumerate(raw_lines):
if not isinstance(item, dict):
continue
line = dict(item)
duration = max(0.08, _float(line.get("duration", default_line_seconds), default_line_seconds))
has_start = line.get("start", "") not in (None, "")
start = max(0.0, _float(line.get("start", cursor), cursor)) if has_start else cursor
overlap_after = max(0.0, _float(line.get("overlap_after", 0.0), 0.0))
line["id"] = str(line.get("id") or f"line_{index + 1:03d}")
line["speaker"] = str(line.get("speaker") or "Speaker").strip() or "Speaker"
line["start"] = start
line["duration"] = duration
line["end"] = start + duration
line["overlap_after"] = overlap_after
line["text"] = _clean_text(line.get("text"))
if line["text"]:
lines.append(line)
cursor = max(cursor, start + duration + default_gap_seconds - overlap_after)
return sorted(lines, key=lambda item: (_float(item.get("start"), 0.0), str(item.get("speaker", ""))))
def _flatten_lines(lines: List[Dict[str, Any]], gap: float) -> List[Dict[str, Any]]:
flattened: List[Dict[str, Any]] = []
cursor = 0.0
for line in sorted(lines, key=lambda item: (_float(item.get("start"), 0.0), str(item.get("id", "")))):
out = dict(line)
duration = max(0.08, _float(out.get("duration"), 1.0))
out["start"] = cursor
out["end"] = cursor + duration
flattened.append(out)
cursor = out["end"] + max(0.0, gap)
return flattened
class IAMCCS_DialogueScriptPlanner:
"""Dialogue script planner for TTS engines with per-speaker SRT stems and overlap metadata."""
DEFAULT_DIALOGUE_DATA = json.dumps(DEFAULT_DIALOGUE, indent=2, ensure_ascii=False)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"dialogue_data": ("STRING", {
"default": cls.DEFAULT_DIALOGUE_DATA,
"multiline": True,
"tooltip": "Edited by the DialogueScriptPlanner UI. Stores speakers, lines, timing, emotions and overlap.",
}),
"engine_profile": ([
"stepaudio_editx",
"chatterbox",
"indextts2",
"plain",
], {"default": "stepaudio_editx"}),
"timeline_mode": ([
"speaker_stems_for_overlap",
"flatten_for_single_tts",
"preserve_overlap_in_master_srt",
], {"default": "speaker_stems_for_overlap"}),
"default_line_seconds": ("FLOAT", {"default": 2.6, "min": 0.1, "max": 60.0, "step": 0.05}),
"default_gap_seconds": ("FLOAT", {"default": 0.15, "min": 0.0, "max": 10.0, "step": 0.05}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
},
}
RETURN_TYPES = (
SUPERNODE_LINX_TYPE,
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
)
RETURN_NAMES = (
"cine_linx",
"master_srt",
"tagged_text",
"speaker_1_srt",
"speaker_2_srt",
"speaker_3_srt",
"speaker_4_srt",
"dialogue_json",
"report",
)
FUNCTION = "plan"
CATEGORY = "IAMCCS/Cine/Audio"
def plan(
self,
dialogue_data: str,
engine_profile: str,
timeline_mode: str,
default_line_seconds: float,
default_gap_seconds: float,
cine_linx: Any = None,
) -> Tuple[Dict[str, Any], str, str, str, str, str, str, str, str]:
data = _safe_json(dialogue_data, DEFAULT_DIALOGUE)
if not isinstance(data, dict):
data = copy.deepcopy(DEFAULT_DIALOGUE)
speakers = data.get("speakers", [])
if not isinstance(speakers, list) or not speakers:
speakers = copy.deepcopy(DEFAULT_DIALOGUE["speakers"])
speakers = [dict(item) for item in speakers if isinstance(item, dict)]
lookup = _speaker_lookup(speakers)
lines = _normalize_lines(data, float(default_line_seconds), float(default_gap_seconds))
export_lines = _flatten_lines(lines, float(default_gap_seconds)) if timeline_mode == "flatten_for_single_tts" else lines
master_parts: List[str] = []
tagged_parts: List[str] = []
speaker_srt: Dict[str, List[str]] = {}
stem_plan: Dict[str, List[Dict[str, Any]]] = {}
for index, line in enumerate(export_lines, start=1):
key = _speaker_key(line.get("speaker"))
speaker = lookup.get(key) or {"name": str(line.get("speaker") or "Speaker")}
formatted = _format_line_for_engine(line, speaker, engine_profile)
if not formatted:
continue
start = _float(line.get("start"), 0.0)
end = _float(line.get("end"), start + _float(line.get("duration"), 1.0))
master_parts.append(_line_to_srt(index, start, end, formatted))
tagged_parts.append(formatted)
speaker_name = str(speaker.get("name") or line.get("speaker") or "Speaker")
speaker_srt.setdefault(speaker_name, []).append(_line_to_srt(len(speaker_srt.get(speaker_name, [])) + 1, start, end, formatted))
stem_plan.setdefault(speaker_name, []).append({
"line_id": line.get("id"),
"speaker": speaker_name,
"start": start,
"end": end,
"duration": max(0.0, end - start),
"emotion": line.get("emotion", ""),
"style": line.get("style", ""),
"overlap_after": line.get("overlap_after", 0.0),
"text": line.get("text", ""),
})
ordered_speakers = [str(item.get("name") or item.get("id") or "") for item in speakers]
ordered_speakers = [name for name in ordered_speakers if name]
for name in speaker_srt.keys():
if name not in ordered_speakers:
ordered_speakers.append(name)
stem_outputs = ["".join(speaker_srt.get(name, [])) for name in ordered_speakers[:4]]
while len(stem_outputs) < 4:
stem_outputs.append("")
payload = {
"schema": "iamccs.dialogue_script_planner",
"schema_version": 1,
"engine_profile": engine_profile,
"timeline_mode": timeline_mode,
"speakers": speakers,
"lines": lines,
"export_lines": export_lines,
"stem_plan": stem_plan,
"truth": "Use speaker stem SRT outputs for real overlap. A single TTS branch can switch voices, but it cannot reliably produce simultaneous dialogue.",
}
master_srt = "".join(master_parts).strip()
tagged_text = "\n".join(tagged_parts).strip()
dialogue_json = json.dumps(payload, indent=2, ensure_ascii=False)
report = json.dumps({
"node": "IAMCCS_DialogueScriptPlanner",
"engine_profile": engine_profile,
"timeline_mode": timeline_mode,
"lines": len(lines),
"speakers": ordered_speakers[:4],
"has_overlap": any(_float(line.get("overlap_after"), 0.0) > 0.0 for line in lines)
or any(_float(a.get("end"), 0.0) > _float(b.get("start"), 0.0)
for a, b in zip(lines, lines[1:])),
"routes": {
"single_voice_or_serial_dialogue": "Use master_srt or tagged_text into Unified TTS Text/SRT.",
"true_overlap": "Use speaker_1_srt..speaker_4_srt into separate TTS branches, then mix in AudioBoardArranger or an audio mixer.",
"indextts2_emotion": "Use indextts2 profile and emotion_ref/character emotion tags; emotion refs must exist as engine-compatible refs or aliases.",
},
}, indent=2, ensure_ascii=False)
out_linx = copy.deepcopy(cine_linx) if isinstance(cine_linx, dict) else {
"type": SUPERNODE_LINX_TYPE,
"mode": "iamccs_dialogue_script_planner",
"resources": {},
"outputs": {},
"chain": [],
}
resources = out_linx.setdefault("resources", {})
if not isinstance(resources, dict):
resources = {}
out_linx["resources"] = resources
outputs = out_linx.setdefault("outputs", {})
if not isinstance(outputs, dict):
outputs = {}
out_linx["outputs"] = outputs
resources["dialogue_script_planner"] = payload
outputs["dialogue_master_srt"] = master_srt
outputs["dialogue_tagged_text"] = tagged_text
outputs["dialogue_stem_srt"] = {f"speaker_{index + 1}": stem_outputs[index] for index in range(4)}
out_linx.setdefault("chain", []).append({"role": "dialogue_script_planner", "name": "IAMCCS_DialogueScriptPlanner"})
out_linx["resource_keys"] = sorted(resources.keys())
return (
out_linx,
master_srt,
tagged_text,
stem_outputs[0],
stem_outputs[1],
stem_outputs[2],
stem_outputs[3],
dialogue_json,
report,
)
NODE_CLASS_MAPPINGS = {
"IAMCCS_DialogueScriptPlanner": IAMCCS_DialogueScriptPlanner,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_DialogueScriptPlanner": "IAMCCS DialogueScript Planner",
}
+35
View File
@@ -0,0 +1,35 @@
# IAMCCS Cine Multigeneration
Optional, removable module for Shotboard Plus + AudioBoard multigeneration.
This module is separate from the WAN edition work. It adds a small sequence layer that reads
`IAMCCS_BusOut` audio metadata, splits it into take windows, and writes a
generation index plus concat plan back into `cine_linx`.
Current nodes:
- `IAMCCS_MultiTimelineBridge`
- Reads BusOut master/stem JSON or BusOut resources inside `cine_linx`.
- Builds chunked takes from 10s, 15s, 20s, 25s, or custom durations.
- Outputs an active take with local audio start at frame 0.
- Keeps the original BusOut master/stems available for final concat/mix.
- `IAMCCS_MultiTimelineTakePicker`
- Selects a take from the generated index and exposes that take as active
`cine_linx` audio metadata.
- `IAMCCS_VideoHardConcat`
- Hard-concatenates up to five generated take videos into one final Comfy
`VIDEO` object.
- Can concatenate clip audio, use a supplied master audio input, keep only
the first video's audio, or output silent video.
Commit safety:
- The root loader imports this folder inside a guarded `try`.
- If this folder is excluded from a commit, IAMCCS-nodes still loads normally.
- The module does not patch stable Shotboard or AudioBoard backend files.
Design rule:
External/generated audio remains custom-audio metadata from AudioBoard/BusOut.
The multigeneration layer only creates local take windows for sequential
video-driven generation.
@@ -0,0 +1,815 @@
from __future__ import annotations
import copy
import json
import math
from fractions import Fraction
from typing import Any, Dict, List, Tuple
import torch
import torchaudio
from comfy_api.latest import InputImpl, Types
SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX"
MAX_TRACK_OUTS = 5
def _safe_json_loads(value: Any, fallback: Any) -> Any:
if isinstance(value, (dict, list)):
return value
try:
text = str(value or "").strip()
if not text:
return fallback
return json.loads(text)
except Exception:
return fallback
def _json_dump(data: Any) -> str:
return json.dumps(data, indent=2, ensure_ascii=False)
def _clone_linx(cine_linx: Any, mode: str = "iamccs_multigeneration") -> Dict[str, Any]:
if isinstance(cine_linx, dict):
return copy.deepcopy(cine_linx)
return {
"type": SUPERNODE_LINX_TYPE,
"mode": mode,
"resources": {},
"outputs": {},
"chain": [],
"stages": [],
}
def _resources(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = cine_linx.setdefault("resources", {})
if not isinstance(resources, dict):
resources = {}
cine_linx["resources"] = resources
return resources
def _outputs(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
outputs = cine_linx.setdefault("outputs", {})
if not isinstance(outputs, dict):
outputs = {}
cine_linx["outputs"] = outputs
return outputs
def _payload(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = _resources(cine_linx)
payload = resources.get("cine_payload")
if not isinstance(payload, dict):
payload = {}
resources["cine_payload"] = payload
return payload
def _refresh_linx_index(cine_linx: Dict[str, Any]) -> None:
resources = _resources(cine_linx)
cine_linx["resource_keys"] = sorted(resources.keys())
cine_linx["resource_types"] = {key: type(value).__name__ for key, value in resources.items()}
def _safe_int(value: Any, fallback: int = 0) -> int:
try:
return int(round(float(value)))
except Exception:
return int(fallback)
def _safe_float(value: Any, fallback: float = 0.0) -> float:
try:
return float(value)
except Exception:
return float(fallback)
def _seconds_for_template(template: str, custom_chunk_seconds: Any) -> float:
normalized = str(template or "20s").strip().lower()
if normalized == "custom":
return max(1.0, _safe_float(custom_chunk_seconds, 20.0))
if normalized.endswith("s"):
normalized = normalized[:-1]
return max(1.0, _safe_float(normalized, 20.0))
def _segments(raw: Any) -> List[Dict[str, Any]]:
if not isinstance(raw, list):
return []
out: List[Dict[str, Any]] = []
for index, item in enumerate(raw):
if not isinstance(item, dict):
continue
seg = copy.deepcopy(item)
seg["id"] = str(seg.get("id") or f"multi_src_{index + 1:03d}")
seg["type"] = "audio"
seg["start"] = max(0, _safe_int(seg.get("start", 0), 0))
seg["length"] = max(1, _safe_int(seg.get("length", seg.get("audioDurationFrames", 1)), 1))
seg["track"] = max(0, _safe_int(seg.get("track", 0), 0))
seg["trimStart"] = max(0, _safe_int(seg.get("trimStart", 0), 0))
seg["audioDurationFrames"] = max(seg["trimStart"] + seg["length"], _safe_int(seg.get("audioDurationFrames", seg["length"]), seg["length"]))
out.append(seg)
return sorted(out, key=lambda seg: (int(seg.get("start", 0)), int(seg.get("track", 0))))
def _max_end(segments: List[Dict[str, Any]]) -> int:
return max([_safe_int(seg.get("start", 0), 0) + _safe_int(seg.get("length", 1), 1) for seg in segments] or [0])
def _parse_track_jsons(track_jsons: Tuple[Any, ...]) -> List[Dict[str, Any]]:
parsed: List[Dict[str, Any]] = []
for index, value in enumerate(track_jsons):
data = _safe_json_loads(value, {})
if isinstance(data, dict) and data:
data = copy.deepcopy(data)
data.setdefault("track_index", index)
data.setdefault("track_name", f"A{index + 1}")
data["segments"] = _segments(data.get("segments"))
parsed.append(data)
return parsed
def _bus_manifest(
cine_linx: Any,
bus_manifest_json: Any,
master_out_json: Any,
track_jsons: Tuple[Any, ...],
) -> Dict[str, Any]:
linx = cine_linx if isinstance(cine_linx, dict) else {}
resources = linx.get("resources", {}) if isinstance(linx.get("resources", {}), dict) else {}
manifest = _safe_json_loads(bus_manifest_json, {})
if not isinstance(manifest, dict) or not manifest:
manifest = resources.get("cine_audio_bus_out") if isinstance(resources.get("cine_audio_bus_out"), dict) else {}
if not isinstance(manifest, dict):
manifest = {}
master = _safe_json_loads(master_out_json, {})
if not isinstance(master, dict) or not master:
master = manifest.get("master") if isinstance(manifest.get("master"), dict) else {}
if not isinstance(master, dict) or not master:
audio_tracks = resources.get("cine_audio_tracks") if isinstance(resources.get("cine_audio_tracks"), dict) else {}
master = {
"schema": "iamccs.audio_bus_out.master",
"source": "IAMCCS_MultiTimelineBridge",
"segments": _segments(audio_tracks.get("all_segments") or audio_tracks.get("segments")),
"masterBus": audio_tracks.get("master_bus") if isinstance(audio_tracks.get("master_bus"), dict) else {},
"duration_frames": _safe_int(audio_tracks.get("source_end_frames", audio_tracks.get("duration_frames", 0)), 0),
}
master = copy.deepcopy(master)
master["segments"] = _segments(master.get("segments"))
master["duration_frames"] = max(_safe_int(master.get("duration_frames", 0), 0), _max_end(master["segments"]))
tracks = _parse_track_jsons(track_jsons)
if not tracks and isinstance(manifest.get("tracks"), list):
for index, item in enumerate(manifest.get("tracks") or []):
if not isinstance(item, dict):
continue
track = copy.deepcopy(item)
track.setdefault("track_index", index)
track.setdefault("track_name", f"A{index + 1}")
track["segments"] = _segments(track.get("segments"))
track["duration_frames"] = max(_safe_int(track.get("duration_frames", 0), 0), _max_end(track["segments"]))
tracks.append(track)
if not tracks:
for track_index in range(MAX_TRACK_OUTS):
track_segments = [seg for seg in master["segments"] if _safe_int(seg.get("track", 0), 0) == track_index]
tracks.append({
"schema": "iamccs.audio_bus_out.track",
"source": "IAMCCS_MultiTimelineBridge",
"track_index": track_index,
"track_name": f"A{track_index + 1}",
"segments": track_segments,
"duration_frames": _max_end(track_segments),
})
return {
"schema": "iamccs.audio_bus_out.manifest",
"schema_version": 1,
"source": "IAMCCS_MultiTimelineBridge",
"master": master,
"tracks": tracks[:MAX_TRACK_OUTS],
}
def _source_from_manifest(manifest: Dict[str, Any], source_bus: str) -> Dict[str, Any]:
source = str(source_bus or "master_out")
if source == "master_out":
return copy.deepcopy(manifest.get("master") if isinstance(manifest.get("master"), dict) else {})
if source.startswith("track_"):
track_number = max(1, _safe_int(source.split("_", 1)[1], 1))
tracks = manifest.get("tracks") if isinstance(manifest.get("tracks"), list) else []
index = track_number - 1
if 0 <= index < len(tracks) and isinstance(tracks[index], dict):
return copy.deepcopy(tracks[index])
return {}
def _slice_segments(
segments: List[Dict[str, Any]],
window_start: int,
window_length: int,
track_layout: str,
) -> List[Dict[str, Any]]:
window_end = window_start + window_length
sliced: List[Dict[str, Any]] = []
for index, seg in enumerate(segments):
seg_start = _safe_int(seg.get("start", 0), 0)
seg_length = max(1, _safe_int(seg.get("length", 1), 1))
seg_end = seg_start + seg_length
overlap_start = max(seg_start, window_start)
overlap_end = min(seg_end, window_end)
if overlap_end <= overlap_start:
continue
offset = overlap_start - seg_start
out = copy.deepcopy(seg)
out["id"] = f"{seg.get('id', 'aud')}_take_{window_start}_{index}"
out["start"] = overlap_start - window_start
out["length"] = overlap_end - overlap_start
out["trimStart"] = max(0, _safe_int(seg.get("trimStart", 0), 0) + offset)
out["audioDurationFrames"] = max(out["trimStart"] + out["length"], _safe_int(seg.get("audioDurationFrames", seg_length), seg_length))
out["sourceSegmentId"] = str(seg.get("id", ""))
out["sourceGlobalStart"] = overlap_start
out["sourceGlobalEnd"] = overlap_end
out["sourceTrack"] = _safe_int(seg.get("track", 0), 0)
out["multiGenerationClip"] = True
if str(track_layout) == "collapse_to_lane_1":
out["track"] = 0
sliced.append(out)
return sorted(sliced, key=lambda seg: (_safe_int(seg.get("start", 0), 0), _safe_int(seg.get("track", 0), 0)))
def _timeline_for_take(take: Dict[str, Any], fps: float, track_layout: str) -> Dict[str, Any]:
segments = _segments(take.get("audioSegments"))
track_count = 1
if str(track_layout) == "preserve_bus_tracks":
track_count = max([_safe_int(seg.get("track", 0), 0) + 1 for seg in segments] or [1])
return {
"schema": "iamccs.multigeneration.take_audio_timeline",
"schema_version": 1,
"timeline_id": str(take.get("timeline_id", "")),
"take_index": _safe_int(take.get("take_index", 1), 1),
"frame_rate": float(fps),
"duration_frames": _safe_int(take.get("duration_frames", 0), 0),
"duration_seconds": _safe_int(take.get("duration_frames", 0), 0) / max(1.0, float(fps)),
"audioSegments": segments,
"audioTrackCount": track_count,
"audioBusMode": "all_tracks",
"onlyFirstTrack": False,
"use_custom_audio": any(str(seg.get("audioFile", "")).strip() or str(seg.get("audioB64", "")).strip() for seg in segments),
}
def _take_sort_key(seg: Dict[str, Any], fallback: int) -> Tuple[int, int, int]:
take_index = _safe_int(seg.get("multiTakeIndex", seg.get("take_index", fallback + 1)), fallback + 1)
global_start = _safe_int(seg.get("sourceGlobalStart", seg.get("globalStart", seg.get("start", 0))), 0)
track = _safe_int(seg.get("track", 0), 0)
return take_index, global_start, track
def _takes_from_prechunked_segments(
segments: List[Dict[str, Any]],
fps: float,
chunk_frames: int,
chunk_seconds: float,
source_bus: str,
track_layout: str,
visual_timelines: Any,
) -> List[Dict[str, Any]]:
multi_segments = [
copy.deepcopy(seg)
for seg in segments
if bool(seg.get("multiGenerationClip")) or str(seg.get("timelineId", "") or "").startswith("T")
]
if not multi_segments:
return []
groups: Dict[int, List[Dict[str, Any]]] = {}
for fallback, seg in enumerate(sorted(multi_segments, key=lambda item: _take_sort_key(item, fallback=0))):
take_index = _safe_int(seg.get("multiTakeIndex", seg.get("take_index", fallback + 1)), fallback + 1)
groups.setdefault(max(1, take_index), []).append(seg)
takes: List[Dict[str, Any]] = []
for order, take_index in enumerate(sorted(groups), start=1):
group = groups[take_index]
global_start = min([
_safe_int(seg.get("sourceGlobalStart", seg.get("globalStart", seg.get("start", 0))), 0)
for seg in group
] or [0])
global_end = max([
_safe_int(seg.get("sourceGlobalEnd", seg.get("globalEnd", _safe_int(seg.get("start", 0), 0) + _safe_int(seg.get("length", 1), 1))), 0)
for seg in group
] or [global_start + chunk_frames])
duration = max(1, global_end - global_start)
audio_segments: List[Dict[str, Any]] = []
for seg_index, seg in enumerate(group):
out = copy.deepcopy(seg)
source_global_start = _safe_int(out.get("sourceGlobalStart", out.get("globalStart", out.get("start", 0))), 0)
out["id"] = str(out.get("id") or f"multi_take_{take_index:02d}_{seg_index + 1:02d}")
out["start"] = max(0, source_global_start - global_start)
out["length"] = max(1, _safe_int(out.get("length", 1), 1))
out["sourceTrack"] = _safe_int(out.get("sourceTrack", out.get("track", 0)), 0)
out["timelineId"] = str(out.get("timelineId") or f"T{take_index:02d}")
out["multiTakeIndex"] = int(take_index)
if str(track_layout) == "collapse_to_lane_1":
out["track"] = 0
audio_segments.append(out)
timeline_id = str(group[0].get("timelineId") or f"T{take_index:02d}")
take = {
"schema": "iamccs.multigeneration.take",
"schema_version": 1,
"take_index": int(order),
"source_take_index": int(take_index),
"timeline_id": timeline_id,
"source_bus": str(source_bus),
"global_start_frames": int(global_start),
"global_end_frames": int(global_start + duration),
"local_start_frames": 0,
"duration_frames": int(duration),
"duration_seconds": duration / fps,
"chunk_frames": int(chunk_frames),
"chunk_seconds": float(chunk_seconds),
"audioSegments": sorted(audio_segments, key=lambda item: (_safe_int(item.get("start", 0), 0), _safe_int(item.get("track", 0), 0))),
"audioTrackCount": max([_safe_int(seg.get("track", 0), 0) + 1 for seg in audio_segments] or [1]),
"visual_timeline_key": timeline_id,
"visual_timeline": visual_timelines.get(timeline_id) if isinstance(visual_timelines, dict) else None,
"prechunked": True,
}
take["take_audio_timeline"] = _timeline_for_take(take, fps, str(track_layout))
takes.append(take)
return takes
def _apply_active_take(
cine_linx: Dict[str, Any],
generation_index: Dict[str, Any],
active_take: Dict[str, Any],
track_layout: str,
) -> None:
fps = _safe_float(generation_index.get("frame_rate", 24.0), 24.0)
take_timeline = _timeline_for_take(active_take, fps, track_layout)
duration_frames = _safe_int(take_timeline.get("duration_frames", 0), 0)
duration_seconds = duration_frames / max(1.0, fps)
resources = _resources(cine_linx)
outputs = _outputs(cine_linx)
payload = _payload(cine_linx)
resources["cine_multigeneration_index"] = generation_index
resources["cine_multigeneration_index_json"] = _json_dump(generation_index)
resources["cine_multigeneration_active_take"] = active_take
resources["cine_multigeneration_active_take_json"] = _json_dump(active_take)
resources["cine_multigeneration_take_audio_timeline"] = take_timeline
resources["cine_multigeneration_take_audio_timeline_json"] = _json_dump(take_timeline)
resources["cine_duration_seconds"] = float(duration_seconds)
resources["cine_max_frames"] = int(duration_frames)
payload["multi_generation"] = generation_index
payload["multi_generation_active_take"] = active_take
payload["timeline_id"] = str(active_take.get("timeline_id", ""))
payload["duration_seconds"] = float(duration_seconds)
payload["max_frames"] = int(duration_frames)
payload["audioSegments"] = take_timeline["audioSegments"]
payload["audioTrackCount"] = take_timeline["audioTrackCount"]
payload["use_custom_audio"] = bool(take_timeline["use_custom_audio"])
payload["audioSyncMode"] = "timeline_audio"
outputs["generation_index_json"] = _json_dump(generation_index)
outputs["active_take_json"] = _json_dump(active_take)
outputs["take_audio_timeline_json"] = _json_dump(take_timeline)
outputs["duration_seconds"] = float(duration_seconds)
outputs["max_frames"] = int(duration_frames)
def _make_concat_plan(index: Dict[str, Any], source_bus: str) -> Dict[str, Any]:
takes = index.get("takes") if isinstance(index.get("takes"), list) else []
return {
"schema": "iamccs.multigeneration.concat_plan",
"schema_version": 1,
"source": "IAMCCS_MultiTimelineBridge",
"source_bus": str(source_bus),
"final_audio_policy": "restore_original_master_or_selected_bus_after_video_concat",
"video_concat_policy": "hard_cut_in_take_order",
"takes": [{
"take_index": _safe_int(take.get("take_index", idx + 1), idx + 1),
"timeline_id": str(take.get("timeline_id", f"T{idx + 1:02d}")),
"global_start_frames": _safe_int(take.get("global_start_frames", 0), 0),
"duration_frames": _safe_int(take.get("duration_frames", 0), 0),
"expected_video_slot": f"video_take_{idx + 1:02d}",
} for idx, take in enumerate(takes)],
}
class IAMCCS_MultiTimelineBridge:
"""Build a sequenced take index from BusOut audio for chunked video-driven generation."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"chunk_template": (["10s", "15s", "20s", "25s", "custom"], {"default": "20s"}),
"custom_chunk_seconds": ("FLOAT", {"default": 20.0, "min": 1.0, "max": 300.0, "step": 0.25}),
"source_bus": (["master_out", "track_1", "track_2", "track_3", "track_4", "track_5"], {"default": "master_out"}),
"take_source_mode": (["auto_detect_multi_lanes", "chunk_source_bus"], {"default": "auto_detect_multi_lanes"}),
"take_count_mode": (["auto_from_audio", "fixed_take_count"], {"default": "auto_from_audio"}),
"fixed_take_count": ("INT", {"default": 3, "min": 1, "max": 64, "step": 1}),
"max_takes": ("INT", {"default": 12, "min": 1, "max": 64, "step": 1}),
"active_take": ("INT", {"default": 1, "min": 1, "max": 64, "step": 1}),
"frame_rate": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}),
"take_track_layout": (["collapse_to_lane_1", "preserve_bus_tracks"], {"default": "collapse_to_lane_1"}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
"bus_manifest_json": ("STRING", {"default": "", "multiline": True}),
"master_out_json": ("STRING", {"default": "", "multiline": True}),
"track_1_json": ("STRING", {"default": "", "multiline": True}),
"track_2_json": ("STRING", {"default": "", "multiline": True}),
"track_3_json": ("STRING", {"default": "", "multiline": True}),
"track_4_json": ("STRING", {"default": "", "multiline": True}),
"track_5_json": ("STRING", {"default": "", "multiline": True}),
"visual_timelines_json": ("STRING", {"default": "", "multiline": True}),
},
}
RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("cine_linx", "generation_index_json", "active_take_json", "concat_plan_json", "report")
FUNCTION = "build"
CATEGORY = "IAMCCS/Cine/Multigeneration"
def build(
self,
chunk_template,
custom_chunk_seconds,
source_bus,
take_source_mode,
take_count_mode,
fixed_take_count,
max_takes,
active_take,
frame_rate,
take_track_layout,
cine_linx=None,
bus_manifest_json="",
master_out_json="",
track_1_json="",
track_2_json="",
track_3_json="",
track_4_json="",
track_5_json="",
visual_timelines_json="",
):
fps = max(1.0, float(frame_rate))
chunk_seconds = _seconds_for_template(chunk_template, custom_chunk_seconds)
chunk_frames = max(1, int(round(chunk_seconds * fps)))
max_take_count = max(1, _safe_int(max_takes, 12))
manifest = _bus_manifest(
cine_linx,
bus_manifest_json,
master_out_json,
(track_1_json, track_2_json, track_3_json, track_4_json, track_5_json),
)
source = _source_from_manifest(manifest, source_bus)
source_segments = _segments(source.get("segments"))
source_duration = max(_safe_int(source.get("duration_frames", 0), 0), _max_end(source_segments))
if source_duration <= 0:
source_duration = chunk_frames * max(1, _safe_int(fixed_take_count, 3))
if str(take_count_mode) == "fixed_take_count":
take_count = max(1, min(max_take_count, _safe_int(fixed_take_count, 3)))
else:
take_count = max(1, min(max_take_count, int(math.ceil(source_duration / max(1, chunk_frames)))))
visual_timelines = _safe_json_loads(visual_timelines_json, {})
if not isinstance(visual_timelines, (dict, list)):
visual_timelines = {}
takes = []
if str(take_source_mode) == "auto_detect_multi_lanes":
takes = _takes_from_prechunked_segments(
source_segments,
fps,
chunk_frames,
chunk_seconds,
str(source_bus),
str(take_track_layout),
visual_timelines,
)
if not takes:
for index in range(take_count):
global_start = index * chunk_frames
remaining = max(0, source_duration - global_start)
duration = chunk_frames if str(take_count_mode) == "fixed_take_count" else min(chunk_frames, remaining or chunk_frames)
duration = max(1, int(duration))
timeline_id = f"T{index + 1:02d}"
audio_segments = _slice_segments(source_segments, global_start, duration, str(take_track_layout))
take = {
"schema": "iamccs.multigeneration.take",
"schema_version": 1,
"take_index": index + 1,
"timeline_id": timeline_id,
"source_bus": str(source_bus),
"global_start_frames": int(global_start),
"global_end_frames": int(global_start + duration),
"local_start_frames": 0,
"duration_frames": int(duration),
"duration_seconds": duration / fps,
"chunk_frames": int(chunk_frames),
"chunk_seconds": float(chunk_seconds),
"audioSegments": audio_segments,
"audioTrackCount": max([_safe_int(seg.get("track", 0), 0) + 1 for seg in audio_segments] or [1]),
"visual_timeline_key": timeline_id,
"visual_timeline": visual_timelines.get(timeline_id) if isinstance(visual_timelines, dict) else None,
"prechunked": False,
}
take["take_audio_timeline"] = _timeline_for_take(take, fps, str(take_track_layout))
takes.append(take)
active_index = max(1, min(len(takes), _safe_int(active_take, 1))) - 1
generation_index = {
"schema": "iamccs.multigeneration.index",
"schema_version": 1,
"source": "IAMCCS_MultiTimelineBridge",
"frame_rate": float(fps),
"chunk_template": str(chunk_template),
"chunk_seconds": float(chunk_seconds),
"chunk_frames": int(chunk_frames),
"source_bus": str(source_bus),
"take_source_mode": str(take_source_mode),
"source_duration_frames": int(source_duration),
"source_duration_seconds": source_duration / fps,
"take_count": int(len(takes)),
"active_take": int(active_index + 1),
"take_track_layout": str(take_track_layout),
"takes": takes,
"truth": "Audio remains BusOut/AudioBoard custom-audio metadata. Each take receives a local audio window for sequential video-driven generation, then video takes are hard-concatenated.",
}
concat_plan = _make_concat_plan(generation_index, str(source_bus))
out_linx = _clone_linx(cine_linx)
resources = _resources(out_linx)
outputs = _outputs(out_linx)
resources["cine_multigeneration_concat_plan"] = concat_plan
resources["cine_multigeneration_concat_plan_json"] = _json_dump(concat_plan)
resources["cine_multigeneration_bus_manifest"] = manifest
_apply_active_take(out_linx, generation_index, takes[active_index], str(take_track_layout))
outputs["concat_plan_json"] = _json_dump(concat_plan)
out_linx["type"] = SUPERNODE_LINX_TYPE
out_linx["mode"] = "iamccs_multigeneration"
out_linx.setdefault("chain", []).append({
"role": "multigeneration_bridge",
"name": "IAMCCS_MultiTimelineBridge",
"active_take": active_index + 1,
})
_refresh_linx_index(out_linx)
report = _json_dump({
"node": "IAMCCS_MultiTimelineBridge",
"source_bus": str(source_bus),
"chunk_seconds": float(chunk_seconds),
"chunk_frames": int(chunk_frames),
"take_count": len(takes),
"active_take": active_index + 1,
"source_segments": len(source_segments),
"active_segments": len(takes[active_index].get("audioSegments", [])),
"prechunked": bool(takes[active_index].get("prechunked", False)),
"concat_policy": concat_plan["video_concat_policy"],
})
outputs["report"] = report
return out_linx, _json_dump(generation_index), _json_dump(takes[active_index]), _json_dump(concat_plan), report
class IAMCCS_MultiTimelineTakePicker:
"""Pick one take from a MultiTimelineBridge index and expose it as active cine_linx audio."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"generation_index_json": ("STRING", {"default": "", "multiline": True}),
"take_index": ("INT", {"default": 1, "min": 1, "max": 64, "step": 1}),
"take_track_layout": (["collapse_to_lane_1", "preserve_bus_tracks"], {"default": "collapse_to_lane_1"}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
},
}
RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "STRING", "STRING")
RETURN_NAMES = ("cine_linx", "active_take_json", "take_audio_timeline_json", "report")
FUNCTION = "pick"
CATEGORY = "IAMCCS/Cine/Multigeneration"
def pick(self, generation_index_json, take_index, take_track_layout, cine_linx=None):
generation_index = _safe_json_loads(generation_index_json, {})
if not isinstance(generation_index, dict):
generation_index = {}
takes = generation_index.get("takes") if isinstance(generation_index.get("takes"), list) else []
if not takes:
empty = {
"schema": "iamccs.multigeneration.take",
"schema_version": 1,
"take_index": 1,
"timeline_id": "T01",
"duration_frames": 1,
"audioSegments": [],
}
takes = [empty]
generation_index["takes"] = takes
generation_index.setdefault("frame_rate", 24.0)
active_index = max(1, min(len(takes), _safe_int(take_index, 1))) - 1
active_take = copy.deepcopy(takes[active_index])
generation_index["active_take"] = active_index + 1
out_linx = _clone_linx(cine_linx)
_apply_active_take(out_linx, generation_index, active_take, str(take_track_layout))
out_linx["type"] = SUPERNODE_LINX_TYPE
out_linx["mode"] = "iamccs_multigeneration_take"
out_linx.setdefault("chain", []).append({
"role": "multigeneration_take_picker",
"name": "IAMCCS_MultiTimelineTakePicker",
"active_take": active_index + 1,
})
_refresh_linx_index(out_linx)
take_timeline = _timeline_for_take(active_take, _safe_float(generation_index.get("frame_rate", 24.0), 24.0), str(take_track_layout))
report = _json_dump({
"node": "IAMCCS_MultiTimelineTakePicker",
"active_take": active_index + 1,
"timeline_id": str(active_take.get("timeline_id", "")),
"duration_frames": _safe_int(active_take.get("duration_frames", 0), 0),
"audio_segments": len(active_take.get("audioSegments", []) if isinstance(active_take.get("audioSegments"), list) else []),
})
_outputs(out_linx)["report"] = report
return out_linx, _json_dump(active_take), _json_dump(take_timeline), report
def _video_components(video: Any):
if video is None:
return None
if not hasattr(video, "get_components"):
raise ValueError("IAMCCS Video Hard Concat: input is not a Comfy VIDEO object.")
return video.get_components()
def _normalize_audio_channels(waveform: torch.Tensor, channels: int) -> torch.Tensor:
if waveform.ndim != 3:
raise ValueError("IAMCCS Video Hard Concat: AUDIO waveform must be [batch, channels, samples].")
if waveform.shape[1] == channels:
return waveform
if waveform.shape[1] == 1 and channels == 2:
return waveform.repeat(1, 2, 1)
if waveform.shape[1] < channels:
pad = torch.zeros(
waveform.shape[0],
channels - waveform.shape[1],
waveform.shape[2],
dtype=waveform.dtype,
device=waveform.device,
)
return torch.cat((waveform, pad), dim=1)
return waveform[:, :channels, :]
def _concat_audio(audio_items: List[Tuple[Any, int, float]]) -> Dict[str, Any] | None:
usable = [audio for audio, _, _ in audio_items if isinstance(audio, dict) and audio.get("waveform") is not None]
if not usable:
return None
target_rate = int(usable[0].get("sample_rate") or 44100)
max_channels = max(int(audio["waveform"].shape[1]) for audio in usable)
pieces = []
for audio, frame_count, fps in audio_items:
expected_samples = max(1, int(math.ceil((max(0, frame_count) / max(1.0, fps)) * target_rate)))
if isinstance(audio, dict) and audio.get("waveform") is not None:
waveform = audio["waveform"]
sample_rate = int(audio.get("sample_rate") or target_rate)
if sample_rate != target_rate:
waveform = torchaudio.functional.resample(waveform, sample_rate, target_rate)
waveform = _normalize_audio_channels(waveform, max_channels)
if waveform.shape[-1] < expected_samples:
pad = torch.zeros(
waveform.shape[0],
waveform.shape[1],
expected_samples - waveform.shape[-1],
dtype=waveform.dtype,
device=waveform.device,
)
waveform = torch.cat((waveform, pad), dim=2)
else:
waveform = waveform[..., :expected_samples]
else:
waveform = torch.zeros(1, max_channels, expected_samples)
pieces.append(waveform)
return {"waveform": torch.cat(pieces, dim=2), "sample_rate": target_rate}
class IAMCCS_VideoHardConcat:
"""Hard-concatenate generated take videos into a final VIDEO object."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"video_1": ("VIDEO",),
"audio_policy": (["concat_clip_audio", "use_master_audio", "first_video_audio", "silent"], {"default": "concat_clip_audio"}),
"fps_mode": (["from_first_video", "override_fps"], {"default": "from_first_video"}),
"override_fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}),
},
"optional": {
"video_2": ("VIDEO",),
"video_3": ("VIDEO",),
"video_4": ("VIDEO",),
"video_5": ("VIDEO",),
"master_audio": ("AUDIO",),
"concat_plan_json": ("STRING", {"default": "", "multiline": True}),
},
}
RETURN_TYPES = ("VIDEO", "IMAGE", "STRING")
RETURN_NAMES = ("video", "frames", "report")
FUNCTION = "concat"
CATEGORY = "IAMCCS/Cine/Multigeneration"
def concat(
self,
video_1,
audio_policy,
fps_mode,
override_fps,
video_2=None,
video_3=None,
video_4=None,
video_5=None,
master_audio=None,
concat_plan_json="",
):
videos = [video for video in (video_1, video_2, video_3, video_4, video_5) if video is not None]
if not videos:
raise ValueError("IAMCCS Video Hard Concat: at least video_1 is required.")
components = [_video_components(video) for video in videos]
first = components[0]
first_shape = tuple(first.images.shape[1:3])
first_device = first.images.device
frame_batches = []
frame_counts = []
for index, comp in enumerate(components):
if tuple(comp.images.shape[1:3]) != first_shape:
raise ValueError(
"IAMCCS Video Hard Concat: all takes must share the same height and width. "
f"video_1={first_shape}, video_{index + 1}={tuple(comp.images.shape[1:3])}"
)
images = comp.images
if images.device != first_device:
images = images.to(first_device)
frame_batches.append(images)
frame_counts.append(int(images.shape[0]))
frames = torch.cat(frame_batches, dim=0)
if str(fps_mode) == "override_fps":
fps = float(override_fps)
else:
fps = float(first.frame_rate)
frame_rate = Fraction(round(max(1.0, fps) * 1000), 1000)
audio = None
if str(audio_policy) == "use_master_audio":
audio = master_audio
elif str(audio_policy) == "first_video_audio":
audio = first.audio
elif str(audio_policy) == "concat_clip_audio":
audio = _concat_audio([
(comp.audio, int(comp.images.shape[0]), float(comp.frame_rate))
for comp in components
])
video = InputImpl.VideoFromComponents(Types.VideoComponents(images=frames, audio=audio, frame_rate=frame_rate))
concat_plan = _safe_json_loads(concat_plan_json, {})
report = _json_dump({
"node": "IAMCCS_VideoHardConcat",
"policy": "hard_cut_tensor_concat",
"take_count": len(videos),
"frames_per_take": frame_counts,
"total_frames": int(frames.shape[0]),
"fps": float(frame_rate),
"duration_seconds": int(frames.shape[0]) / max(1.0, float(frame_rate)),
"audio_policy": str(audio_policy),
"has_audio": audio is not None,
"concat_plan_takes": len(concat_plan.get("takes", [])) if isinstance(concat_plan, dict) else 0,
})
return video, frames, report
NODE_CLASS_MAPPINGS = {
"IAMCCS_MultiTimelineBridge": IAMCCS_MultiTimelineBridge,
"IAMCCS_MultiTimelineTakePicker": IAMCCS_MultiTimelineTakePicker,
"IAMCCS_VideoHardConcat": IAMCCS_VideoHardConcat,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_MultiTimelineBridge": "IAMCCS MultiTimeline Bridge",
"IAMCCS_MultiTimelineTakePicker": "IAMCCS MultiTimeline Take Picker",
"IAMCCS_VideoHardConcat": "IAMCCS Video Hard Concat",
}
+871
View File
@@ -0,0 +1,871 @@
from __future__ import annotations
import copy
import json
import math
from fractions import Fraction
from typing import Any, Dict, List, Tuple
import torch
import torchaudio
from comfy_api.latest import InputImpl, Types
SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX"
MAX_TRACK_OUTS = 5
def _safe_json_loads(value: Any, fallback: Any) -> Any:
if isinstance(value, (dict, list)):
return value
try:
text = str(value or "").strip()
if not text:
return fallback
return json.loads(text)
except Exception:
return fallback
def _json_dump(data: Any) -> str:
return json.dumps(data, indent=2, ensure_ascii=False)
def _clone_linx(cine_linx: Any, mode: str = "iamccs_multigeneration") -> Dict[str, Any]:
if isinstance(cine_linx, dict):
return copy.deepcopy(cine_linx)
return {
"type": SUPERNODE_LINX_TYPE,
"mode": mode,
"resources": {},
"outputs": {},
"chain": [],
"stages": [],
}
def _resources(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = cine_linx.setdefault("resources", {})
if not isinstance(resources, dict):
resources = {}
cine_linx["resources"] = resources
return resources
def _outputs(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
outputs = cine_linx.setdefault("outputs", {})
if not isinstance(outputs, dict):
outputs = {}
cine_linx["outputs"] = outputs
return outputs
def _payload(cine_linx: Dict[str, Any]) -> Dict[str, Any]:
resources = _resources(cine_linx)
payload = resources.get("cine_payload")
if not isinstance(payload, dict):
payload = {}
resources["cine_payload"] = payload
return payload
def _refresh_linx_index(cine_linx: Dict[str, Any]) -> None:
resources = _resources(cine_linx)
cine_linx["resource_keys"] = sorted(resources.keys())
cine_linx["resource_types"] = {key: type(value).__name__ for key, value in resources.items()}
def _safe_int(value: Any, fallback: int = 0) -> int:
try:
return int(round(float(value)))
except Exception:
return int(fallback)
def _safe_float(value: Any, fallback: float = 0.0) -> float:
try:
return float(value)
except Exception:
return float(fallback)
def _seconds_for_template(template: str, custom_chunk_seconds: Any) -> float:
normalized = str(template or "20s").strip().lower()
if normalized == "custom":
return max(1.0, _safe_float(custom_chunk_seconds, 20.0))
if normalized.endswith("s"):
normalized = normalized[:-1]
return max(1.0, _safe_float(normalized, 20.0))
def _segments(raw: Any) -> List[Dict[str, Any]]:
if not isinstance(raw, list):
return []
out: List[Dict[str, Any]] = []
for index, item in enumerate(raw):
if not isinstance(item, dict):
continue
seg = copy.deepcopy(item)
seg["id"] = str(seg.get("id") or f"multi_src_{index + 1:03d}")
seg["type"] = "audio"
seg["start"] = max(0, _safe_int(seg.get("start", 0), 0))
seg["length"] = max(1, _safe_int(seg.get("length", seg.get("audioDurationFrames", 1)), 1))
seg["track"] = max(0, _safe_int(seg.get("track", 0), 0))
seg["trimStart"] = max(0, _safe_int(seg.get("trimStart", 0), 0))
seg["audioDurationFrames"] = max(seg["trimStart"] + seg["length"], _safe_int(seg.get("audioDurationFrames", seg["length"]), seg["length"]))
out.append(seg)
return sorted(out, key=lambda seg: (int(seg.get("start", 0)), int(seg.get("track", 0))))
def _max_end(segments: List[Dict[str, Any]]) -> int:
return max([_safe_int(seg.get("start", 0), 0) + _safe_int(seg.get("length", 1), 1) for seg in segments] or [0])
def _parse_track_jsons(track_jsons: Tuple[Any, ...]) -> List[Dict[str, Any]]:
parsed: List[Dict[str, Any]] = []
for index, value in enumerate(track_jsons):
data = _safe_json_loads(value, {})
if isinstance(data, dict) and data:
data = copy.deepcopy(data)
data.setdefault("track_index", index)
data.setdefault("track_name", f"A{index + 1}")
data["segments"] = _segments(data.get("segments"))
parsed.append(data)
return parsed
def _bus_manifest(
cine_linx: Any,
bus_manifest_json: Any,
master_out_json: Any,
track_jsons: Tuple[Any, ...],
) -> Dict[str, Any]:
linx = cine_linx if isinstance(cine_linx, dict) else {}
resources = linx.get("resources", {}) if isinstance(linx.get("resources", {}), dict) else {}
manifest = _safe_json_loads(bus_manifest_json, {})
if not isinstance(manifest, dict) or not manifest:
manifest = resources.get("cine_audio_bus_out") if isinstance(resources.get("cine_audio_bus_out"), dict) else {}
if not isinstance(manifest, dict):
manifest = {}
master = _safe_json_loads(master_out_json, {})
if not isinstance(master, dict) or not master:
master = manifest.get("master") if isinstance(manifest.get("master"), dict) else {}
if not isinstance(master, dict) or not master:
audio_tracks = resources.get("cine_audio_tracks") if isinstance(resources.get("cine_audio_tracks"), dict) else {}
master = {
"schema": "iamccs.audio_bus_out.master",
"source": "IAMCCS_MultiTimelineBridge",
"segments": _segments(audio_tracks.get("all_segments") or audio_tracks.get("segments")),
"masterBus": audio_tracks.get("master_bus") if isinstance(audio_tracks.get("master_bus"), dict) else {},
"duration_frames": _safe_int(audio_tracks.get("source_end_frames", audio_tracks.get("duration_frames", 0)), 0),
}
master = copy.deepcopy(master)
master["segments"] = _segments(master.get("segments"))
master["duration_frames"] = max(_safe_int(master.get("duration_frames", 0), 0), _max_end(master["segments"]))
tracks = _parse_track_jsons(track_jsons)
if not tracks and isinstance(manifest.get("tracks"), list):
for index, item in enumerate(manifest.get("tracks") or []):
if not isinstance(item, dict):
continue
track = copy.deepcopy(item)
track.setdefault("track_index", index)
track.setdefault("track_name", f"A{index + 1}")
track["segments"] = _segments(track.get("segments"))
track["duration_frames"] = max(_safe_int(track.get("duration_frames", 0), 0), _max_end(track["segments"]))
tracks.append(track)
if not tracks:
for track_index in range(MAX_TRACK_OUTS):
track_segments = [seg for seg in master["segments"] if _safe_int(seg.get("track", 0), 0) == track_index]
tracks.append({
"schema": "iamccs.audio_bus_out.track",
"source": "IAMCCS_MultiTimelineBridge",
"track_index": track_index,
"track_name": f"A{track_index + 1}",
"segments": track_segments,
"duration_frames": _max_end(track_segments),
})
generation_index = manifest.get("generation_index") if isinstance(manifest.get("generation_index"), dict) else {}
if not generation_index:
generation_index = resources.get("cine_audio_generation_index") if isinstance(resources.get("cine_audio_generation_index"), dict) else {}
if not generation_index:
generation_index = _safe_json_loads(resources.get("cine_audio_generation_index_json"), {})
if not isinstance(generation_index, dict):
generation_index = {}
return {
"schema": "iamccs.audio_bus_out.manifest",
"schema_version": 1,
"source": "IAMCCS_MultiTimelineBridge",
"master": master,
"tracks": tracks[:MAX_TRACK_OUTS],
"generation_index": generation_index,
}
def _source_from_manifest(manifest: Dict[str, Any], source_bus: str) -> Dict[str, Any]:
source = str(source_bus or "master_out")
if source == "master_out":
return copy.deepcopy(manifest.get("master") if isinstance(manifest.get("master"), dict) else {})
if source.startswith("track_"):
track_number = max(1, _safe_int(source.split("_", 1)[1], 1))
tracks = manifest.get("tracks") if isinstance(manifest.get("tracks"), list) else []
index = track_number - 1
if 0 <= index < len(tracks) and isinstance(tracks[index], dict):
return copy.deepcopy(tracks[index])
return {}
def _slice_segments(
segments: List[Dict[str, Any]],
window_start: int,
window_length: int,
track_layout: str,
) -> List[Dict[str, Any]]:
window_end = window_start + window_length
sliced: List[Dict[str, Any]] = []
for index, seg in enumerate(segments):
seg_start = _safe_int(seg.get("start", 0), 0)
seg_length = max(1, _safe_int(seg.get("length", 1), 1))
seg_end = seg_start + seg_length
overlap_start = max(seg_start, window_start)
overlap_end = min(seg_end, window_end)
if overlap_end <= overlap_start:
continue
offset = overlap_start - seg_start
out = copy.deepcopy(seg)
out["id"] = f"{seg.get('id', 'aud')}_take_{window_start}_{index}"
out["start"] = overlap_start - window_start
out["length"] = overlap_end - overlap_start
out["trimStart"] = max(0, _safe_int(seg.get("trimStart", 0), 0) + offset)
out["audioDurationFrames"] = max(out["trimStart"] + out["length"], _safe_int(seg.get("audioDurationFrames", seg_length), seg_length))
out["sourceSegmentId"] = str(seg.get("id", ""))
out["sourceGlobalStart"] = overlap_start
out["sourceGlobalEnd"] = overlap_end
out["sourceTrack"] = _safe_int(seg.get("track", 0), 0)
out["multiGenerationClip"] = True
if str(track_layout) == "collapse_to_lane_1":
out["track"] = 0
sliced.append(out)
return sorted(sliced, key=lambda seg: (_safe_int(seg.get("start", 0), 0), _safe_int(seg.get("track", 0), 0)))
def _timeline_for_take(take: Dict[str, Any], fps: float, track_layout: str) -> Dict[str, Any]:
segments = _segments(take.get("audioSegments"))
track_count = 1
if str(track_layout) == "preserve_bus_tracks":
track_count = max([_safe_int(seg.get("track", 0), 0) + 1 for seg in segments] or [1])
return {
"schema": "iamccs.multigeneration.take_audio_timeline",
"schema_version": 1,
"timeline_id": str(take.get("timeline_id", "")),
"take_index": _safe_int(take.get("take_index", 1), 1),
"frame_rate": float(fps),
"duration_frames": _safe_int(take.get("duration_frames", 0), 0),
"duration_seconds": _safe_int(take.get("duration_frames", 0), 0) / max(1.0, float(fps)),
"audioSegments": segments,
"audioTrackCount": track_count,
"audioBusMode": "all_tracks",
"onlyFirstTrack": False,
"use_custom_audio": any(str(seg.get("audioFile", "")).strip() or str(seg.get("audioB64", "")).strip() for seg in segments),
}
def _take_sort_key(seg: Dict[str, Any], fallback: int) -> Tuple[int, int, int]:
take_index = _safe_int(seg.get("multiTakeIndex", seg.get("take_index", fallback + 1)), fallback + 1)
global_start = _safe_int(seg.get("sourceGlobalStart", seg.get("globalStart", seg.get("start", 0))), 0)
track = _safe_int(seg.get("track", 0), 0)
return take_index, global_start, track
def _segments_from_generation_index(generation_index: Any) -> List[Dict[str, Any]]:
if not isinstance(generation_index, dict):
return []
out: List[Dict[str, Any]] = []
for take in generation_index.get("takes") if isinstance(generation_index.get("takes"), list) else []:
if isinstance(take, dict):
out.extend(_segments(take.get("segments")))
return out
def _collect_prechunked_segments(manifest: Dict[str, Any], source_segments: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
candidates: List[Dict[str, Any]] = list(source_segments)
master = manifest.get("master") if isinstance(manifest.get("master"), dict) else {}
candidates.extend(_segments(master.get("segments")))
for track in manifest.get("tracks") if isinstance(manifest.get("tracks"), list) else []:
if isinstance(track, dict):
candidates.extend(_segments(track.get("segments")))
candidates.extend(_segments_from_generation_index(manifest.get("generation_index")))
out: List[Dict[str, Any]] = []
seen = set()
for seg in candidates:
if not (bool(seg.get("multiGenerationClip")) or str(seg.get("timelineId", "") or "").startswith("T")):
continue
key = (
str(seg.get("id", "")),
str(seg.get("timelineId", "")),
_safe_int(seg.get("multiTakeIndex", seg.get("take_index", 0)), 0),
_safe_int(seg.get("track", 0), 0),
_safe_int(seg.get("sourceGlobalStart", seg.get("globalStart", seg.get("start", 0))), 0),
_safe_int(seg.get("length", 1), 1),
)
if key in seen:
continue
seen.add(key)
out.append(copy.deepcopy(seg))
return sorted(out, key=lambda item: _take_sort_key(item, 0))
def _takes_from_prechunked_segments(
segments: List[Dict[str, Any]],
fps: float,
chunk_frames: int,
chunk_seconds: float,
source_bus: str,
track_layout: str,
visual_timelines: Any,
) -> List[Dict[str, Any]]:
multi_segments = [
copy.deepcopy(seg)
for seg in segments
if bool(seg.get("multiGenerationClip")) or str(seg.get("timelineId", "") or "").startswith("T")
]
if not multi_segments:
return []
groups: Dict[int, List[Dict[str, Any]]] = {}
for fallback, seg in enumerate(sorted(multi_segments, key=lambda item: _take_sort_key(item, fallback=0))):
take_index = _safe_int(seg.get("multiTakeIndex", seg.get("take_index", fallback + 1)), fallback + 1)
groups.setdefault(max(1, take_index), []).append(seg)
takes: List[Dict[str, Any]] = []
for order, take_index in enumerate(sorted(groups), start=1):
group = groups[take_index]
global_start = min([
_safe_int(seg.get("sourceGlobalStart", seg.get("globalStart", seg.get("start", 0))), 0)
for seg in group
] or [0])
global_end = max([
_safe_int(seg.get("sourceGlobalEnd", seg.get("globalEnd", _safe_int(seg.get("start", 0), 0) + _safe_int(seg.get("length", 1), 1))), 0)
for seg in group
] or [global_start + chunk_frames])
duration = max(1, global_end - global_start)
audio_segments: List[Dict[str, Any]] = []
for seg_index, seg in enumerate(group):
out = copy.deepcopy(seg)
source_global_start = _safe_int(out.get("sourceGlobalStart", out.get("globalStart", out.get("start", 0))), 0)
out["id"] = str(out.get("id") or f"multi_take_{take_index:02d}_{seg_index + 1:02d}")
out["start"] = max(0, source_global_start - global_start)
out["length"] = max(1, _safe_int(out.get("length", 1), 1))
out["sourceTrack"] = _safe_int(out.get("sourceTrack", out.get("track", 0)), 0)
out["timelineId"] = str(out.get("timelineId") or f"T{take_index:02d}")
out["multiTakeIndex"] = int(take_index)
if str(track_layout) == "collapse_to_lane_1":
out["track"] = 0
audio_segments.append(out)
timeline_id = str(group[0].get("timelineId") or f"T{take_index:02d}")
take = {
"schema": "iamccs.multigeneration.take",
"schema_version": 1,
"take_index": int(order),
"source_take_index": int(take_index),
"timeline_id": timeline_id,
"source_bus": str(source_bus),
"global_start_frames": int(global_start),
"global_end_frames": int(global_start + duration),
"local_start_frames": 0,
"duration_frames": int(duration),
"duration_seconds": duration / fps,
"chunk_frames": int(chunk_frames),
"chunk_seconds": float(chunk_seconds),
"audioSegments": sorted(audio_segments, key=lambda item: (_safe_int(item.get("start", 0), 0), _safe_int(item.get("track", 0), 0))),
"audioTrackCount": max([_safe_int(seg.get("track", 0), 0) + 1 for seg in audio_segments] or [1]),
"visual_timeline_key": timeline_id,
"visual_timeline": visual_timelines.get(timeline_id) if isinstance(visual_timelines, dict) else None,
"prechunked": True,
}
take["take_audio_timeline"] = _timeline_for_take(take, fps, str(track_layout))
takes.append(take)
return takes
def _apply_active_take(
cine_linx: Dict[str, Any],
generation_index: Dict[str, Any],
active_take: Dict[str, Any],
track_layout: str,
) -> None:
fps = _safe_float(generation_index.get("frame_rate", 24.0), 24.0)
take_timeline = _timeline_for_take(active_take, fps, track_layout)
duration_frames = _safe_int(take_timeline.get("duration_frames", 0), 0)
duration_seconds = duration_frames / max(1.0, fps)
resources = _resources(cine_linx)
outputs = _outputs(cine_linx)
payload = _payload(cine_linx)
resources["cine_multigeneration_index"] = generation_index
resources["cine_multigeneration_index_json"] = _json_dump(generation_index)
resources["cine_multigeneration_active_take"] = active_take
resources["cine_multigeneration_active_take_json"] = _json_dump(active_take)
resources["cine_multigeneration_take_audio_timeline"] = take_timeline
resources["cine_multigeneration_take_audio_timeline_json"] = _json_dump(take_timeline)
resources["cine_duration_seconds"] = float(duration_seconds)
resources["cine_max_frames"] = int(duration_frames)
payload["multi_generation"] = generation_index
payload["multi_generation_active_take"] = active_take
payload["timeline_id"] = str(active_take.get("timeline_id", ""))
payload["duration_seconds"] = float(duration_seconds)
payload["max_frames"] = int(duration_frames)
payload["audioSegments"] = take_timeline["audioSegments"]
payload["audioTrackCount"] = take_timeline["audioTrackCount"]
payload["use_custom_audio"] = bool(take_timeline["use_custom_audio"])
payload["audioSyncMode"] = "timeline_audio"
outputs["generation_index_json"] = _json_dump(generation_index)
outputs["active_take_json"] = _json_dump(active_take)
outputs["take_audio_timeline_json"] = _json_dump(take_timeline)
outputs["duration_seconds"] = float(duration_seconds)
outputs["max_frames"] = int(duration_frames)
def _make_concat_plan(index: Dict[str, Any], source_bus: str) -> Dict[str, Any]:
takes = index.get("takes") if isinstance(index.get("takes"), list) else []
return {
"schema": "iamccs.multigeneration.concat_plan",
"schema_version": 1,
"source": "IAMCCS_MultiTimelineBridge",
"source_bus": str(source_bus),
"final_audio_policy": "restore_original_master_or_selected_bus_after_video_concat",
"video_concat_policy": "hard_cut_in_take_order",
"takes": [{
"take_index": _safe_int(take.get("take_index", idx + 1), idx + 1),
"timeline_id": str(take.get("timeline_id", f"T{idx + 1:02d}")),
"global_start_frames": _safe_int(take.get("global_start_frames", 0), 0),
"duration_frames": _safe_int(take.get("duration_frames", 0), 0),
"expected_video_slot": f"video_take_{idx + 1:02d}",
} for idx, take in enumerate(takes)],
}
class IAMCCS_MultiTimelineBridge:
"""Build a sequenced take index from BusOut audio for chunked video-driven generation."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"chunk_template": (["10s", "15s", "20s", "25s", "custom"], {"default": "20s"}),
"custom_chunk_seconds": ("FLOAT", {"default": 20.0, "min": 1.0, "max": 300.0, "step": 0.25}),
"source_bus": (["master_out", "track_1", "track_2", "track_3", "track_4", "track_5"], {"default": "master_out"}),
"take_source_mode": (["auto_detect_multi_lanes", "chunk_source_bus"], {"default": "auto_detect_multi_lanes"}),
"take_count_mode": (["auto_from_audio", "fixed_take_count"], {"default": "auto_from_audio"}),
"fixed_take_count": ("INT", {"default": 3, "min": 1, "max": 64, "step": 1}),
"max_takes": ("INT", {"default": 12, "min": 1, "max": 64, "step": 1}),
"active_take": ("INT", {"default": 1, "min": 1, "max": 64, "step": 1}),
"frame_rate": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}),
"take_track_layout": (["collapse_to_lane_1", "preserve_bus_tracks"], {"default": "collapse_to_lane_1"}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
"bus_manifest_json": ("STRING", {"default": "", "multiline": True}),
"master_out_json": ("STRING", {"default": "", "multiline": True}),
"track_1_json": ("STRING", {"default": "", "multiline": True}),
"track_2_json": ("STRING", {"default": "", "multiline": True}),
"track_3_json": ("STRING", {"default": "", "multiline": True}),
"track_4_json": ("STRING", {"default": "", "multiline": True}),
"track_5_json": ("STRING", {"default": "", "multiline": True}),
"visual_timelines_json": ("STRING", {"default": "", "multiline": True}),
},
}
RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("cine_linx", "generation_index_json", "active_take_json", "concat_plan_json", "report")
FUNCTION = "build"
CATEGORY = "IAMCCS/Cine/Multigeneration"
def build(
self,
chunk_template,
custom_chunk_seconds,
source_bus,
take_source_mode,
take_count_mode,
fixed_take_count,
max_takes,
active_take,
frame_rate,
take_track_layout,
cine_linx=None,
bus_manifest_json="",
master_out_json="",
track_1_json="",
track_2_json="",
track_3_json="",
track_4_json="",
track_5_json="",
visual_timelines_json="",
):
fps = max(1.0, float(frame_rate))
chunk_seconds = _seconds_for_template(chunk_template, custom_chunk_seconds)
chunk_frames = max(1, int(round(chunk_seconds * fps)))
max_take_count = max(1, _safe_int(max_takes, 12))
manifest = _bus_manifest(
cine_linx,
bus_manifest_json,
master_out_json,
(track_1_json, track_2_json, track_3_json, track_4_json, track_5_json),
)
source = _source_from_manifest(manifest, source_bus)
source_segments = _segments(source.get("segments"))
source_duration = max(_safe_int(source.get("duration_frames", 0), 0), _max_end(source_segments))
if source_duration <= 0:
source_duration = chunk_frames * max(1, _safe_int(fixed_take_count, 3))
if str(take_count_mode) == "fixed_take_count":
take_count = max(1, min(max_take_count, _safe_int(fixed_take_count, 3)))
else:
take_count = max(1, min(max_take_count, int(math.ceil(source_duration / max(1, chunk_frames)))))
visual_timelines = _safe_json_loads(visual_timelines_json, {})
if not isinstance(visual_timelines, (dict, list)):
visual_timelines = {}
takes = []
if str(take_source_mode) == "auto_detect_multi_lanes":
prechunked_segments = _collect_prechunked_segments(manifest, source_segments)
if prechunked_segments:
prechunked_end = max([
_safe_int(seg.get("sourceGlobalEnd", seg.get("globalEnd", _safe_int(seg.get("start", 0), 0) + _safe_int(seg.get("length", 1), 1))), 0)
for seg in prechunked_segments
] or [0])
source_duration = max(source_duration, prechunked_end)
takes = _takes_from_prechunked_segments(
prechunked_segments,
fps,
chunk_frames,
chunk_seconds,
str(source_bus),
str(take_track_layout),
visual_timelines,
)
if not takes:
for index in range(take_count):
global_start = index * chunk_frames
remaining = max(0, source_duration - global_start)
duration = chunk_frames if str(take_count_mode) == "fixed_take_count" else min(chunk_frames, remaining or chunk_frames)
duration = max(1, int(duration))
timeline_id = f"T{index + 1:02d}"
audio_segments = _slice_segments(source_segments, global_start, duration, str(take_track_layout))
take = {
"schema": "iamccs.multigeneration.take",
"schema_version": 1,
"take_index": index + 1,
"timeline_id": timeline_id,
"source_bus": str(source_bus),
"global_start_frames": int(global_start),
"global_end_frames": int(global_start + duration),
"local_start_frames": 0,
"duration_frames": int(duration),
"duration_seconds": duration / fps,
"chunk_frames": int(chunk_frames),
"chunk_seconds": float(chunk_seconds),
"audioSegments": audio_segments,
"audioTrackCount": max([_safe_int(seg.get("track", 0), 0) + 1 for seg in audio_segments] or [1]),
"visual_timeline_key": timeline_id,
"visual_timeline": visual_timelines.get(timeline_id) if isinstance(visual_timelines, dict) else None,
"prechunked": False,
}
take["take_audio_timeline"] = _timeline_for_take(take, fps, str(take_track_layout))
takes.append(take)
active_index = max(1, min(len(takes), _safe_int(active_take, 1))) - 1
generation_index = {
"schema": "iamccs.multigeneration.index",
"schema_version": 1,
"source": "IAMCCS_MultiTimelineBridge",
"frame_rate": float(fps),
"chunk_template": str(chunk_template),
"chunk_seconds": float(chunk_seconds),
"chunk_frames": int(chunk_frames),
"source_bus": str(source_bus),
"take_source_mode": str(take_source_mode),
"source_duration_frames": int(source_duration),
"source_duration_seconds": source_duration / fps,
"take_count": int(len(takes)),
"active_take": int(active_index + 1),
"take_track_layout": str(take_track_layout),
"takes": takes,
"bus_generation_index": manifest.get("generation_index") if isinstance(manifest.get("generation_index"), dict) else {},
"truth": "Audio remains BusOut/AudioBoard custom-audio metadata. Each take receives a local audio window for sequential video-driven generation, then video takes are hard-concatenated.",
}
concat_plan = _make_concat_plan(generation_index, str(source_bus))
out_linx = _clone_linx(cine_linx)
resources = _resources(out_linx)
outputs = _outputs(out_linx)
resources["cine_multigeneration_concat_plan"] = concat_plan
resources["cine_multigeneration_concat_plan_json"] = _json_dump(concat_plan)
resources["cine_multigeneration_bus_manifest"] = manifest
_apply_active_take(out_linx, generation_index, takes[active_index], str(take_track_layout))
outputs["concat_plan_json"] = _json_dump(concat_plan)
out_linx["type"] = SUPERNODE_LINX_TYPE
out_linx["mode"] = "iamccs_multigeneration"
out_linx.setdefault("chain", []).append({
"role": "multigeneration_bridge",
"name": "IAMCCS_MultiTimelineBridge",
"active_take": active_index + 1,
})
_refresh_linx_index(out_linx)
report = _json_dump({
"node": "IAMCCS_MultiTimelineBridge",
"source_bus": str(source_bus),
"chunk_seconds": float(chunk_seconds),
"chunk_frames": int(chunk_frames),
"take_count": len(takes),
"active_take": active_index + 1,
"source_segments": len(source_segments),
"active_segments": len(takes[active_index].get("audioSegments", [])),
"prechunked": bool(takes[active_index].get("prechunked", False)),
"concat_policy": concat_plan["video_concat_policy"],
})
outputs["report"] = report
return out_linx, _json_dump(generation_index), _json_dump(takes[active_index]), _json_dump(concat_plan), report
class IAMCCS_MultiTimelineTakePicker:
"""Pick one take from a MultiTimelineBridge index and expose it as active cine_linx audio."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"generation_index_json": ("STRING", {"default": "", "multiline": True}),
"take_index": ("INT", {"default": 1, "min": 1, "max": 64, "step": 1}),
"take_track_layout": (["collapse_to_lane_1", "preserve_bus_tracks"], {"default": "collapse_to_lane_1"}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
},
}
RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "STRING", "STRING")
RETURN_NAMES = ("cine_linx", "active_take_json", "take_audio_timeline_json", "report")
FUNCTION = "pick"
CATEGORY = "IAMCCS/Cine/Multigeneration"
def pick(self, generation_index_json, take_index, take_track_layout, cine_linx=None):
generation_index = _safe_json_loads(generation_index_json, {})
if not isinstance(generation_index, dict):
generation_index = {}
takes = generation_index.get("takes") if isinstance(generation_index.get("takes"), list) else []
if not takes:
empty = {
"schema": "iamccs.multigeneration.take",
"schema_version": 1,
"take_index": 1,
"timeline_id": "T01",
"duration_frames": 1,
"audioSegments": [],
}
takes = [empty]
generation_index["takes"] = takes
generation_index.setdefault("frame_rate", 24.0)
active_index = max(1, min(len(takes), _safe_int(take_index, 1))) - 1
active_take = copy.deepcopy(takes[active_index])
generation_index["active_take"] = active_index + 1
out_linx = _clone_linx(cine_linx)
_apply_active_take(out_linx, generation_index, active_take, str(take_track_layout))
out_linx["type"] = SUPERNODE_LINX_TYPE
out_linx["mode"] = "iamccs_multigeneration_take"
out_linx.setdefault("chain", []).append({
"role": "multigeneration_take_picker",
"name": "IAMCCS_MultiTimelineTakePicker",
"active_take": active_index + 1,
})
_refresh_linx_index(out_linx)
take_timeline = _timeline_for_take(active_take, _safe_float(generation_index.get("frame_rate", 24.0), 24.0), str(take_track_layout))
report = _json_dump({
"node": "IAMCCS_MultiTimelineTakePicker",
"active_take": active_index + 1,
"timeline_id": str(active_take.get("timeline_id", "")),
"duration_frames": _safe_int(active_take.get("duration_frames", 0), 0),
"audio_segments": len(active_take.get("audioSegments", []) if isinstance(active_take.get("audioSegments"), list) else []),
})
_outputs(out_linx)["report"] = report
return out_linx, _json_dump(active_take), _json_dump(take_timeline), report
def _video_components(video: Any):
if video is None:
return None
if not hasattr(video, "get_components"):
raise ValueError("IAMCCS Video Hard Concat: input is not a Comfy VIDEO object.")
return video.get_components()
def _normalize_audio_channels(waveform: torch.Tensor, channels: int) -> torch.Tensor:
if waveform.ndim != 3:
raise ValueError("IAMCCS Video Hard Concat: AUDIO waveform must be [batch, channels, samples].")
if waveform.shape[1] == channels:
return waveform
if waveform.shape[1] == 1 and channels == 2:
return waveform.repeat(1, 2, 1)
if waveform.shape[1] < channels:
pad = torch.zeros(
waveform.shape[0],
channels - waveform.shape[1],
waveform.shape[2],
dtype=waveform.dtype,
device=waveform.device,
)
return torch.cat((waveform, pad), dim=1)
return waveform[:, :channels, :]
def _concat_audio(audio_items: List[Tuple[Any, int, float]]) -> Dict[str, Any] | None:
usable = [audio for audio, _, _ in audio_items if isinstance(audio, dict) and audio.get("waveform") is not None]
if not usable:
return None
target_rate = int(usable[0].get("sample_rate") or 44100)
max_channels = max(int(audio["waveform"].shape[1]) for audio in usable)
pieces = []
for audio, frame_count, fps in audio_items:
expected_samples = max(1, int(math.ceil((max(0, frame_count) / max(1.0, fps)) * target_rate)))
if isinstance(audio, dict) and audio.get("waveform") is not None:
waveform = audio["waveform"]
sample_rate = int(audio.get("sample_rate") or target_rate)
if sample_rate != target_rate:
waveform = torchaudio.functional.resample(waveform, sample_rate, target_rate)
waveform = _normalize_audio_channels(waveform, max_channels)
if waveform.shape[-1] < expected_samples:
pad = torch.zeros(
waveform.shape[0],
waveform.shape[1],
expected_samples - waveform.shape[-1],
dtype=waveform.dtype,
device=waveform.device,
)
waveform = torch.cat((waveform, pad), dim=2)
else:
waveform = waveform[..., :expected_samples]
else:
waveform = torch.zeros(1, max_channels, expected_samples)
pieces.append(waveform)
return {"waveform": torch.cat(pieces, dim=2), "sample_rate": target_rate}
class IAMCCS_VideoHardConcat:
"""Hard-concatenate generated take videos into a final VIDEO object."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"video_1": ("VIDEO",),
"audio_policy": (["concat_clip_audio", "use_master_audio", "first_video_audio", "silent"], {"default": "concat_clip_audio"}),
"fps_mode": (["from_first_video", "override_fps"], {"default": "from_first_video"}),
"override_fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}),
},
"optional": {
"video_2": ("VIDEO",),
"video_3": ("VIDEO",),
"video_4": ("VIDEO",),
"video_5": ("VIDEO",),
"master_audio": ("AUDIO",),
"concat_plan_json": ("STRING", {"default": "", "multiline": True}),
},
}
RETURN_TYPES = ("VIDEO", "IMAGE", "STRING")
RETURN_NAMES = ("video", "frames", "report")
FUNCTION = "concat"
CATEGORY = "IAMCCS/Cine/Multigeneration"
def concat(
self,
video_1,
audio_policy,
fps_mode,
override_fps,
video_2=None,
video_3=None,
video_4=None,
video_5=None,
master_audio=None,
concat_plan_json="",
):
videos = [video for video in (video_1, video_2, video_3, video_4, video_5) if video is not None]
if not videos:
raise ValueError("IAMCCS Video Hard Concat: at least video_1 is required.")
components = [_video_components(video) for video in videos]
first = components[0]
first_shape = tuple(first.images.shape[1:3])
first_device = first.images.device
frame_batches = []
frame_counts = []
for index, comp in enumerate(components):
if tuple(comp.images.shape[1:3]) != first_shape:
raise ValueError(
"IAMCCS Video Hard Concat: all takes must share the same height and width. "
f"video_1={first_shape}, video_{index + 1}={tuple(comp.images.shape[1:3])}"
)
images = comp.images
if images.device != first_device:
images = images.to(first_device)
frame_batches.append(images)
frame_counts.append(int(images.shape[0]))
frames = torch.cat(frame_batches, dim=0)
if str(fps_mode) == "override_fps":
fps = float(override_fps)
else:
fps = float(first.frame_rate)
frame_rate = Fraction(round(max(1.0, fps) * 1000), 1000)
audio = None
if str(audio_policy) == "use_master_audio":
audio = master_audio
elif str(audio_policy) == "first_video_audio":
audio = first.audio
elif str(audio_policy) == "concat_clip_audio":
audio = _concat_audio([
(comp.audio, int(comp.images.shape[0]), float(comp.frame_rate))
for comp in components
])
video = InputImpl.VideoFromComponents(Types.VideoComponents(images=frames, audio=audio, frame_rate=frame_rate))
concat_plan = _safe_json_loads(concat_plan_json, {})
report = _json_dump({
"node": "IAMCCS_VideoHardConcat",
"policy": "hard_cut_tensor_concat",
"take_count": len(videos),
"frames_per_take": frame_counts,
"total_frames": int(frames.shape[0]),
"fps": float(frame_rate),
"duration_seconds": int(frames.shape[0]) / max(1.0, float(frame_rate)),
"audio_policy": str(audio_policy),
"has_audio": audio is not None,
"concat_plan_takes": len(concat_plan.get("takes", [])) if isinstance(concat_plan, dict) else 0,
})
return video, frames, report
NODE_CLASS_MAPPINGS = {
"IAMCCS_MultiTimelineBridge": IAMCCS_MultiTimelineBridge,
"IAMCCS_MultiTimelineTakePicker": IAMCCS_MultiTimelineTakePicker,
"IAMCCS_VideoHardConcat": IAMCCS_VideoHardConcat,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_MultiTimelineBridge": "IAMCCS MultiTimeline Bridge",
"IAMCCS_MultiTimelineTakePicker": "IAMCCS MultiTimeline Take Picker",
"IAMCCS_VideoHardConcat": "IAMCCS Video Hard Concat",
}
+19
View File
@@ -0,0 +1,19 @@
# IAMCCS Shotboard V3 WAN Edition BETA
This folder is intentionally isolated from the stable LTX Shotboard V3 code.
Commit safety:
- The module is optional.
- The root `__init__.py` loader is guarded with `try/except`.
- If this folder is excluded from a commit, IAMCCS-nodes still starts.
- If the folder is included without workflow usage, existing LTX workflows are unchanged.
Nodes:
- `IAMCCS_CineShotboardPlannerV3WANEdition_BETA`
- `IAMCCS_WanPromptRelayBridge_BETA`
Purpose:
- Compile Shotboard-style WAN FLF/SVI metadata.
- Export true PromptRelay inputs for WAN.
- Patch the connected WAN model only inside `IAMCCS_WanPromptRelayBridge_BETA`.
+767
View File
@@ -0,0 +1,767 @@
from __future__ import annotations
import hashlib
import json
import re
from typing import Any, Dict, List, Optional, Tuple
import torch
from ..iamccs_cine_nodes import (
MAX_CINE_ITEMS,
SUPERNODE_LINX_TYPE,
IAMCCS_CineReferenceBoard,
IAMCCS_CineShotboardPlannerV3,
_cine_debug,
_clamp,
_iamccs_cine_resize_method,
_json_report,
_load_original_promptrelay_module,
_safe_bool,
_safe_float,
_safe_int,
)
WAN_BETA_MODE = "iamccs_wan_shotboard_v3_beta"
WAN_BETA_SCHEMA = "iamccs.wan.shotboard_v3.beta"
def _resources(cine_linx: Any) -> Dict[str, Any]:
if not isinstance(cine_linx, dict):
return {}
resources = cine_linx.get("resources")
return resources if isinstance(resources, dict) else {}
def _outputs(cine_linx: Any) -> Dict[str, Any]:
if not isinstance(cine_linx, dict):
return {}
outputs = cine_linx.get("outputs")
return outputs if isinstance(outputs, dict) else {}
def _payload(cine_linx: Any) -> Dict[str, Any]:
resources = _resources(cine_linx)
payload = resources.get("cine_payload")
if isinstance(payload, dict):
return payload
if isinstance(cine_linx, dict):
for stage in cine_linx.get("stages") or []:
if isinstance(stage, dict) and isinstance(stage.get("payload"), dict):
return stage["payload"]
return {}
def _split_prompt_parts(text: Any) -> List[str]:
return [part.strip() for part in str(text or "").split("|") if part.strip()]
def _split_length_parts(text: Any) -> List[str]:
return [part for part in re.split(r"[,;\s]+", str(text or "")) if part.strip()]
def _text_hash(text: Any) -> str:
return hashlib.sha1(str(text or "").encode("utf-8", errors="ignore")).hexdigest()[:12]
def _reference_paths(image_paths: str) -> List[str]:
paths: List[str] = []
for raw in str(image_paths or "").replace(";", "\n").splitlines():
item = raw.strip().strip('"')
if item:
paths.append(item)
return paths
class IAMCCS_CineShotboardPlannerV3WANEdition_BETA(IAMCCS_CineShotboardPlannerV3):
"""BETA WAN edition of Shotboard V3.
It does not execute a WAN model directly. It compiles a WAN-native cine_linx
plan with FLF/SVI metadata plus true PromptRelay inputs for the dedicated
IAMCCS_WanPromptRelayBridge_BETA node.
"""
CATEGORY = "IAMCCS/Wan/BETA"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"global_prompt": ("STRING", {
"default": "cinematic WAN image-to-video shot, stable identity, coherent physical motion, detailed scene continuity",
"multiline": True,
}),
"timeline_data": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "BETA. Use Shotboard V3-style JSON: segments with start/length/ref/prompt/use_prompt/use_guide.",
}),
"frame_rate": ("INT", {"default": 16, "min": 1, "max": 120, "step": 1}),
"wan_chunk_frames": ("INT", {"default": 81, "min": 1, "max": 120, "step": 1}),
"duration_seconds": ("FLOAT", {"default": 5.0625, "min": 0.01, "max": 36000.0, "step": 0.01}),
"frame_budget_mode": (["wan_chunk_frames", "duration_seconds"], {"default": "wan_chunk_frames"}),
"promptrelay_epsilon": ("FLOAT", {"default": 0.001, "min": 0.000001, "max": 0.99, "step": 0.0001}),
"guide_policy": (["every_checked_row", "safe_core_guides", "prompt_only"], {"default": "every_checked_row"}),
"min_guide_gap_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 60.0, "step": 0.05}),
"max_guides": ("INT", {"default": 8, "min": 0, "max": 50, "step": 1}),
"default_force": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01}),
"wan_mode": (["flf_svi_promptrelay", "flf_only", "promptrelay_only", "svi_continuation"], {"default": "flf_svi_promptrelay"}),
"flf_role_policy": (["start_end", "every_image_anchor", "manual_metadata"], {"default": "start_end"}),
"image_paths": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "Optional image paths, one per line. Used as WAN visual anchors and reference metadata.",
}),
"image_width": ("INT", {"default": 1280, "min": 64, "max": 8192, "step": 32}),
"image_height": ("INT", {"default": 736, "min": 64, "max": 8192, "step": 32}),
"image_resize_method": (["crop", "pad", "keep proportion", "stretch", ""], {"default": "crop"}),
"image_multiple_of": ("INT", {"default": 32, "min": 1, "max": 512, "step": 1}),
"img_compression": ("INT", {"default": 0, "min": 0, "max": 100, "step": 1}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
"multi_input": ("IMAGE",),
},
}
RETURN_TYPES = (
SUPERNODE_LINX_TYPE,
"STRING",
"STRING",
"STRING",
"STRING",
"INT",
"FLOAT",
"STRING",
"IMAGE",
"IMAGE",
)
RETURN_NAMES = (
"cine_linx",
"wan_flf_plan_json",
"global_prompt",
"local_prompts",
"segment_lengths",
"max_frames",
"promptrelay_epsilon",
"report",
"multi_output",
"image_1",
)
FUNCTION = "execute"
@classmethod
def IS_CHANGED(
cls,
global_prompt=None,
timeline_data=None,
frame_rate=None,
wan_chunk_frames=None,
duration_seconds=None,
frame_budget_mode=None,
promptrelay_epsilon=None,
guide_policy=None,
min_guide_gap_seconds=None,
max_guides=None,
default_force=None,
wan_mode=None,
flf_role_policy=None,
image_paths=None,
image_width=None,
image_height=None,
image_resize_method=None,
image_multiple_of=None,
img_compression=None,
cine_linx=None,
multi_input=None,
**kwargs,
):
return _text_hash(json.dumps({
"node": cls.__name__,
"global_prompt": str(global_prompt or ""),
"timeline_data": str(timeline_data or ""),
"frame_rate": int(frame_rate or 0),
"wan_chunk_frames": int(wan_chunk_frames or 0),
"duration_seconds": float(duration_seconds or 0.0),
"frame_budget_mode": str(frame_budget_mode or ""),
"promptrelay_epsilon": float(promptrelay_epsilon or 0.0),
"guide_policy": str(guide_policy or ""),
"min_guide_gap_seconds": float(min_guide_gap_seconds or 0.0),
"max_guides": int(max_guides or 0),
"default_force": float(default_force or 0.0),
"wan_mode": str(wan_mode or ""),
"flf_role_policy": str(flf_role_policy or ""),
"image_paths": str(image_paths or ""),
"image_width": int(image_width or 0),
"image_height": int(image_height or 0),
"image_resize_method": str(image_resize_method or ""),
"image_multiple_of": int(image_multiple_of or 0),
"img_compression": int(img_compression or 0),
}, sort_keys=True, ensure_ascii=False))
@classmethod
def _effective_frame_budget(cls, duration_seconds: float, fps: int, wan_chunk_frames: int, mode: str) -> Tuple[int, float]:
fps = max(1, int(fps))
if str(mode or "wan_chunk_frames") == "duration_seconds":
frames = max(1, int(round(float(duration_seconds) * fps)))
frames = min(120, frames)
else:
frames = max(1, min(120, int(wan_chunk_frames or 81)))
return int(frames), float(frames) / float(fps)
@classmethod
def _rows_from_v3_timeline(cls, timeline_data: str, duration_seconds: float, fps: int, default_force: float) -> List[Dict[str, Any]]:
rows = cls._parse_rows(str(timeline_data or ""), float(duration_seconds), float(default_force))
for idx, row in enumerate(rows):
if row.get("frame") is None:
row["frame"] = max(0, int(round(float(row.get("second", 0.0)) * fps)))
row["frame"] = max(0, int(row.get("frame") or 0))
row["ref"] = max(1, min(MAX_CINE_ITEMS, _safe_int(row.get("ref", idx + 1), idx + 1)))
row["force"] = _clamp(row.get("force", default_force), 0.0, 1.0, default_force)
row["guide_strength"] = _clamp(row.get("guide_strength", row.get("force", default_force)), 0.0, 1.0, default_force)
return rows
@classmethod
def _compile_wan_relay(cls, rows: List[Dict[str, Any]], frame_budget: int) -> Tuple[str, str, List[int]]:
frame_budget = max(1, int(frame_budget))
candidates: List[Tuple[int, str, Dict[str, Any]]] = []
for row in rows:
if not bool(row.get("use_prompt", False)):
continue
prompt = cls._row_prompt(row)
if not str(prompt or "").strip():
continue
start = max(0, min(frame_budget - 1, int(row.get("frame") or 0)))
candidates.append((start, str(prompt).strip(), row))
candidates.sort(key=lambda item: item[0])
if not candidates:
return "", "", []
prompts: List[str] = []
lengths: List[int] = []
for idx, (start, prompt, _row) in enumerate(candidates):
next_start = candidates[idx + 1][0] if idx + 1 < len(candidates) else frame_budget
length = max(1, int(next_start) - int(start))
prompts.append(prompt)
lengths.append(length)
diff = frame_budget - sum(lengths)
if lengths:
lengths[-1] = max(1, lengths[-1] + diff)
return " | ".join(prompts), ",".join(str(int(v)) for v in lengths), lengths
@classmethod
def _visual_segments(
cls,
rows: List[Dict[str, Any]],
frame_budget: int,
fps: int,
reference_paths: List[str],
default_force: float,
flf_role_policy: str,
) -> List[Dict[str, Any]]:
frame_budget = max(1, int(frame_budget))
visual_rows = [row for row in rows if int(row.get("frame") or 0) < frame_budget]
visual_rows.sort(key=lambda row: int(row.get("frame") or 0))
segments: List[Dict[str, Any]] = []
for idx, row in enumerate(visual_rows):
start = max(0, min(frame_budget - 1, int(row.get("frame") or 0)))
next_start = int(visual_rows[idx + 1].get("frame") or frame_budget) if idx + 1 < len(visual_rows) else frame_budget
length = max(1, min(frame_budget, next_start) - start)
ref = max(1, _safe_int(row.get("ref", idx + 1), idx + 1))
if str(flf_role_policy or "") == "every_image_anchor":
role = "flf_anchor"
elif str(flf_role_policy or "") == "manual_metadata":
role = str(row.get("wan_role", row.get("role", "visual_anchor")) or "visual_anchor")
else:
role = "start_frame" if idx == 0 else "end_frame" if idx == len(visual_rows) - 1 else "bridge_frame"
segments.append({
"id": f"wan_beta_seg_{idx + 1:02d}",
"schema": WAN_BETA_SCHEMA,
"type": "image",
"start": int(start),
"length": int(length),
"second": float(start) / float(max(1, fps)),
"ref": int(ref),
"imageFile": reference_paths[ref - 1] if ref <= len(reference_paths) else "",
"label": str(row.get("label", f"wan_ref_{idx + 1}") or f"wan_ref_{idx + 1}"),
"prompt": str(row.get("relay_prompt", "")),
"camera": str(row.get("camera", "")),
"transition": str(row.get("transition", "continuous_motion")),
"wan_role": role,
"use_flf": bool(row.get("use_guide", True)),
"use_svi": True,
"use_promptrelay": bool(row.get("use_prompt", False)),
"guideStrength": float(_safe_float(row.get("guide_strength", row.get("force", default_force)), default_force)),
"motion_force": float(_safe_float(row.get("motion_force", row.get("force", default_force)), default_force)),
})
return segments
@staticmethod
def _multi_from_linx(cine_linx: Any) -> Any:
resources = _resources(cine_linx)
for key in ("cine_multi_input", "multi_input"):
value = resources.get(key)
if torch.is_tensor(value):
return value
return None
def execute(
self,
global_prompt,
timeline_data,
frame_rate,
wan_chunk_frames,
duration_seconds,
frame_budget_mode,
promptrelay_epsilon,
guide_policy,
min_guide_gap_seconds,
max_guides,
default_force,
wan_mode,
flf_role_policy,
image_paths,
image_width,
image_height,
image_resize_method,
image_multiple_of,
img_compression,
cine_linx=None,
multi_input=None,
):
upstream_resources = _resources(cine_linx)
if upstream_resources:
upstream_timeline = upstream_resources.get("cine_board_timeline_data")
if isinstance(upstream_timeline, str) and upstream_timeline.strip():
timeline_data = upstream_timeline
upstream_prompt = upstream_resources.get("cine_global_prompt")
if isinstance(upstream_prompt, str) and upstream_prompt.strip() and not str(global_prompt or "").strip():
global_prompt = upstream_prompt
multi_input = multi_input if torch.is_tensor(multi_input) else self._multi_from_linx(cine_linx)
fps = max(1, int(frame_rate or 16))
frame_budget, effective_duration = self._effective_frame_budget(
float(duration_seconds),
fps,
int(wan_chunk_frames or 81),
str(frame_budget_mode or "wan_chunk_frames"),
)
image_resize_method = _iamccs_cine_resize_method(image_resize_method)
if str(image_paths or "").strip():
try:
multi_input = IAMCCS_CineReferenceBoard().load_ltx_style_images(
image_paths,
int(image_width),
int(image_height),
image_resize_method,
int(image_multiple_of or 32),
int(img_compression or 0),
)
except Exception as exc:
print(f"[IAMCCS WAN BETA] could not load image_paths: {exc}")
if torch.is_tensor(multi_input):
multi_output = multi_input
else:
multi_output = torch.zeros((1, 64, 64, 3))
image_1 = multi_output[0:1] if torch.is_tensor(multi_output) and multi_output.shape[0] > 0 else torch.zeros((1, 64, 64, 3))
rows = self._rows_from_v3_timeline(str(timeline_data or ""), effective_duration, fps, float(default_force))
local_prompts, segment_lengths, lengths = self._compile_wan_relay(rows, frame_budget)
promptrelay_enabled = bool(local_prompts.strip()) and str(wan_mode) in {"flf_svi_promptrelay", "promptrelay_only"}
if not promptrelay_enabled:
local_prompts = ""
segment_lengths = ""
lengths = []
reference_paths = _reference_paths(str(image_paths or ""))
visual_segments = self._visual_segments(rows, frame_budget, fps, reference_paths, float(default_force), str(flf_role_policy))
guide_rows = self._select_guides(rows, str(guide_policy), float(min_guide_gap_seconds), int(max_guides))
flf_timeline = self._flf_from_rows(guide_rows, effective_duration, fps, int(multi_output.shape[0]) if torch.is_tensor(multi_output) else 0)
wan_flf_plan = {
"schema": WAN_BETA_SCHEMA,
"version": 1,
"beta": True,
"mode": str(wan_mode),
"frame_budget": int(frame_budget),
"fps": int(fps),
"duration_seconds": float(effective_duration),
"flf_role_policy": str(flf_role_policy),
"visual_segments": visual_segments,
"flf_timeline": flf_timeline,
"promptrelay": {
"enabled": bool(promptrelay_enabled),
"backend": "IAMCCS_WanPromptRelayBridge_BETA",
"global_prompt": str(global_prompt or ""),
"local_prompts": local_prompts,
"segment_lengths": segment_lengths,
"pixel_lengths": lengths,
"epsilon": float(promptrelay_epsilon),
},
"svi": {
"enabled": str(wan_mode) in {"flf_svi_promptrelay", "flf_only", "svi_continuation"},
"recommended_motion_node": "WanImageMotionProPlus",
"recommended_bridge_node": "IAMCCS_WanSVIToFLFBridgeProPlus",
},
}
wan_flf_plan_json = json.dumps(wan_flf_plan, ensure_ascii=False)
visual_segments_json = json.dumps(visual_segments, ensure_ascii=False)
payload = {
"schema": WAN_BETA_SCHEMA,
"beta": True,
"backend_mode": WAN_BETA_MODE,
"pipeline_kind": "wan_i2v_flf_svi_promptrelay_beta",
"global_prompt": str(global_prompt or ""),
"timeline_data": str(timeline_data or ""),
"duration_seconds": float(effective_duration),
"frame_rate": int(fps),
"wan_chunk_frames": int(frame_budget),
"max_frames": int(frame_budget),
"wan_mode": str(wan_mode),
"flf_role_policy": str(flf_role_policy),
"promptrelay_enabled": bool(promptrelay_enabled),
"promptrelay_backend": "IAMCCS_WanPromptRelayBridge_BETA",
"promptrelay_epsilon": float(promptrelay_epsilon),
"local_prompts": local_prompts,
"segment_lengths": segment_lengths,
"promptrelay_pixel_lengths": lengths,
"rows": rows,
"guide_rows": guide_rows,
"visual_segments": visual_segments,
"wan_flf_plan": wan_flf_plan,
}
resources = {
"cine_payload": payload,
"cine_global_prompt": str(global_prompt or ""),
"cine_local_prompts": local_prompts,
"cine_segment_lengths": segment_lengths,
"cine_promptrelay_enabled": bool(promptrelay_enabled),
"cine_promptrelay_epsilon": float(promptrelay_epsilon),
"cine_max_frames": int(frame_budget),
"cine_duration_seconds": float(effective_duration),
"cine_frame_rate": int(fps),
"cine_image_paths": str(image_paths or ""),
"cine_image_width": int(image_width),
"cine_image_height": int(image_height),
"cine_multi_input": multi_output,
"cine_image_1": image_1,
"cine_flf_timeline": flf_timeline,
"cine_visual_segments_json": visual_segments_json,
"wan_beta_enabled": True,
"wan_beta_schema": WAN_BETA_SCHEMA,
"wan_global_prompt": str(global_prompt or ""),
"wan_local_prompts": local_prompts,
"wan_segment_lengths": segment_lengths,
"wan_promptrelay_enabled": bool(promptrelay_enabled),
"wan_promptrelay_epsilon": float(promptrelay_epsilon),
"wan_max_frames": int(frame_budget),
"wan_chunk_frames": int(frame_budget),
"wan_frame_rate": int(fps),
"wan_duration_seconds": float(effective_duration),
"wan_flf_plan_json": wan_flf_plan_json,
"wan_visual_segments_json": visual_segments_json,
}
report = _json_report({
"node": "IAMCCS_CineShotboardPlannerV3WANEdition_BETA",
"beta": True,
"mode": WAN_BETA_MODE,
"frame_budget": int(frame_budget),
"fps": int(fps),
"duration_seconds": float(effective_duration),
"promptrelay_enabled": bool(promptrelay_enabled),
"local_prompt_count": len(_split_prompt_parts(local_prompts)),
"segment_lengths": segment_lengths,
"visual_segments": len(visual_segments),
"guide_rows": len(guide_rows),
"truth": "BETA WAN edition. It prepares WAN FLF/SVI metadata and true PromptRelay inputs, but the model is patched only by IAMCCS_WanPromptRelayBridge_BETA.",
})
resources["cine_report"] = report
resources["wan_report"] = report
cine_out = {
"type": SUPERNODE_LINX_TYPE,
"pipeline_kind": "wan_i2v_flf_svi_promptrelay_beta",
"mode": WAN_BETA_MODE,
"beta": True,
"chain": [{"role": "planner", "name": "IAMCCS Shotboard V3 WAN Edition BETA"}],
"stages": [{"name": "WAN_BETA", "kind": "wan_shotboard_v3_beta", "payload": payload}],
"policies": {
"promptrelay_backend": "IAMCCS_WanPromptRelayBridge_BETA",
"promptrelay_model_family": "wan",
"commit_safety": "optional_module; safe to exclude folder from commit when __init__ optional loader remains",
},
"outputs": {
"global_prompt": str(global_prompt or ""),
"local_prompts": local_prompts,
"segment_lengths": segment_lengths,
"max_frames": int(frame_budget),
"promptrelay_epsilon": float(promptrelay_epsilon),
"duration_seconds": float(effective_duration),
"frame_rate": int(fps),
"promptrelay_enabled": bool(promptrelay_enabled),
"wan_flf_plan_json": wan_flf_plan_json,
"report": report,
},
"resources": resources,
"resource_keys": sorted(resources.keys()),
"resource_types": {key: type(value).__name__ for key, value in resources.items()},
}
print(
"[IAMCCS WAN BETA] "
f"planner frame_budget={frame_budget} fps={fps} relay={bool(promptrelay_enabled)} "
f"locals={len(_split_prompt_parts(local_prompts))} visual_segments={len(visual_segments)}"
)
_cine_debug(
"[IAMCCS WAN BETA] "
f"global_hash={_text_hash(global_prompt)} local_hash={_text_hash(local_prompts)} "
f"segment_lengths={segment_lengths or '<empty>'}"
)
return (
cine_out,
wan_flf_plan_json,
str(global_prompt or ""),
local_prompts,
segment_lengths,
int(frame_budget),
float(promptrelay_epsilon),
report,
multi_output,
image_1,
)
class IAMCCS_WanPromptRelayBridge_BETA:
"""BETA true PromptRelay bridge for WAN models.
The bridge is intentionally separate from the LTX Shotboard backend. It reads
WAN BETA cine_linx metadata and calls ComfyUI-PromptRelay's original
_encode_relay against the connected WAN model, clip and latent.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
"model": ("MODEL",),
"positive": ("CONDITIONING",),
},
"optional": {
"global_prompt": ("STRING", {"forceInput": True}),
"local_prompts": ("STRING", {"forceInput": True}),
"segment_lengths": ("STRING", {"forceInput": True}),
"epsilon": ("FLOAT", {"default": 0.001, "min": 0.000001, "max": 0.99, "step": 0.0001, "forceInput": True}),
"clip": ("CLIP", {"lazy": True}),
"latent": ("LATENT", {"lazy": True}),
"relay_options": ("RELAY_OPTIONS",),
},
}
RETURN_TYPES = ("MODEL", "CONDITIONING", "BOOLEAN", "STRING", "STRING", "INT", "STRING")
RETURN_NAMES = ("model", "positive", "promptrelay_enabled", "local_prompts", "segment_lengths", "max_frames", "report")
FUNCTION = "execute"
CATEGORY = "IAMCCS/Wan/BETA"
@classmethod
def _relay_values(
cls,
cine_linx: Any,
global_prompt: Optional[str] = None,
local_prompts: Optional[str] = None,
segment_lengths: Optional[str] = None,
epsilon: Optional[float] = None,
) -> Tuple[bool, str, str, str, float, int, str]:
resources = _resources(cine_linx)
outputs = _outputs(cine_linx)
payload = _payload(cine_linx)
resolved_global = str(
global_prompt
if global_prompt is not None else
resources.get("wan_global_prompt",
resources.get("cine_global_prompt",
outputs.get("global_prompt",
payload.get("global_prompt", ""))))
or ""
)
resolved_local = str(
local_prompts
if local_prompts is not None else
resources.get("wan_local_prompts",
resources.get("cine_local_prompts",
outputs.get("local_prompts",
payload.get("local_prompts", ""))))
or ""
)
resolved_lengths = str(
segment_lengths
if segment_lengths is not None else
resources.get("wan_segment_lengths",
resources.get("cine_segment_lengths",
outputs.get("segment_lengths",
payload.get("segment_lengths", ""))))
or ""
)
resolved_epsilon = _safe_float(
epsilon
if epsilon is not None else
resources.get("wan_promptrelay_epsilon",
resources.get("cine_promptrelay_epsilon",
outputs.get("promptrelay_epsilon",
payload.get("promptrelay_epsilon", 0.001)))),
0.001,
)
max_frames = _safe_int(
resources.get("wan_max_frames",
resources.get("cine_max_frames",
outputs.get("max_frames",
payload.get("max_frames", 0)))),
0,
)
active = bool(_split_prompt_parts(resolved_local))
source = "explicit_inputs" if local_prompts is not None else "wan_cine_linx"
return active, resolved_local, resolved_lengths, resolved_global, float(resolved_epsilon), int(max_frames), source
def check_lazy_status(
self,
cine_linx,
model,
positive,
global_prompt=None,
local_prompts=None,
segment_lengths=None,
epsilon=None,
clip=None,
latent=None,
relay_options=None,
):
active, *_ = self._relay_values(cine_linx, global_prompt, local_prompts, segment_lengths, epsilon)
needed = []
if active:
if clip is None:
needed.append("clip")
if latent is None:
needed.append("latent")
return needed
def execute(
self,
cine_linx,
model,
positive,
global_prompt=None,
local_prompts=None,
segment_lengths=None,
epsilon=None,
clip=None,
latent=None,
relay_options=None,
):
active, local_prompts, segment_lengths, global_prompt, epsilon, max_frames, source = self._relay_values(
cine_linx,
global_prompt,
local_prompts,
segment_lengths,
epsilon,
)
local_parts = _split_prompt_parts(local_prompts)
length_parts = _split_length_parts(segment_lengths)
relay_prompt_log = [
{
"index": idx,
"segment_length": length_parts[idx] if idx < len(length_parts) else "<auto>",
"prompt": prompt,
}
for idx, prompt in enumerate(local_parts)
]
if not active:
report = _json_report({
"node": "IAMCCS_WanPromptRelayBridge_BETA",
"beta": True,
"promptrelay_enabled": False,
"mode": "BYPASS_PASS_THROUGH",
"source": source,
"truth": "No WAN local prompts were found. The WAN model and positive conditioning are returned unchanged.",
})
return model, positive, False, "", "", int(max_frames), report
if clip is None or latent is None:
report = _json_report({
"node": "IAMCCS_WanPromptRelayBridge_BETA",
"beta": True,
"promptrelay_enabled": False,
"mode": "BYPASS_MISSING_INPUTS",
"warning": "Relay is active but clip or latent is missing.",
"local_prompt_count": len(local_parts),
"truth": "Connect WAN clip and WAN latent to enable true PromptRelay patching.",
})
print("[IAMCCS WAN BETA] PromptRelay bypass: missing clip or latent")
return model, positive, False, local_prompts, segment_lengths, int(max_frames), report
try:
promptrelay_nodes = _load_original_promptrelay_module()
patched_model, conditioning = promptrelay_nodes._encode_relay(
model,
clip,
latent,
global_prompt,
local_prompts,
segment_lengths,
float(epsilon),
relay_options,
)
except Exception as exc:
report = _json_report({
"node": "IAMCCS_WanPromptRelayBridge_BETA",
"beta": True,
"promptrelay_enabled": False,
"mode": "BYPASS_RELAY_ERROR",
"error": str(exc),
"local_prompt_count": len(local_parts),
"global_hash": _text_hash(global_prompt),
"local_hash": _text_hash(local_prompts),
"truth": "The original PromptRelay _encode_relay raised an error, so the bridge returned the unpatched WAN model.",
})
print(f"[IAMCCS WAN BETA] PromptRelay error, bypassing: {exc}")
return model, positive, False, local_prompts, segment_lengths, int(max_frames), report
report = _json_report({
"node": "IAMCCS_WanPromptRelayBridge_BETA",
"beta": True,
"promptrelay_enabled": True,
"mode": "WAN_PROMPT_RELAY_ORIGINAL_1TO1",
"source": source,
"local_prompt_count": len(local_parts),
"segment_count": len(length_parts),
"max_frames": int(max_frames),
"epsilon": float(epsilon),
"global_hash": _text_hash(global_prompt),
"local_hash": _text_hash(local_prompts),
"local_prompts_used": relay_prompt_log,
"truth": "True WAN PromptRelay path. Called ComfyUI-PromptRelay _encode_relay against the connected model, clip and latent.",
})
print(
"[IAMCCS WAN BETA] "
f"PromptRelay APPLIED locals={len(local_parts)} segments={len(length_parts)} "
f"max_frames={int(max_frames)} global_hash={_text_hash(global_prompt)} local_hash={_text_hash(local_prompts)}"
)
for item in relay_prompt_log[:30]:
compact = str(item["prompt"]).replace("\n", " ")
if len(compact) > 300:
compact = compact[:297] + "..."
print(f"[IAMCCS WAN BETA] relay[{int(item['index']):02d}] length={item['segment_length']} prompt={compact!r}")
return patched_model, conditioning, True, local_prompts, segment_lengths, int(max_frames), report
NODE_CLASS_MAPPINGS = {
"IAMCCS_CineShotboardPlannerV3WANEdition_BETA": IAMCCS_CineShotboardPlannerV3WANEdition_BETA,
"IAMCCS_WanPromptRelayBridge_BETA": IAMCCS_WanPromptRelayBridge_BETA,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_CineShotboardPlannerV3WANEdition_BETA": "IAMCCS Cine Shotboard Planner V3 WAN Edition BETA",
"IAMCCS_WanPromptRelayBridge_BETA": "IAMCCS WAN PromptRelay Bridge BETA",
}
File diff suppressed because it is too large Load Diff
+182 -11
View File
@@ -546,7 +546,7 @@ class IAMCCS_CineReferenceBoard:
def load_ltx_style_image(self, source, width, height, resize_method="crop", multiple_of=32, img_compression=0):
image = None
if isinstance(source, dict):
image_file = str(source.get("imageFile", source.get("image_file", "")) or "").strip()
image_file = str(source.get("imageTruthPath") or source.get("image_truth_path") or source.get("imageFile") or source.get("image_file") or source.get("path") or "").strip()
image_b64 = str(source.get("imageB64", source.get("image_b64", "")) or "").strip()
if image_file:
full_path = image_file
@@ -588,11 +588,30 @@ class IAMCCS_CineReferenceBoard:
def load_ltx_style_images(self, image_paths, width, height, resize_method="crop", multiple_of=32, img_compression=0):
results = []
missing_paths = []
failed_paths = []
for path in _cine_reference_paths_from_text(image_paths):
try:
results.append(self.load_ltx_style_image(path, width, height, resize_method, multiple_of, img_compression))
except FileNotFoundError:
missing_paths.append(str(path))
except Exception as exc:
print(f"IAMCCS Cine Shotboard warning: could not load internal guide image {path}: {exc}")
failed_paths.append((str(path), str(exc)))
if missing_paths and results:
print(
"IAMCCS Cine Shotboard warning: skipped "
f"{len(missing_paths)} stale/missing internal guide image path(s); "
f"using {len(results)} valid reference image(s)."
)
elif missing_paths:
for path in missing_paths[:12]:
print(f"IAMCCS Cine Shotboard warning: could not load internal guide image {path}: image source not found")
if len(missing_paths) > 12:
print(f"IAMCCS Cine Shotboard warning: {len(missing_paths) - 12} additional missing guide image path(s) suppressed.")
for path, exc in failed_paths[:12]:
print(f"IAMCCS Cine Shotboard warning: could not load internal guide image {path}: {exc}")
if len(failed_paths) > 12:
print(f"IAMCCS Cine Shotboard warning: {len(failed_paths) - 12} additional failed guide image path(s) suppressed.")
if results:
first_shape = results[0].shape
batch_safe = []
@@ -2435,7 +2454,14 @@ class IAMCCS_CineShotboardPlannerV3(IAMCCS_CineShotboardPlannerPro):
continue
start_frame = max(0, _safe_int(seg.get("start", seg.get("frame", 0)), 0))
second = start_frame / fps
if second >= float(duration_seconds):
# Fix 3 (slot-9 consistency): use frame-based boundary identical to _compile_flfreal_timeline
# so segments starting exactly at the last frame (e.g. frame 528 of 529) are NOT excluded.
# Old: second >= duration_seconds (excluded frame 528 when 528/24 == 22.0 == duration_seconds)
# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
_target_frames = max(1, int(round(float(duration_seconds) * fps)))
_ltx_mode = str(data.get("ltx_round_mode", "up_8n_plus_1") if isinstance(data, dict) else "up_8n_plus_1")
_max_frames = int(cls._round_frames(_target_frames, _ltx_mode))
if start_frame >= _max_frames:
continue
is_text = seg_type == "text"
if not is_text:
@@ -2603,6 +2629,88 @@ class IAMCCS_CineShotboardPlannerV3(IAMCCS_CineShotboardPlannerPro):
def execute(self, global_prompt, timeline_data, duration_seconds, frame_rate, guide_policy, min_guide_gap_seconds, max_guides, default_force, promptrelay_epsilon, ltx_round_mode, image_paths, image_width, image_height, image_resize_method="crop", image_multiple_of=32, img_compression=0, cine_linx=None):
upstream_cine_linx = cine_linx
upstream_resources = self._input_linx_resources(upstream_cine_linx)
upstream_mode = str(upstream_cine_linx.get("mode", "") if isinstance(upstream_cine_linx, dict) else "")
widget_duration_seconds = _safe_float(duration_seconds, 20.0)
widget_frame_rate = _safe_int(frame_rate, 24)
def _positive_float_or_none(value):
try:
parsed = float(value)
return parsed if parsed > 0 and parsed == parsed else None
except Exception:
return None
def _positive_int_or_none(value):
try:
parsed = int(round(float(value)))
return parsed if parsed > 0 else None
except Exception:
return None
# V3 truth-source guard: V3's own widget data is ALWAYS the source of truth.
# Upstream CineLinX board data is only a fallback when V3 has no segments of its own
# (e.g. a BoardMaker feeding a blank V3). Once the user has configured V3 (imported a
# board and made changes), upstream board data must NEVER overwrite those changes.
# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
_v3_own_parsed = _safe_json_loads(str(timeline_data or "{}"), {})
_v3_has_own_segments = (
isinstance(_v3_own_parsed, dict)
and isinstance(_v3_own_parsed.get("segments"), list)
and len(_v3_own_parsed.get("segments", [])) > 0
)
if upstream_resources:
upstream_timeline = upstream_resources.get("cine_board_timeline_data")
# Only adopt upstream timeline when V3 has no own segments - so user changes
# (images, prompts, values) made after importing a board are preserved
# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
if isinstance(upstream_timeline, str) and upstream_timeline.strip() and not _v3_has_own_segments:
timeline_data = upstream_timeline
upstream_prompt = upstream_resources.get("cine_global_prompt")
if isinstance(upstream_prompt, str) and upstream_prompt.strip() and not str(global_prompt or "").strip():
global_prompt = upstream_prompt
timeline_meta_for_timing = _safe_json_loads(str(timeline_data or "{}"), {})
timeline_duration_seconds = None
timeline_frame_rate = None
if isinstance(timeline_meta_for_timing, dict):
settings_meta = timeline_meta_for_timing.get("settings") if isinstance(timeline_meta_for_timing.get("settings"), dict) else {}
timeline_duration_seconds = _positive_float_or_none(
timeline_meta_for_timing.get("duration_seconds",
timeline_meta_for_timing.get("durationSeconds",
timeline_meta_for_timing.get("duration",
settings_meta.get("duration_seconds", settings_meta.get("durationSeconds", settings_meta.get("duration"))))))
)
timeline_frame_rate = _positive_int_or_none(
timeline_meta_for_timing.get("frame_rate",
timeline_meta_for_timing.get("frameRate",
timeline_meta_for_timing.get("fps",
settings_meta.get("frame_rate", settings_meta.get("frameRate", settings_meta.get("fps"))))))
)
upstream_duration_seconds = _positive_float_or_none(upstream_resources.get("cine_duration_seconds")) if upstream_resources else None
upstream_frame_rate = _positive_int_or_none(upstream_resources.get("cine_frame_rate")) if upstream_resources else None
duration_truth_source = "widget"
frame_rate_truth_source = "widget"
if timeline_duration_seconds is not None:
duration_seconds = timeline_duration_seconds
duration_truth_source = "timeline_data"
elif upstream_duration_seconds is not None and upstream_mode != "iamccs_audio_board_arranger":
duration_seconds = upstream_duration_seconds
duration_truth_source = "upstream_cine_linx"
if timeline_frame_rate is not None:
frame_rate = timeline_frame_rate
frame_rate_truth_source = "timeline_data"
elif upstream_frame_rate is not None:
frame_rate = upstream_frame_rate
frame_rate_truth_source = "upstream_cine_linx"
if upstream_resources:
# Guard: only adopt upstream image dimensions when V3 has no own segments
# so user-set resolution in V3 is never silently replaced by board/upstream values
# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
if upstream_resources.get("cine_image_width") is not None and not _v3_has_own_segments:
image_width = _safe_int(upstream_resources.get("cine_image_width"), image_width)
if upstream_resources.get("cine_image_height") is not None and not _v3_has_own_segments:
image_height = _safe_int(upstream_resources.get("cine_image_height"), image_height)
multi_input = self._multi_input_from_cine_linx(upstream_cine_linx)
(cine_linx,) = super().execute(
global_prompt,
@@ -2640,8 +2748,10 @@ class IAMCCS_CineShotboardPlannerV3(IAMCCS_CineShotboardPlannerPro):
if seg_type not in {"text", "audio"} and not self._as_bool(item.get("placeholder", False), False):
image_order += 1
ref = max(1, _safe_int(item.get("ref", item.get("reference_index", item.get("image_ref", image_order))), image_order))
if not str(item.get("imageFile", item.get("image_file", "")) or "").strip() and ref <= len(reference_paths):
if not str(item.get("imageTruthPath") or item.get("image_truth_path") or item.get("imageFile") or item.get("image_file") or item.get("path") or "").strip() and ref <= len(reference_paths):
item["imageFile"] = reference_paths[ref - 1]
elif str(item.get("path") or "").strip() and not str(item.get("imageFile") or item.get("image_file") or "").strip():
item["imageFile"] = str(item.get("path") or "").strip()
enriched_segments.append(item)
visual_segments = enriched_segments
flfreal_mode = str(data.get("flfrealMode", data.get("flfreal_mode", "iamccs_enhanced")) if isinstance(data, dict) else "iamccs_enhanced").strip()
@@ -2654,6 +2764,15 @@ class IAMCCS_CineShotboardPlannerV3(IAMCCS_CineShotboardPlannerPro):
global_prompt_only = bool(_safe_bool(data.get("global_prompt_only", data.get("use_global_prompt_only", False)), False)) if isinstance(data, dict) else False
verbose_log = bool(_safe_bool(data.get("verbose_log", data.get("verboseLog", True)), True)) if isinstance(data, dict) else True
fps = max(1, _safe_int(frame_rate, 24))
if verbose_log:
target_frames = int(round(float(_safe_float(duration_seconds, widget_duration_seconds)) * fps))
rounded_frames = _round_ltx_frames(target_frames, str(ltx_round_mode or "up_8n_plus_1"))
print(
"[IAMCCS ShotboardPlannerV3] "
f"DURATION_TRUTH source={duration_truth_source} duration_passed={float(_safe_float(duration_seconds, widget_duration_seconds)):.3f}s "
f"fps_source={frame_rate_truth_source} fps_passed={int(fps)} target_frames={int(target_frames)} rounded_max_frames={int(rounded_frames)} "
f"widget_duration={float(widget_duration_seconds):.3f}s timeline_duration={timeline_duration_seconds} upstream_duration={upstream_duration_seconds}"
)
promptrelay_requested = False
if isinstance(data, dict):
promptrelay_requested = self._as_bool(data.get("promptrelay_enabled", data.get("enable_promptrelay", False)), False)
@@ -3889,6 +4008,29 @@ class IAMCCS_CineFilmmakerBackend:
audio_segments = data if isinstance(data, list) else data.get("audioSegments", []) if isinstance(data, dict) else []
return any(isinstance(seg, dict) and (seg.get("audioFile") or seg.get("audioB64")) for seg in audio_segments)
@staticmethod
def _timeline_end_frames(timeline_json: Any, key: str = "") -> int:
data = _safe_json_loads(str(timeline_json or "[]"), [])
if isinstance(data, dict):
if key and isinstance(data.get(key), list):
data = data.get(key, [])
elif isinstance(data.get("audioSegments"), list):
data = data.get("audioSegments", [])
elif isinstance(data.get("segments"), list):
data = data.get("segments", [])
else:
data = []
if not isinstance(data, list):
return 0
end_frame = 0
for seg in data:
if not isinstance(seg, dict):
continue
start = _safe_int(seg.get("start", seg.get("frame", 0)), 0)
length = _safe_int(seg.get("length", seg.get("len", 1)), 1)
end_frame = max(end_frame, start + max(1, length))
return int(max(0, end_frame))
@classmethod
def _build_combined_audio(cls, audio_timeline_json: str, pixel_frames: int, frame_rate: float) -> Dict[str, Any]:
target_sr = 44100
@@ -4086,9 +4228,11 @@ class IAMCCS_CineFilmmakerBackend:
continue
img_tensor = None
source = dict(seg)
if not str(source.get("imageFile", source.get("image_file", "")) or "").strip() and ref <= len(reference_paths):
if not str(source.get("imageTruthPath") or source.get("image_truth_path") or source.get("imageFile") or source.get("image_file") or source.get("path") or "").strip() and ref <= len(reference_paths):
source["imageFile"] = reference_paths[ref - 1]
if str(source.get("imageFile", source.get("image_file", "")) or "").strip() or str(source.get("imageB64", source.get("image_b64", "")) or "").strip():
elif str(source.get("path") or "").strip() and not str(source.get("imageFile") or source.get("image_file") or "").strip():
source["imageFile"] = str(source.get("path") or "").strip()
if str(source.get("imageTruthPath") or source.get("image_truth_path") or source.get("imageFile") or source.get("image_file") or source.get("path") or "").strip() or str(source.get("imageB64", source.get("image_b64", "")) or "").strip():
try:
img_tensor = loader.load_ltx_style_image(
source,
@@ -4112,7 +4256,7 @@ class IAMCCS_CineFilmmakerBackend:
guide_data["reference_indices"].append(int(ref))
guide_data.setdefault("motion_forces", []).append(float(_safe_float(seg.get("guideStrength", seg.get("motion_force", seg.get("force", 0.0))), 0.0)))
guide_data.setdefault("image_lock_strengths", []).append(float(strength))
guide_data.setdefault("image_sources", []).append(str(source.get("imageFile", "")) or f"multi_output[{ref}]")
guide_data.setdefault("image_sources", []).append(str(source.get("imageTruthPath") or source.get("image_truth_path") or source.get("imageFile") or source.get("image_file") or source.get("path") or "") or f"multi_output[{ref}]")
return guide_data
@staticmethod
@@ -4143,6 +4287,7 @@ class IAMCCS_CineFilmmakerBackend:
flf_timeline = str(resources.get("cine_flf_timeline", outputs.get("flf_timeline", payload.get("flf_timeline", ""))) or "")
audio_timeline_json = str(resources.get("cine_audio_timeline_json", outputs.get("audio_timeline_json", payload.get("audioSegments", "[]"))) or "[]")
image_paths = str(resources.get("cine_image_paths", payload.get("image_paths", "")) or "")
visual_segments_json = resources.get("cine_visual_segments_json", "")
duration_seconds = _safe_float(resources.get("cine_duration_seconds", outputs.get("duration_seconds", payload.get("duration_seconds", 20.0))), 20.0)
frame_rate = _safe_int(resources.get("cine_frame_rate", outputs.get("frame_rate", payload.get("frame_rate", 24))), 24)
width = _safe_int(resources.get("cine_image_width", outputs.get("width", payload.get("image_width", 768))), 768)
@@ -4153,6 +4298,31 @@ class IAMCCS_CineFilmmakerBackend:
max_frames = _safe_int(resources.get("cine_max_frames", outputs.get("max_frames", payload.get("max_frames", 0))), 0)
if max_frames <= 0:
max_frames = _round_ltx_frames(int(round(duration_seconds * max(1, frame_rate))), str(payload.get("ltx_round_mode", "up_8n_plus_1")))
timeline_end_frames = max(
self._timeline_end_frames(audio_timeline_json, "audioSegments"),
self._timeline_end_frames(visual_segments_json, "segments"),
)
duration_target_frames = int(round(float(duration_seconds) * max(1, int(frame_rate))))
duration_clamp_applied = False
if timeline_end_frames > 0:
rounded_timeline_frames = _round_ltx_frames(timeline_end_frames, str(payload.get("ltx_round_mode", "up_8n_plus_1")))
runaway_threshold = max(rounded_timeline_frames + max(16, int(frame_rate) * 2), int(rounded_timeline_frames * 1.35))
if max_frames > runaway_threshold:
print(
"[IAMCCS FilmmakerBackend] "
f"Duration clamp: cine_linx requested max_frames={int(max_frames)} "
f"but timeline ends at {int(timeline_end_frames)} frames; using {int(rounded_timeline_frames)}."
)
max_frames = int(rounded_timeline_frames)
duration_seconds = float(timeline_end_frames) / max(1.0, float(frame_rate))
duration_clamp_applied = True
if verbose_log:
print(
"[IAMCCS FilmmakerBackend] "
f"DURATION_EFFECTIVE source=cine_linx duration={float(duration_seconds):.3f}s fps={int(frame_rate)} "
f"target_frames={int(duration_target_frames)} max_frames={int(max_frames)} "
f"timeline_end_frames={int(timeline_end_frames)} clamp_applied={bool(duration_clamp_applied)}"
)
epsilon = _safe_float(resources.get("cine_promptrelay_epsilon", outputs.get("promptrelay_epsilon", payload.get("promptrelay_epsilon", 0.65))), 0.65)
latent = optional_latent if isinstance(optional_latent, dict) else self._empty_latent(width, height, max_frames)
@@ -4254,7 +4424,6 @@ class IAMCCS_CineFilmmakerBackend:
if not torch.is_tensor(multi_output):
multi_output = torch.zeros((1, max(64, int(height)), max(64, int(width)), 3))
default_force = _safe_float(resources.get("cine_default_force", payload.get("default_force", 0.25)), 0.25)
visual_segments_json = resources.get("cine_visual_segments_json", "")
guide_data = self._guide_data_from_visual_segments(
visual_segments_json,
multi_output,
@@ -4310,9 +4479,10 @@ class IAMCCS_CineFilmmakerBackend:
prompt = str(seg.get("prompt", seg.get("local_prompt", seg.get("relay_prompt", ""))) or "").replace("\n", " ")
if len(prompt) > 260:
prompt = prompt[:257] + "..."
image_file = str(seg.get("imageFile", seg.get("image_file", "")) or "")
image_file = str(seg.get("imageTruthPath") or seg.get("image_truth_path") or seg.get("imageFile") or seg.get("image_file") or seg.get("path") or "")
if len(image_file) > 180:
image_file = "..." + image_file[-177:]
canonical_strength = _wdc_image_guide_strength(seg, _safe_float(seg.get("guideStrength", seg.get("force", default_force)), float(default_force)))
print(
"[IAMCCS FilmmakerBackend] "
f"segment[{idx:02d}] "
@@ -4321,8 +4491,9 @@ class IAMCCS_CineFilmmakerBackend:
f"length={_safe_int(seg.get('length', seg.get('len', 0)), 0)} "
f"ref={_safe_int(seg.get('ref', seg.get('reference_index', seg.get('image_ref', 0))), 0)} "
f"use_guide={bool(_safe_bool(seg.get('use_guide', seg.get('guide', True)), True))} "
f"guideStrength={float(_safe_float(seg.get('guideStrength', seg.get('force', default_force)), float(default_force))):.4f} "
f"imageLockStrength={float(_safe_float(seg.get('imageLockStrength', seg.get('image_lock_strength', seg.get('guideStrength', default_force))), float(default_force))):.4f} "
f"guideStrength={float(canonical_strength):.4f} "
f"imageLockStrength={float(canonical_strength):.4f} "
f"singleStrengthSource='guideStrength' "
f"label={str(seg.get('label', ''))!r} "
f"imageFile={image_file!r} "
f"prompt={prompt!r}"
+114 -5
View File
@@ -206,6 +206,56 @@ def _audio_duration_seconds(audio):
return float(total_samples) / float(sample_rate)
def _empty_preview_image():
return torch.zeros((1, 64, 64, 3), dtype=torch.float32)
def _limit_video_latent_for_preview(video_latent, vae, max_preview_frames):
if not isinstance(video_latent, dict):
return video_latent
samples = video_latent.get("samples")
if not isinstance(samples, torch.Tensor) or samples.ndim != 5:
return video_latent
max_frames = int(max(0, max_preview_frames or 0))
if max_frames <= 0:
return video_latent
temporal_compression = 8
try:
if hasattr(vae, "temporal_compression_decode"):
temporal_compression = max(1, int(vae.temporal_compression_decode()))
except Exception:
temporal_compression = 8
latent_frames = max(1, int(math.ceil(max(0, max_frames - 1) / float(temporal_compression))) + 1)
latent_frames = min(int(samples.shape[2]), int(latent_frames))
preview_latent = dict(video_latent)
preview_latent["samples"] = samples[:, :, :latent_frames, :, :]
return preview_latent
def _decode_taeltx_preview(taeltx_vae, video_latent, enabled=False, max_preview_frames=17):
if not bool(enabled):
return _empty_preview_image(), "taeltx_preview=off", False
if taeltx_vae is None:
return _empty_preview_image(), "taeltx_preview=off(no_taeltx_vae)", False
if video_latent is None:
return _empty_preview_image(), "taeltx_preview=off(no_video_latent)", False
try:
preview_latent = _limit_video_latent_for_preview(video_latent, taeltx_vae, max_preview_frames)
images = comfy_nodes.VAEDecode().decode(taeltx_vae, preview_latent)[0]
if torch.is_tensor(images) and images.ndim == 4 and images.shape[0] > 0:
max_frames = int(max(0, max_preview_frames or 0))
if max_frames and images.shape[0] > max_frames:
images = images[:max_frames]
report = (
f"taeltx_preview=on | frames={int(images.shape[0])} | "
f"shape={tuple(int(v) for v in images.shape)} | max_frames={int(max_preview_frames or 0)}"
)
return images, report, True
return _empty_preview_image(), f"taeltx_preview=failed(unexpected_output={type(images).__name__})", False
except Exception as exc:
return _empty_preview_image(), f"taeltx_preview=failed({type(exc).__name__}: {str(exc)[:180]})", False
def _sanitize_audio_for_ffmpeg(audio):
if audio is None:
return None, "audio_sanitize=off(no_audio)"
@@ -701,7 +751,7 @@ def _linx_models_only(existing_linx):
source_resources = existing_linx.get("resources") or {}
if not isinstance(source_resources, dict):
source_resources = {}
keep_keys = ("model", "clip", "vae", "audio_vae", "second_stage_model")
keep_keys = ("model", "clip", "vae", "audio_vae", "taeltx_vae", "second_stage_model")
resources = {
key: source_resources.get(key)
for key in keep_keys
@@ -2006,8 +2056,8 @@ class IAMCCS_GC_AUIMG2VIDExecutablePlanner:
class IAMCCS_GC_AUIMG2VIDExecutableRender:
CATEGORY = "IAMCCS/GoyAIcanvas/TestBackends"
FUNCTION = "render"
RETURN_TYPES = ("STRING", "STRING", "INT", "FLOAT", SUPERNODE_LINX_TYPE, "STRING")
RETURN_NAMES = ("frames_dir", "start_dir", "segments_rendered", "estimated_duration_seconds", "linx", "report")
RETURN_TYPES = ("STRING", "STRING", "INT", "FLOAT", SUPERNODE_LINX_TYPE, "STRING", "IMAGE")
RETURN_NAMES = ("frames_dir", "start_dir", "segments_rendered", "estimated_duration_seconds", "linx", "report", "taeltx_preview")
@classmethod
def INPUT_TYPES(cls):
@@ -2062,6 +2112,8 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
"media_mode": (_MEDIA_MODES, {"default": "auto_from_generation_mode"}),
"vram_flush": ("BOOLEAN", {"default": False}),
"motion_intensity": ("FLOAT", {"default": 1.0, "min": 0.25, "max": 4.0, "step": 0.05}),
"taeltx_preview": ("BOOLEAN", {"default": False}),
"taeltx_preview_max_frames": ("INT", {"default": 17, "min": 1, "max": 257, "step": 1}),
},
"optional": {
"image": ("IMAGE", {"lazy": True}),
@@ -2075,6 +2127,7 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
"refresh_image": ("IMAGE", {"lazy": True}),
"second_stage_linx": (SUPERNODE_LINX_TYPE,),
"stage2_model": ("MODEL",),
"taeltx_vae": ("VAE", {"lazy": True}),
"show_manual_sigmas": ("BOOLEAN", {"default": False}),
"debug_verbose": ("BOOLEAN", {"default": False}),
},
@@ -2244,6 +2297,9 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
refresh_image=None,
second_stage_linx=None,
stage2_model=None,
taeltx_vae=None,
taeltx_preview=False,
taeltx_preview_max_frames=17,
unique_id=None,
):
debug_verbose = _debug_verbose_enabled(debug_verbose, linx)
@@ -2300,6 +2356,11 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
clip = _require_runtime_value(_input_or_linx(clip, linx, "clip"), "clip")
vae = _require_runtime_value(_input_or_linx(vae, linx, "vae"), "vae")
audio_vae = _require_runtime_value(_input_or_linx(audio_vae, linx, "audio_vae"), "audio_vae")
taeltx_vae = _input_or_linx(taeltx_vae, linx, "taeltx_vae")
taeltx_preview_enabled = bool(taeltx_preview)
taeltx_preview_max_frames = max(1, int(taeltx_preview_max_frames or 17))
taeltx_preview_images = _empty_preview_image()
taeltx_preview_report = "taeltx_preview=off"
uses_input_audio_probe = bool(media_probe["uses_input_audio"])
fps_default = _REFERENCE_AUDIO_IMG2VID_FPS if reference_audio_img2vid else (24.0 if uses_input_audio_probe else float(generated_media_fps or 25.0))
fps_value = float(linx_resource(linx, "fps", fps_default) or fps_default) if uses_input_audio_probe else max(1.0, float(generated_media_fps or fps_default))
@@ -2310,6 +2371,9 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
clip=clip,
vae=vae,
audio_vae=audio_vae,
taeltx_vae_connected=taeltx_vae is not None,
taeltx_preview=taeltx_preview_enabled,
taeltx_preview_max_frames=taeltx_preview_max_frames,
fps=fps_value,
fps_source="planner_linx" if uses_input_audio_probe else "render_widget",
)
@@ -3033,6 +3097,20 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
sampled_video=sampled_video,
sampled_audio_latent=sampled_audio_latent,
)
taeltx_preview_images, taeltx_preview_report, taeltx_preview_ok = _decode_taeltx_preview(
taeltx_vae,
sampled_video,
taeltx_preview_enabled,
taeltx_preview_max_frames,
)
_pipeline_debug_step(
pipeline_debug,
"single_taeltx_preview",
enabled=taeltx_preview_enabled,
ok=taeltx_preview_ok,
report=taeltx_preview_report,
preview_images=taeltx_preview_images,
)
stage2_model_active = _resolve_stage2_model(model, second_stage_model, second_stage_payload)
stage2_data = _parse_payload(second_stage_payload)
@@ -3176,6 +3254,7 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
f"Single duration protection. planner_total={int(planner_total_frames)}f | audio_total_with_tail={int(audio_total_frames_with_tail)}f | chosen_total={int(total_frames)}f\n"
f"Audio preprocess. {audio_preprocess_report}\n"
f"Single route details. sampler={effective_sampler} | cfg={float(effective_cfg_value):.3f} | sigmas={sigmas_report} | sampler_node={sampler_node_name} | cleanup_before_sampling=soft_cleanup | model_sampling={model_sampling_report} | motion_intensity={'ignored_strict_reference' if reference_audio_img2vid else f'{motion_intensity:.2f}'} | vram_flush={'on' if bool(vram_flush) else 'off'} | vae_frame_align={'off_official_audio_img2vid' if disable_vae_frame_align else 'on'} | {route_extra_report}\n"
f"TAELTX preview. {taeltx_preview_report}\n"
f"Executable AU+IMG2VID render completed. single generation backend={'ti2v_incremental_advanced' if ti2v_incremental_backend else ('legacy_single' if legacy_audio_img2vid_backend else ('strict_workflow1_canvas_reference' if reference_audio_img2vid else 'workflow1_best'))} | latent handed to VAE stage"
)
report = _append_debug_report(report, single_debug_report)
@@ -3195,6 +3274,8 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
"generated_media_fps": float(fps_value),
"generation_type": str(generation_type),
"debug_verbose": bool(debug_verbose),
"taeltx_preview": bool(taeltx_preview_enabled),
"taeltx_preview_report": str(taeltx_preview_report),
"target_frame_count": int(total_frames),
"target_frame_count_source": "render_single_planner_audio" if uses_input_audio else "render_single_generated_duration",
"disable_vae_frame_align": disable_vae_frame_align,
@@ -3240,6 +3321,8 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
"disable_vae_frame_align": disable_vae_frame_align,
"pipeline_debug": list(pipeline_debug.get("lines") or []),
"debug_verbose": bool(debug_verbose),
"taeltx_preview": bool(taeltx_preview_enabled),
"taeltx_preview_report": str(taeltx_preview_report),
},
resources={
"audio": rendered_audio if exports_audio else None,
@@ -3247,6 +3330,9 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
"clip": clip,
"vae": vae,
"audio_vae": audio_vae,
"taeltx_vae": taeltx_vae,
"taeltx_first_stage_preview_images": taeltx_preview_images if taeltx_preview_enabled else None,
"taeltx_first_stage_preview_report": str(taeltx_preview_report),
"fps": float(fps_value),
"decode_mode": str(modular_decode),
"output_root": str(output_root),
@@ -3263,7 +3349,7 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
"second_stage_payload": second_stage_payload,
},
)
return ("", "", 1, float(total_duration_seconds), render_linx, report)
return ("", "", 1, float(total_duration_seconds), render_linx, report, taeltx_preview_images)
extension_node_mem = _node_class("IAMCCS_LTX2_ExtensionModule")() if use_in_memory_loop else None
start_inject_images_node = _node_class("IAMCCS_StartImagesToVideoLatent")() if use_in_memory_loop else None
@@ -3718,6 +3804,21 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
sampled_video=sampled_video,
sampled_audio_latent=sampled_audio_latent,
)
if taeltx_preview_enabled:
taeltx_preview_images, taeltx_preview_report, taeltx_preview_ok = _decode_taeltx_preview(
taeltx_vae,
sampled_video,
True,
taeltx_preview_max_frames,
)
_pipeline_debug_step(
pipeline_debug,
f"segment_{segment_index}_taeltx_preview",
enabled=True,
ok=taeltx_preview_ok,
report=taeltx_preview_report,
preview_images=taeltx_preview_images,
)
stage_mode = str(second_stage_mode)
stage2_applied = False
@@ -4061,6 +4162,7 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
+ f"Prompt route. positive=\"{prompt_excerpt}\"\n"
+ f"Audio preprocess. melband_enabled={melband_enabled} | {audio_preprocess_report}\n"
+ f"Render route. backend_requested={requested_backend_mode} | backend_resolved={backend_mode} | media_mode={effective_media_mode} | decode_mode={modular_decode} | generation_mode={generation_mode} | sampler={effective_sampler} | cfg={float(effective_cfg_value):.3f} | sigmas={sigmas_report} | audio_export={'yes' if rendered_audio is not None else 'no'} | audio_export_note={loop_audio_export_note} | motion_intensity={motion_intensity:.2f} | vram_flush={'on' if bool(vram_flush) else 'off'} | vae_frame_align={'off_iamccs_audio_img2vid' if disable_vae_frame_align else 'on'}\n"
+ f"TAELTX preview. {taeltx_preview_report}\n"
+ f"Executable AU+IMG2VID render completed. segments_rendered={rendered_segments}/{segment_count} | "
f"frames_dir={final_report_hint} | start_dir={final_start_dir or '(in-memory start_images)'}\n"
+ "\n".join(segment_reports)
@@ -4091,6 +4193,8 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
"disable_vae_frame_align": disable_vae_frame_align,
"vram_flush": bool(vram_flush),
"debug_verbose": bool(debug_verbose),
"taeltx_preview": bool(taeltx_preview_enabled),
"taeltx_preview_report": str(taeltx_preview_report),
"second_stage_mode": str(second_stage_mode),
"second_stage_scale_mode": str(second_stage_scale_mode),
"second_stage_upscale_model_name": str(second_stage_upscale_model_name),
@@ -4145,6 +4249,8 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
"disable_vae_frame_align": disable_vae_frame_align,
"pipeline_debug": list(pipeline_debug.get("lines") or []),
"debug_verbose": bool(debug_verbose),
"taeltx_preview": bool(taeltx_preview_enabled),
"taeltx_preview_report": str(taeltx_preview_report),
},
resources={
"audio": rendered_audio if exports_audio else None,
@@ -4152,6 +4258,9 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
"clip": clip,
"vae": vae,
"audio_vae": audio_vae,
"taeltx_vae": taeltx_vae,
"taeltx_first_stage_preview_images": taeltx_preview_images if taeltx_preview_enabled else None,
"taeltx_first_stage_preview_report": str(taeltx_preview_report),
"fps": float(fps_value),
"decode_mode": str(modular_decode),
"output_root": str(output_root),
@@ -4169,7 +4278,7 @@ class IAMCCS_GC_AUIMG2VIDExecutableRender:
"start_images": current_start_images if use_in_memory_loop else None,
},
)
return (final_frames_dir, final_start_dir, int(rendered_segments), float(total_duration_seconds), render_linx, report)
return (final_frames_dir, final_start_dir, int(rendered_segments), float(total_duration_seconds), render_linx, report, taeltx_preview_images)
class IAMCCS_GC_AUIMG2VIDExecutableFinalize:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "iamccs-nodes",
"version": "1.4.6",
"version": "1.4.7",
"author": "Carmine Cristallo Scalzi (IAMCCS)",
"description": "IAMCCS nodes for ComfyUI: IAMCCS echosystem for ComfyUI, nodes 4 LoRA, WAN 2.2, WAN 2.1 and LTX-2 pipelines, WANIMAGEMOTION pro added."
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+544
View File
@@ -0,0 +1,544 @@
import { app } from "../../scripts/app.js";
console.info("[IAMCCS DialogueScriptPlanner UI] module loaded", { ts: new Date().toISOString() });
const STYLE_ID = "iamccs-dialogue-script-planner-style";
const EMOTIONS = [
"none", "happy", "sad", "angry", "excited", "calm", "fearful", "surprised",
"disgusted", "confusion", "empathy", "embarrass", "depressed", "coldness",
"admiration", "whisper", "urgent", "wonder", "resolve"
];
const STYLES = [
"none", "whisper", "serious", "child", "older", "girl", "pure", "sister",
"sweet", "exaggerated", "ethereal", "generous", "recite", "act_coy",
"warm", "shy", "comfort", "authority", "chat", "radio", "soulful",
"gentle", "story", "vivid", "program", "news", "advertising", "roar",
"murmur", "shout", "deeply", "loudly", "friendly"
];
const PARA = [
"none", "Breathing", "Laughter", "Surprise-oh", "Confirmation-en",
"Uhm", "Surprise-ah", "Surprise-wa", "Sigh", "Question-ei", "Dissatisfaction-hnn"
];
const DEFAULT_DATA = {
schema: "iamccs.dialogue_script_planner",
schema_version: 1,
settings: {
engine_profile: "stepaudio_editx",
timeline_mode: "speaker_stems_for_overlap",
default_line_seconds: 2.6,
default_gap_seconds: 0.15,
},
speakers: [
{ id: "A", name: "Alice", voice: "voices_examples/female/female_02.wav", reference_text: "", emotion_ref: "happy" },
{ id: "B", name: "Bob", voice: "voices_examples/Clint_Eastwood CC3 (enhanced2).wav", reference_text: "", emotion_ref: "serious" },
],
lines: [
{ id: "line_001", speaker: "Alice", start: 0, duration: 2.6, overlap_after: 0.25, emotion: "happy", style: "warm", paralinguistic: "none", text: "I thought the room would be empty by now." },
{ id: "line_002", speaker: "Bob", start: 2.35, duration: 2.7, overlap_after: 0, emotion: "calm", style: "serious", paralinguistic: "none", text: "It is never empty when somebody is still listening." },
],
};
function nodeType(node) {
return String(node?.type || node?.comfyClass || node?.constructor?.type || "");
}
function isPlanner(node) {
const type = nodeType(node);
return type === "IAMCCS_DialogueScriptPlanner" || type.includes("DialogueScriptPlanner");
}
function widget(node, name) {
return (node?.widgets || []).find((item) => item?.name === name);
}
function setWidget(node, name, value) {
const item = widget(node, name);
if (!item) return false;
item.value = value;
try { item.callback?.(value); } catch {}
try { node.setDirtyCanvas?.(true, true); } catch {}
try { app.graph?.setDirtyCanvas?.(true, true); } catch {}
return true;
}
function hideWidget(item) {
if (!item) return;
item.hidden = true;
item.type = "hidden";
item.computeSize = () => [0, -4];
item.draw = () => {};
item.options = { ...(item.options || {}), hidden: true };
if (item.inputEl) {
item.inputEl.style.display = "none";
item.inputEl.style.height = "0";
}
}
function parseData(node) {
const raw = widget(node, "dialogue_data")?.value;
try {
const parsed = JSON.parse(String(raw || ""));
if (parsed && typeof parsed === "object") return parsed;
} catch {}
return JSON.parse(JSON.stringify(DEFAULT_DATA));
}
function writeData(node, data) {
data.schema = "iamccs.dialogue_script_planner";
data.schema_version = 1;
data.settings = data.settings || {};
data.settings.engine_profile = widget(node, "engine_profile")?.value || data.settings.engine_profile || "stepaudio_editx";
data.settings.timeline_mode = widget(node, "timeline_mode")?.value || data.settings.timeline_mode || "speaker_stems_for_overlap";
data.settings.default_line_seconds = Number(widget(node, "default_line_seconds")?.value || data.settings.default_line_seconds || 2.6);
data.settings.default_gap_seconds = Number(widget(node, "default_gap_seconds")?.value || data.settings.default_gap_seconds || 0.15);
setWidget(node, "dialogue_data", JSON.stringify(data, null, 2));
}
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.iamccs-dsp {
box-sizing: border-box;
width: 100%;
padding: 10px;
color: #dbe7e6;
background: linear-gradient(180deg, #11191b, #0b1012);
border: 1px solid rgba(150,185,185,.22);
border-radius: 8px;
font: 12px/1.35 Inter, ui-sans-serif, system-ui, sans-serif;
}
.iamccs-dsp * { box-sizing: border-box; }
.iamccs-dsp-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.iamccs-dsp-title {
color: #f4f3df;
font-size: 14px;
font-weight: 800;
}
.iamccs-dsp-sub {
color: #8facaa;
font-size: 10px;
margin-top: 2px;
}
.iamccs-dsp-actions,
.iamccs-dsp-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.iamccs-dsp button,
.iamccs-dsp select,
.iamccs-dsp input,
.iamccs-dsp textarea {
color: #e7f4ef;
background: #0b2022;
border: 1px solid rgba(103,178,183,.42);
border-radius: 6px;
font: inherit;
}
.iamccs-dsp button {
padding: 6px 9px;
cursor: pointer;
font-weight: 800;
box-shadow: inset 0 1px 0 rgba(255,255,255,.06);
}
.iamccs-dsp button:hover {
border-color: rgba(238,215,146,.8);
color: #fff4cf;
}
.iamccs-dsp .primary {
color: #1f1906;
background: linear-gradient(180deg, #f2d88c, #c99a45);
border-color: #f4d28a;
}
.iamccs-dsp .danger {
background: #4f1717;
border-color: #9a5550;
}
.iamccs-dsp select,
.iamccs-dsp input {
height: 28px;
padding: 3px 6px;
}
.iamccs-dsp textarea {
width: 100%;
min-height: 52px;
padding: 7px;
resize: vertical;
background: #081316;
}
.iamccs-dsp-grid {
display: grid;
grid-template-columns: 220px 1fr;
gap: 10px;
}
.iamccs-dsp-speakers,
.iamccs-dsp-lines {
min-width: 0;
}
.iamccs-dsp-section-title {
color: #f3d58a;
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
margin: 0 0 6px;
}
.iamccs-dsp-speaker {
padding: 8px;
margin-bottom: 7px;
background: linear-gradient(180deg, #182524, #111917);
border: 1px solid rgba(161,135,72,.45);
border-radius: 7px;
}
.iamccs-dsp-speaker input {
width: 100%;
margin-top: 5px;
}
.iamccs-dsp-line {
padding: 8px;
margin-bottom: 8px;
background: linear-gradient(180deg, #132024, #0d1518);
border: 1px solid rgba(102,144,150,.32);
border-left: 4px solid #c79a45;
border-radius: 7px;
}
.iamccs-dsp-line-top {
display: grid;
grid-template-columns: 110px repeat(3, 72px) 115px 115px 120px auto;
gap: 6px;
align-items: center;
margin-bottom: 6px;
}
.iamccs-dsp-line-top input,
.iamccs-dsp-line-top select { width: 100%; }
.iamccs-dsp-mini {
display: flex;
gap: 5px;
justify-content: flex-end;
}
.iamccs-dsp-help {
margin-top: 9px;
padding: 7px 8px;
color: #9ec2be;
background: #071012;
border: 1px solid rgba(127,165,164,.18);
border-radius: 7px;
font-size: 10px;
}
@media (max-width: 980px) {
.iamccs-dsp-grid { grid-template-columns: 1fr; }
.iamccs-dsp-line-top { grid-template-columns: 1fr 1fr; }
}
`;
document.head.appendChild(style);
}
function optionList(values, current) {
return values.map((value) => {
const opt = document.createElement("option");
opt.value = value;
opt.textContent = value;
opt.selected = value === current;
return opt;
});
}
function fieldInput(value, type = "text", step = "0.05") {
const input = document.createElement("input");
input.type = type;
input.value = value ?? "";
if (type === "number") input.step = step;
return input;
}
function fieldSelect(values, current) {
const select = document.createElement("select");
select.append(...optionList(values, current));
return select;
}
function installPlannerUI(node, reason = "install") {
if (!isPlanner(node) || node._iamccsDialoguePlannerReady) return;
ensureStyle();
node._iamccsDialoguePlannerReady = true;
[
"dialogue_data",
"engine_profile",
"timeline_mode",
"default_line_seconds",
"default_gap_seconds",
].forEach((name) => hideWidget(widget(node, name)));
const root = document.createElement("div");
root.className = "iamccs-dsp";
const data = parseData(node);
data.speakers = Array.isArray(data.speakers) && data.speakers.length ? data.speakers : JSON.parse(JSON.stringify(DEFAULT_DATA.speakers));
data.lines = Array.isArray(data.lines) && data.lines.length ? data.lines : JSON.parse(JSON.stringify(DEFAULT_DATA.lines));
const render = () => {
root.replaceChildren();
const head = document.createElement("div");
head.className = "iamccs-dsp-head";
const titleWrap = document.createElement("div");
const title = document.createElement("div");
title.className = "iamccs-dsp-title";
title.textContent = "IAMCCS DialogueScript Planner";
const sub = document.createElement("div");
sub.className = "iamccs-dsp-sub";
sub.textContent = `${data.lines.length} lines / ${data.speakers.length} speakers / overlap-ready stems`;
titleWrap.append(title, sub);
const actions = document.createElement("div");
actions.className = "iamccs-dsp-actions";
const addSpeaker = document.createElement("button");
addSpeaker.type = "button";
addSpeaker.textContent = "Add Speaker";
addSpeaker.onclick = () => {
const id = String.fromCharCode(65 + data.speakers.length);
data.speakers.push({ id, name: `Speaker ${id}`, voice: "", reference_text: "", emotion_ref: "calm" });
writeData(node, data);
render();
};
const addLine = document.createElement("button");
addLine.type = "button";
addLine.className = "primary";
addLine.textContent = "Add Line";
addLine.onclick = () => {
const last = data.lines[data.lines.length - 1] || { start: 0, duration: 2.6, overlap_after: 0 };
const start = Math.max(0, Number(last.start || 0) + Number(last.duration || 2.6) - Number(last.overlap_after || 0) + 0.15);
data.lines.push({
id: `line_${String(data.lines.length + 1).padStart(3, "0")}`,
speaker: data.speakers[0]?.name || "Speaker",
start: Number(start.toFixed(2)),
duration: 2.6,
overlap_after: 0,
emotion: "calm",
style: "none",
paralinguistic: "none",
text: "New dialogue line.",
});
writeData(node, data);
render();
};
const reflow = document.createElement("button");
reflow.type = "button";
reflow.textContent = "Reflow Timing";
reflow.onclick = () => {
let cursor = 0;
const gap = Number(widget(node, "default_gap_seconds")?.value || data.settings?.default_gap_seconds || 0.15);
data.lines.forEach((line) => {
line.start = Number(cursor.toFixed(2));
const dur = Number(line.duration || 2.6);
cursor += dur + gap - Number(line.overlap_after || 0);
});
writeData(node, data);
render();
};
actions.append(addSpeaker, addLine, reflow);
head.append(titleWrap, actions);
const toolbar = document.createElement("div");
toolbar.className = "iamccs-dsp-toolbar";
const engine = fieldSelect(["stepaudio_editx", "chatterbox", "indextts2", "plain"], widget(node, "engine_profile")?.value || data.settings?.engine_profile || "stepaudio_editx");
engine.onchange = () => {
setWidget(node, "engine_profile", engine.value);
writeData(node, data);
};
const mode = fieldSelect(["speaker_stems_for_overlap", "flatten_for_single_tts", "preserve_overlap_in_master_srt"], widget(node, "timeline_mode")?.value || data.settings?.timeline_mode || "speaker_stems_for_overlap");
mode.onchange = () => {
setWidget(node, "timeline_mode", mode.value);
writeData(node, data);
};
const lineSec = fieldInput(widget(node, "default_line_seconds")?.value || 2.6, "number");
lineSec.onchange = () => setWidget(node, "default_line_seconds", Number(lineSec.value || 2.6));
const gapSec = fieldInput(widget(node, "default_gap_seconds")?.value || 0.15, "number");
gapSec.onchange = () => setWidget(node, "default_gap_seconds", Number(gapSec.value || 0.15));
toolbar.append(engine, mode, lineSec, gapSec);
const grid = document.createElement("div");
grid.className = "iamccs-dsp-grid";
const speakers = document.createElement("div");
speakers.className = "iamccs-dsp-speakers";
const speakerTitle = document.createElement("div");
speakerTitle.className = "iamccs-dsp-section-title";
speakerTitle.textContent = "Speakers / Clones";
speakers.appendChild(speakerTitle);
data.speakers.forEach((speaker, index) => {
const card = document.createElement("div");
card.className = "iamccs-dsp-speaker";
const label = document.createElement("div");
label.textContent = `${speaker.id || index + 1}. ${speaker.name || "Speaker"}`;
label.style.color = "#f0d88d";
label.style.fontWeight = "900";
const name = fieldInput(speaker.name || "");
name.placeholder = "Speaker name";
name.onchange = () => {
const oldName = speaker.name;
speaker.name = name.value || `Speaker ${index + 1}`;
data.lines.forEach((line) => {
if (line.speaker === oldName) line.speaker = speaker.name;
});
writeData(node, data);
render();
};
const voice = fieldInput(speaker.voice || "");
voice.placeholder = "voice path / alias";
voice.onchange = () => {
speaker.voice = voice.value;
writeData(node, data);
};
const ref = fieldInput(speaker.reference_text || "");
ref.placeholder = "reference text";
ref.onchange = () => {
speaker.reference_text = ref.value;
writeData(node, data);
};
const emotionRef = fieldInput(speaker.emotion_ref || "");
emotionRef.placeholder = "IndexTTS emotion ref / alias";
emotionRef.onchange = () => {
speaker.emotion_ref = emotionRef.value;
writeData(node, data);
};
card.append(label, name, voice, ref, emotionRef);
speakers.appendChild(card);
});
const lines = document.createElement("div");
lines.className = "iamccs-dsp-lines";
const lineTitle = document.createElement("div");
lineTitle.className = "iamccs-dsp-section-title";
lineTitle.textContent = "Dialogue Lines";
lines.appendChild(lineTitle);
const speakerNames = data.speakers.map((speaker) => speaker.name || speaker.id || "Speaker");
data.lines.forEach((line, index) => {
const card = document.createElement("div");
card.className = "iamccs-dsp-line";
const top = document.createElement("div");
top.className = "iamccs-dsp-line-top";
const speaker = fieldSelect(speakerNames, line.speaker || speakerNames[0]);
speaker.onchange = () => {
line.speaker = speaker.value;
writeData(node, data);
};
const start = fieldInput(line.start ?? 0, "number");
start.onchange = () => {
line.start = Number(start.value || 0);
writeData(node, data);
};
const duration = fieldInput(line.duration ?? 2.6, "number");
duration.onchange = () => {
line.duration = Math.max(0.08, Number(duration.value || 2.6));
writeData(node, data);
};
const overlap = fieldInput(line.overlap_after ?? 0, "number");
overlap.onchange = () => {
line.overlap_after = Math.max(0, Number(overlap.value || 0));
writeData(node, data);
};
const emotion = fieldSelect(EMOTIONS, line.emotion || "none");
emotion.onchange = () => {
line.emotion = emotion.value;
writeData(node, data);
};
const style = fieldSelect(STYLES, line.style || "none");
style.onchange = () => {
line.style = style.value;
writeData(node, data);
};
const para = fieldSelect(PARA, line.paralinguistic || "none");
para.onchange = () => {
line.paralinguistic = para.value;
writeData(node, data);
};
const mini = document.createElement("div");
mini.className = "iamccs-dsp-mini";
const up = document.createElement("button");
up.type = "button";
up.textContent = "Up";
up.onclick = () => {
if (index <= 0) return;
const tmp = data.lines[index - 1];
data.lines[index - 1] = data.lines[index];
data.lines[index] = tmp;
writeData(node, data);
render();
};
const del = document.createElement("button");
del.type = "button";
del.className = "danger";
del.textContent = "Del";
del.onclick = () => {
data.lines.splice(index, 1);
writeData(node, data);
render();
};
mini.append(up, del);
top.append(speaker, start, duration, overlap, emotion, style, para, mini);
const text = document.createElement("textarea");
text.value = line.text || "";
text.placeholder = "Dialogue text";
text.onchange = () => {
line.text = text.value;
writeData(node, data);
};
card.append(top, text);
lines.appendChild(card);
});
grid.append(speakers, lines);
const help = document.createElement("div");
help.className = "iamccs-dsp-help";
help.textContent = "For true conversation overlap: route speaker_1_srt and speaker_2_srt to separate Unified TTS SRT branches, then mix stems in AudioBoardArranger. Master SRT/tagged text is for serial dialogue or tests.";
root.append(head, toolbar, grid, help);
writeData(node, data);
};
render();
const uiWidget = node.addDOMWidget("IAMCCS DialogueScript Planner UI", "iamccs_dialogue_script_planner_ui", root, { serialize: false });
uiWidget.computeSize = (width) => [width, 620];
node.size = [Math.max(Number(node.size?.[0] || 0), 900), Math.max(Number(node.size?.[1] || 0), 760)];
console.info("[IAMCCS DialogueScriptPlanner UI] installed", { nodeId: node?.id, reason });
}
app.registerExtension({
name: "IAMCCS.DialogueScriptPlannerUI",
setup() {
[600, 1600, 3600].forEach((delay) => setTimeout(() => {
const nodes = Array.isArray(app?.graph?._nodes) ? app.graph._nodes : [];
nodes.forEach((node) => installPlannerUI(node, `scan+${delay}`));
}, delay));
},
nodeCreated(node) {
[0, 200, 700].forEach((delay) => setTimeout(() => installPlannerUI(node, `nodeCreated+${delay}`), delay));
},
loadedGraphNode(node) {
[0, 200, 700].forEach((delay) => setTimeout(() => installPlannerUI(node, `loadedGraphNode+${delay}`), delay));
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== "IAMCCS_DialogueScriptPlanner") return;
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
originalOnNodeCreated?.apply(this, arguments);
setTimeout(() => installPlannerUI(this, "prototype.onNodeCreated"), 0);
};
},
});
+375
View File
@@ -0,0 +1,375 @@
import { app } from "../../scripts/app.js";
console.info("[IAMCCS MultiTimelineBridge UI] stable module loaded", { ts: new Date().toISOString() });
const STYLE_ID = "iamccs-multitimeline-bridge-stable-style";
function nodeType(node) {
return String(node?.type || node?.comfyClass || node?.constructor?.type || "");
}
function isBridgeNode(node) {
const type = nodeType(node);
return type === "IAMCCS_MultiTimelineBridge" || type.includes("MultiTimelineBridge");
}
function widget(node, name) {
return (node?.widgets || []).find((item) => item?.name === name);
}
function setWidget(node, name, value) {
const item = widget(node, name);
if (!item) return false;
item.value = value;
try { item.callback?.(value); } catch {}
try { node.setDirtyCanvas?.(true, true); } catch {}
try { app.graph?.setDirtyCanvas?.(true, true); } catch {}
return true;
}
function hideWidget(item) {
if (!item) return;
item.hidden = true;
item.type = "hidden";
item.computeSize = () => [0, -4];
item.draw = () => {};
item.options = { ...(item.options || {}), hidden: true };
if (item.inputEl) {
item.inputEl.style.display = "none";
item.inputEl.style.height = "0";
item.inputEl.style.opacity = "0";
}
}
function hideRawWidgets(node) {
[
"chunk_template",
"custom_chunk_seconds",
"source_bus",
"take_source_mode",
"take_count_mode",
"fixed_take_count",
"max_takes",
"active_take",
"frame_rate",
"take_track_layout",
"bus_manifest_json",
"master_out_json",
"track_1_json",
"track_2_json",
"track_3_json",
"track_4_json",
"track_5_json",
"visual_timelines_json",
].forEach((name) => hideWidget(widget(node, name)));
}
function num(value, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function maxTakes(node) {
return Math.max(2, Math.min(12, Math.round(num(widget(node, "max_takes")?.value, widget(node, "fixed_take_count")?.value || 3))));
}
function activeTake(node) {
return Math.max(1, Math.min(maxTakes(node), Math.round(num(widget(node, "active_take")?.value, 1))));
}
function setTake(node, take) {
const safeTake = Math.max(1, Math.round(Number(take) || 1));
setWidget(node, "active_take", safeTake);
try {
window.dispatchEvent(new CustomEvent("iamccs:multigeneration-active-take", {
detail: { nodeId: node?.id, activeTake: safeTake },
}));
} catch {}
}
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.iamccs-mtb {
box-sizing: border-box;
width: 100%;
padding: 10px;
border: 1px solid rgba(244, 212, 158, .28);
border-radius: 8px;
background: linear-gradient(180deg, #141b1d, #080d0f);
color: #e8f7f3;
font: 11px Inter, Arial, sans-serif;
pointer-events: auto;
overflow: hidden;
}
.iamccs-mtb-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 9px;
}
.iamccs-mtb-title {
color: #fff1ba;
font-size: 13px;
font-weight: 950;
}
.iamccs-mtb-sub {
color: #91b4b3;
font-size: 9px;
font-weight: 850;
margin-top: 2px;
}
.iamccs-mtb-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
margin-bottom: 8px;
}
.iamccs-mtb label {
min-width: 0;
display: grid;
gap: 3px;
color: #9fb9ba;
font-size: 8px;
font-weight: 950;
text-transform: uppercase;
}
.iamccs-mtb select,
.iamccs-mtb input {
min-width: 0;
height: 27px;
border: 1px solid rgba(91, 151, 154, .72);
border-radius: 5px;
background: #071013;
color: #eaffff;
font-size: 10px;
font-weight: 850;
padding: 0 7px;
box-sizing: border-box;
}
.iamccs-mtb button {
min-height: 27px;
border: 1px solid rgba(143, 208, 204, .42);
border-radius: 5px;
background: linear-gradient(180deg, #284a4e, #1a3034);
color: #ecffff;
cursor: pointer;
font-size: 10px;
font-weight: 900;
}
.iamccs-mtb button.is-primary,
.iamccs-mtb-take.is-active {
color: #171207;
background: linear-gradient(180deg, #f2d79a, #c79e59);
border-color: #ffe6ae;
}
.iamccs-mtb-takes {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
margin-bottom: 8px;
}
.iamccs-mtb-take {
min-width: 0;
min-height: 48px;
padding: 7px;
border: 1px solid rgba(143,208,204,.26);
border-radius: 6px;
background: rgba(0,0,0,.24);
color: #e8f7f3;
text-align: left;
}
.iamccs-mtb-take strong {
display: block;
font-size: 12px;
font-weight: 950;
}
.iamccs-mtb-take span {
display: block;
color: #8ba9aa;
font-size: 9px;
font-weight: 850;
}
.iamccs-mtb-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.iamccs-mtb-ledger {
margin-top: 7px;
padding: 6px 7px;
border: 1px solid rgba(255,255,255,.08);
border-radius: 5px;
background: #05090a;
color: #b8fff1;
font: 10px Consolas, monospace;
}
`;
document.head.appendChild(style);
}
function select(options, value, onChange) {
const el = document.createElement("select");
options.forEach(([optionValue, optionLabel]) => {
const option = document.createElement("option");
option.value = String(optionValue);
option.textContent = optionLabel;
el.appendChild(option);
});
el.value = String(value);
el.onchange = () => onChange(el.value);
return el;
}
function field(label, child) {
const wrap = document.createElement("label");
wrap.textContent = label;
wrap.appendChild(child);
return wrap;
}
function installBridgeUI(node, reason = "install") {
if (!isBridgeNode(node) || node._iamccsStableMultiTimelineBridgeReady || typeof node.addDOMWidget !== "function") return;
node._iamccsStableMultiTimelineBridgeReady = true;
ensureStyle();
hideRawWidgets(node);
const root = document.createElement("div");
root.className = "iamccs-mtb";
const render = () => {
hideRawWidgets(node);
const active = activeTake(node);
const max = maxTakes(node);
const chunkTemplate = String(widget(node, "chunk_template")?.value || "20s");
const customSeconds = num(widget(node, "custom_chunk_seconds")?.value, 20);
const fixedCount = Math.max(1, Math.round(num(widget(node, "fixed_take_count")?.value, 3)));
root.innerHTML = "";
const head = document.createElement("div");
head.className = "iamccs-mtb-head";
const title = document.createElement("div");
title.innerHTML = `<div class="iamccs-mtb-title">IAMCCS MultiTimeline Bridge</div><div class="iamccs-mtb-sub">real indexed take bridge, no raw JSON editing</div>`;
const refresh = document.createElement("button");
refresh.type = "button";
refresh.textContent = "Refresh UI";
refresh.onclick = render;
head.append(title, refresh);
const grid = document.createElement("div");
grid.className = "iamccs-mtb-grid";
const templateSelect = select([["10s", "10 sec"], ["15s", "15 sec"], ["20s", "20 sec"], ["25s", "25 sec"], ["custom", "custom"]], chunkTemplate, (value) => {
setWidget(node, "chunk_template", value);
render();
});
const secondsInput = document.createElement("input");
secondsInput.type = "number";
secondsInput.min = "1";
secondsInput.max = "300";
secondsInput.step = "0.25";
secondsInput.value = String(customSeconds);
secondsInput.onchange = () => setWidget(node, "custom_chunk_seconds", Math.max(1, Number(secondsInput.value || 20)));
const activeSelect = select(Array.from({ length: max }, (_, index) => {
const take = index + 1;
return [String(take), `T${String(take).padStart(2, "0")} / generation ${take}`];
}), active, (value) => {
setTake(node, value);
render();
});
const sourceSelect = select([["master_out", "Master out"], ["track_1", "A1"], ["track_2", "A2"], ["track_3", "A3"], ["track_4", "A4"], ["track_5", "A5"]], widget(node, "source_bus")?.value || "master_out", (value) => setWidget(node, "source_bus", value));
const sourceMode = select([["auto_detect_multi_lanes", "Use arranger T lanes"], ["chunk_source_bus", "Chunk source bus"]], widget(node, "take_source_mode")?.value || "auto_detect_multi_lanes", (value) => setWidget(node, "take_source_mode", value));
const countMode = select([["auto_from_audio", "Auto"], ["fixed_take_count", "Fixed"]], widget(node, "take_count_mode")?.value || "auto_from_audio", (value) => setWidget(node, "take_count_mode", value));
const fixedInput = document.createElement("input");
fixedInput.type = "number";
fixedInput.min = "1";
fixedInput.max = "64";
fixedInput.value = String(fixedCount);
fixedInput.onchange = () => setWidget(node, "fixed_take_count", Math.max(1, Math.round(Number(fixedInput.value || 1))));
const layoutSelect = select([["collapse_to_lane_1", "Send active chunk as A1"], ["preserve_bus_tracks", "Preserve bus lane"]], widget(node, "take_track_layout")?.value || "collapse_to_lane_1", (value) => setWidget(node, "take_track_layout", value));
grid.append(
field("Chunk template", templateSelect),
field("Custom sec", secondsInput),
field("Active timeline", activeSelect),
field("Source bus", sourceSelect),
field("Take source", sourceMode),
field("Take count", countMode),
field("Fixed takes", fixedInput),
field("Shotboard audio layout", layoutSelect),
);
const takeRow = document.createElement("div");
takeRow.className = "iamccs-mtb-takes";
for (let take = 1; take <= Math.min(5, max); take += 1) {
const card = document.createElement("button");
card.type = "button";
card.className = `iamccs-mtb-take${take === active ? " is-active" : ""}`;
card.innerHTML = `<strong>T${String(take).padStart(2, "0")}</strong><span>${take === active ? "prepared now" : "click to prepare"}</span>`;
card.onclick = () => {
setTake(node, take);
render();
};
takeRow.appendChild(card);
}
const actions = document.createElement("div");
actions.className = "iamccs-mtb-actions";
const prepare = document.createElement("button");
prepare.type = "button";
prepare.className = "is-primary";
prepare.textContent = `Prepare T${String(active).padStart(2, "0")}`;
prepare.onclick = () => {
setTake(node, active);
render();
};
const auto = document.createElement("button");
auto.type = "button";
auto.textContent = "Auto From T Lanes";
auto.onclick = () => {
setWidget(node, "take_source_mode", "auto_detect_multi_lanes");
setWidget(node, "source_bus", "master_out");
setWidget(node, "take_count_mode", "auto_from_audio");
setWidget(node, "take_track_layout", "collapse_to_lane_1");
setTake(node, 1);
render();
};
actions.append(prepare, auto);
const ledger = document.createElement("div");
ledger.className = "iamccs-mtb-ledger";
ledger.textContent = `Active T${String(active).padStart(2, "0")} | ${chunkTemplate === "custom" ? customSeconds + "s" : chunkTemplate} | Auto reads BusOut indexed T lanes: T1-A1, T2-A2, T3-A3. Use TakePicker branches to queue multiple generations in one run.`;
root.append(head, grid, takeRow, actions, ledger);
};
render();
const uiWidget = node.addDOMWidget("IAMCCS MultiTimeline Bridge UI", "iamccs_multitimeline_bridge_ui", root, { serialize: false });
uiWidget.computeSize = (width) => [width, 330];
node.size = [Math.max(Number(node.size?.[0] || 0), 620), Math.max(Number(node.size?.[1] || 0), 430)];
console.info("[IAMCCS MultiTimelineBridge UI] installed", { nodeId: node?.id, reason });
}
app.registerExtension({
name: "IAMCCS.MultiTimelineBridgeStableUI",
setup() {
[600, 1600, 3600].forEach((delay) => setTimeout(() => {
const nodes = Array.isArray(app?.graph?._nodes) ? app.graph._nodes : [];
nodes.forEach((node) => installBridgeUI(node, `scan+${delay}`));
}, delay));
},
nodeCreated(node) {
[0, 200, 700].forEach((delay) => setTimeout(() => installBridgeUI(node, `nodeCreated+${delay}`), delay));
},
loadedGraphNode(node) {
[0, 200, 700].forEach((delay) => setTimeout(() => installBridgeUI(node, `loadedGraphNode+${delay}`), delay));
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== "IAMCCS_MultiTimelineBridge") return;
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
originalOnNodeCreated?.apply(this, arguments);
setTimeout(() => installBridgeUI(this, "prototype.onNodeCreated"), 0);
};
},
});
+49 -9
View File
@@ -1,6 +1,6 @@
import { app } from "../../../scripts/app.js";
const IAMCCS_SUPERNODES_EXEC_UI_VERSION = "2026-05-04-section-button-reposition-v1";
const IAMCCS_SUPERNODES_EXEC_UI_VERSION = "2026-05-28-taeltx-preview-v2";
const PRESET_CONFIGS = {
"IAMCCS-SuperNodes AU+IMG2VID Exec Render": {
@@ -51,6 +51,7 @@ const NODE_GROUPS = {
{ key: "prompts", label: "Prompting", color: "#9a6b2f", widgets: ["positive_text", "negative_text"] },
{ key: "video", label: "Video", color: "#3f6fb0", widgets: ["width", "height"] },
{ key: "sampling", label: "Sampling", color: "#8352a6", widgets: ["steps", "cfg", "sampler_name", "seed", "max_shift", "base_shift", "sigma_terminal", "show_manual_sigmas", "manual_sigmas", "image_strength", "image_compression"] },
{ key: "taeltx_preview", label: "TAELTX Preview", color: "#4e7c9b", widgets: ["taeltx_preview", "taeltx_preview_max_frames"] },
{ key: "transition", label: "Transition / Stitch", color: "#7a7040", widgets: ["stitch_preset", "overlap_side", "overlap_mode", "start_frames_rule", "color_match_mode", "color_match_strength"] },
{ key: "audio_context", label: "Audio Context", color: "#477c7a", widgets: ["audio_context_mode", "audio_left_context_s", "audio_right_context_s"] },
{ key: "latent_refresh", label: "Latent Refresh (beta)", color: "#b35c5c", widgets: ["continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength"] },
@@ -76,7 +77,7 @@ const SECTION_BUTTON_COLLAPSED_BORDER = "#6d7785";
const SECTION_BUTTON_RADIUS = 6;
const SECTION_BUTTON_TEXT = "#f8fbff";
const SECTION_BUTTON_HEIGHT = 30;
const DEFAULT_COLLAPSED_SECTION_KEYS = new Set(["latent_refresh", "stage2", "output"]);
const DEFAULT_COLLAPSED_SECTION_KEYS = new Set(["taeltx_preview", "latent_refresh", "stage2", "output"]);
const RENDER_INTERNAL_WIDGETS = new Set([
"ui_preset",
"backend_mode",
@@ -127,6 +128,8 @@ const COMMON_GENERATION_DEFAULTS = {
second_stage_mode: "off",
second_stage_reinject_strength: 0.0,
show_manual_sigmas: false,
taeltx_preview: false,
taeltx_preview_max_frames: 17,
};
// Regression guard: these sampling defaults are the contract for the
// generation_type switch. They are applied only when the type changes; after
@@ -429,7 +432,7 @@ const RENDER_LEGACY_WIDGET_ORDER_PRE_ADVANCED_MANUAL_SIGMAS = [
"continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength",
"vae_mode", "downstream_stage_mode", "output_root", "segment_overlay_mode", "segment_overlay_text",
"second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas",
"media_mode", "vram_flush", "motion_intensity", "debug_verbose", "ui_preset", "generated_media_duration_seconds", "generation_type",
"media_mode", "vram_flush", "motion_intensity", "debug_verbose", "ui_preset", "generated_media_duration_seconds", "generation_type", "taeltx_preview", "taeltx_preview_max_frames",
];
const RENDER_LEGACY_WIDGET_ORDER = [
"generation_mode", "backend_mode", "positive_text", "negative_text", "width", "height", "steps", "cfg", "sampler_name", "seed", "control_after_generate",
@@ -439,7 +442,7 @@ const RENDER_LEGACY_WIDGET_ORDER = [
"continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength",
"vae_mode", "downstream_stage_mode", "output_root", "segment_overlay_mode", "segment_overlay_text",
"second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas",
"media_mode", "vram_flush", "motion_intensity", "debug_verbose", "ui_preset", "generated_media_duration_seconds", "generation_type",
"media_mode", "vram_flush", "motion_intensity", "debug_verbose", "ui_preset", "generated_media_duration_seconds", "generation_type", "taeltx_preview", "taeltx_preview_max_frames",
];
const RENDER_CURRENT_WIDGET_ORDER_PRE_GENERATED_FPS_PRE_ADVANCED_MANUAL_SIGMAS = [
"generation_type", "ui_preset", "generated_media_duration_seconds", "generation_mode", "backend_mode", "positive_text", "negative_text",
@@ -450,7 +453,7 @@ const RENDER_CURRENT_WIDGET_ORDER_PRE_GENERATED_FPS_PRE_ADVANCED_MANUAL_SIGMAS =
"continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength",
"vae_mode", "downstream_stage_mode", "output_root", "segment_overlay_mode", "segment_overlay_text",
"second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas",
"media_mode", "vram_flush", "motion_intensity", "debug_verbose",
"media_mode", "vram_flush", "motion_intensity", "debug_verbose", "taeltx_preview", "taeltx_preview_max_frames",
];
const RENDER_CURRENT_WIDGET_ORDER_PRE_GENERATED_FPS = [
"generation_type", "ui_preset", "generated_media_duration_seconds", "generation_mode", "backend_mode", "positive_text", "negative_text",
@@ -461,7 +464,7 @@ const RENDER_CURRENT_WIDGET_ORDER_PRE_GENERATED_FPS = [
"continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength",
"vae_mode", "downstream_stage_mode", "output_root", "segment_overlay_mode", "segment_overlay_text",
"second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas",
"media_mode", "vram_flush", "motion_intensity", "debug_verbose",
"media_mode", "vram_flush", "motion_intensity", "debug_verbose", "taeltx_preview", "taeltx_preview_max_frames",
];
const RENDER_CURRENT_WIDGET_ORDER_PRE_ADVANCED_MANUAL_SIGMAS = [
"generation_type", "ui_preset", "generated_media_duration_seconds", "generated_media_fps", "generation_mode", "backend_mode", "positive_text", "negative_text",
@@ -472,7 +475,7 @@ const RENDER_CURRENT_WIDGET_ORDER_PRE_ADVANCED_MANUAL_SIGMAS = [
"continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength",
"vae_mode", "downstream_stage_mode", "output_root", "segment_overlay_mode", "segment_overlay_text",
"second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas",
"media_mode", "vram_flush", "motion_intensity", "debug_verbose",
"media_mode", "vram_flush", "motion_intensity", "debug_verbose", "taeltx_preview", "taeltx_preview_max_frames",
];
const RENDER_CURRENT_WIDGET_ORDER = [
"generation_type", "ui_preset", "generated_media_duration_seconds", "generated_media_fps", "generation_mode", "backend_mode", "positive_text", "negative_text",
@@ -483,7 +486,7 @@ const RENDER_CURRENT_WIDGET_ORDER = [
"continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength",
"vae_mode", "downstream_stage_mode", "output_root", "segment_overlay_mode", "segment_overlay_text",
"second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas",
"media_mode", "vram_flush", "motion_intensity", "debug_verbose",
"media_mode", "vram_flush", "motion_intensity", "debug_verbose", "taeltx_preview", "taeltx_preview_max_frames",
];
const VAE_LEGACY_WIDGET_ORDER = [
"frame_rate", "decode_mode", "tiled_tile_size", "tiled_overlap", "frames_subdir", "image_format", "jpg_quality",
@@ -920,6 +923,7 @@ function normalizeRenderWidgetOrder(node) {
moveWidgetsAfter(node, "sigma_terminal", ["show_manual_sigmas", "manual_sigmas"]);
moveWidgetsAfter(node, "start_frames_rule", ["color_match_mode", "color_match_strength"]);
moveWidgetsAfter(node, "second_stage_mode", ["stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas"]);
moveWidgetsAfter(node, "debug_verbose", ["taeltx_preview", "taeltx_preview_max_frames"]);
}
function normalizeVaeWidgetOrder(node) {
@@ -1712,6 +1716,20 @@ function applyRenderManualSigmasVisibility(node) {
fitNodeToWidgets(node);
}
function applyRenderTaeltxPreviewVisibility(node) {
if (node?.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec Render") {
return;
}
const previewExpanded = !!node.properties?.iamccs_section_taeltx_preview;
const enabledWidget = findWidget(node, "taeltx_preview");
const maxFramesWidget = findWidget(node, "taeltx_preview_max_frames");
setWidgetLabel(node, "taeltx_preview", "TAELTX Preview");
setWidgetLabel(node, "taeltx_preview_max_frames", "Preview Frames");
setWidgetVisibility(enabledWidget, previewExpanded);
setWidgetVisibility(maxFramesWidget, previewExpanded && enabledWidget?.value === true);
fitNodeToWidgets(node);
}
function inferRenderGenerationType(valuesByName) {
const generationMode = String(valuesByName.generation_mode || "img2vid");
const backendMode = String(valuesByName.backend_mode || "auto");
@@ -1772,6 +1790,7 @@ function applyRenderInternalWidgetVisibility(node) {
const generationExpanded = node.properties?.iamccs_section_generation !== false;
setWidgetVisibility(findWidget(node, "backend_mode"), generationExpanded && GENERATED_DURATION_TYPES.has(generationType));
applyRenderManualSigmasVisibility(node);
applyRenderTaeltxPreviewVisibility(node);
}
function applyRenderSecondStageVisibility(node) {
@@ -1856,6 +1875,7 @@ function applyPresetConfig(node, nodeName) {
applyRenderAnchorVisibility(node);
applyRenderSecondStageVisibility(node);
applyRenderGeneratedDurationVisibility(node);
applyRenderTaeltxPreviewVisibility(node);
applyRenderPresetDropdownOptions(node);
}
fitNodeToWidgets(node);
@@ -2024,6 +2044,12 @@ function readRenderSerializedValues(cleanValues) {
valuesByName.generated_media_fps = Number.isFinite(Number(valuesByName.generated_media_fps))
? Number(valuesByName.generated_media_fps)
: Number(generationConfig.generated_media_fps || DEFAULT_GENERATED_FPS);
valuesByName.taeltx_preview = isSerializedBoolean(valuesByName.taeltx_preview)
? String(valuesByName.taeltx_preview).toLowerCase() === "true"
: false;
valuesByName.taeltx_preview_max_frames = Number.isFinite(Number(valuesByName.taeltx_preview_max_frames))
? Number(valuesByName.taeltx_preview_max_frames)
: 17;
return normalizeReferenceAudioImg2VidValues(valuesByName);
}
if (["img2vid", "t2v"].includes(String(cleanValues[0] || ""))) {
@@ -2048,6 +2074,12 @@ function readRenderSerializedValues(cleanValues) {
valuesByName.generated_media_fps = Number.isFinite(Number(valuesByName.generated_media_fps))
? Number(valuesByName.generated_media_fps)
: Number(generationConfig.generated_media_fps || DEFAULT_GENERATED_FPS);
valuesByName.taeltx_preview = isSerializedBoolean(valuesByName.taeltx_preview)
? String(valuesByName.taeltx_preview).toLowerCase() === "true"
: false;
valuesByName.taeltx_preview_max_frames = Number.isFinite(Number(valuesByName.taeltx_preview_max_frames))
? Number(valuesByName.taeltx_preview_max_frames)
: 17;
return normalizeReferenceAudioImg2VidValues(valuesByName);
}
return null;
@@ -2217,7 +2249,9 @@ function sanitizeRenderWidgetValues(node) {
sanitizeRenderNumber(node, "second_stage_reinject_strength", 0.0);
sanitizeRenderNumber(node, "second_stage_cfg", 1.0);
sanitizeRenderNumber(node, "motion_intensity", 1.0);
sanitizeRenderNumber(node, "taeltx_preview_max_frames", 17);
sanitizeBooleanWidget(node, "show_manual_sigmas", false);
sanitizeBooleanWidget(node, "taeltx_preview", false);
sanitizeBooleanWidget(node, "debug_verbose", false);
const normalizedBackendMode = normalizeRenderBackendValue(findWidget(node, "backend_mode")?.value);
if (LEGACY_RENDER_BACKEND_DEFAULTS[normalizedBackendMode]) {
@@ -2229,6 +2263,7 @@ function sanitizeRenderWidgetValues(node) {
applyRenderGeneratedDurationVisibility(node);
applyRenderManualSigmasVisibility(node);
applyRenderTaeltxPreviewVisibility(node);
}
function sanitizeVaeWidgetValues(node) {
@@ -2855,7 +2890,7 @@ app.registerExtension({
applyRenderGenerationTypeChange(this);
});
}
for (const widgetName of ["backend_mode", "generated_media_duration_seconds", "generated_media_fps", "show_manual_sigmas", "second_stage_mode", "continuity_anchor_mode", "anti_drift_mode"]) {
for (const widgetName of ["backend_mode", "generated_media_duration_seconds", "generated_media_fps", "show_manual_sigmas", "taeltx_preview", "second_stage_mode", "continuity_anchor_mode", "anti_drift_mode"]) {
const widget = findWidget(this, widgetName);
if (!widget) {
continue;
@@ -3002,6 +3037,11 @@ app.registerExtension({
applyRenderManualSigmasVisibility(this);
markCanvasDirty();
}
if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render" && changedName === "taeltx_preview") {
sanitizeRenderWidgetValues(this);
applyRenderTaeltxPreviewVisibility(this);
markCanvasDirty();
}
});
return result;
};
+162 -9
View File
@@ -1,16 +1,169 @@
import { app } from "../../scripts/app.js";
// IAMCCS intentionally does not touch ComfyUI workflow persistence.
// IAMCCS: targeted ComfyUI frontend draft cleanup.
//
// ComfyUI frontend v1.43+ restores open workflow tabs through its own browser
// storage keys. Older revisions of this extension tried to clean or recover
// those keys when localStorage quota was exceeded, but that can make ComfyUI
// start with a blank workspace or only one restored workflow.
// ComfyUI frontend v1.46 stores workflow drafts in browser localStorage under:
// - Comfy.Workflow.Draft.v2:
// - Comfy.Workflow.DraftIndex.v2:
//
// Keep this extension as a harmless no-op so cached extension lists and older
// installs do not fail to import it, while leaving all workflow/tab persistence
// behavior to ComfyUI itself.
// Large audio-board / multigeneration workflows can exhaust browser draft
// storage and trigger "Failed to save workflow draft" on every edit, even for
// unrelated workflows. IAMCCS stores important workflows as physical JSON files,
// so draft payloads can be safely purged once when the browser gets stuck.
function clearWorkflowDraftStorage(reason = "manual") {
try {
const payloadPrefix = "Comfy.Workflow.Draft.v2:";
const indexPrefix = "Comfy.Workflow.DraftIndex.v2:";
const legacyPayloadPrefix = "Comfy.Workflow.Draft:";
const legacyIndexPrefix = "Comfy.Workflow.DraftIndex:";
let removed = 0;
let chars = 0;
for (let i = localStorage.length - 1; i >= 0; i -= 1) {
const key = localStorage.key(i);
if (!key) continue;
const shouldPurge =
key.startsWith(payloadPrefix) ||
key.startsWith(indexPrefix) ||
key.startsWith(legacyPayloadPrefix) ||
key.startsWith(legacyIndexPrefix);
if (!shouldPurge) continue;
const value = localStorage.getItem(key) || "";
chars += value.length;
localStorage.removeItem(key);
removed += 1;
}
if (removed) {
console.info(`[IAMCCS] Manually purged ${removed} ComfyUI workflow draft storage entries (${chars} chars).`, { reason });
} else {
console.info("[IAMCCS] Workflow draft storage manual purge requested; no entries found.", { reason });
}
} catch (err) {
console.warn("[IAMCCS] Workflow draft cleanup failed", err);
}
}
function installWorkflowDraftRescue() {
try {
window.IAMCCS_clearWorkflowDraftStorage = clearWorkflowDraftStorage;
installLiteDraftFallback();
console.info("[IAMCCS] Workflow draft restore mode active. Drafts are preserved; manual rescue available as window.IAMCCS_clearWorkflowDraftStorage().");
} catch (err) {
console.warn("[IAMCCS] Workflow draft rescue install failed", err);
}
}
function isWorkflowDraftKey(key) {
const text = String(key || "");
return text.startsWith("Comfy.Workflow.Draft.v2:") || text.startsWith("Comfy.Workflow.Draft:");
}
function looksLikeHeavyMediaString(value) {
const text = String(value || "");
return text.length > 180_000 || text.includes("data:audio") || text.includes("data:video") || text.includes(";base64,");
}
function makeLiteDraftPayload(payload) {
try {
const parsed = JSON.parse(String(payload || "{}"));
let stripped = 0;
const visit = (value, key = "") => {
if (typeof value === "string") {
const lowerKey = String(key || "").toLowerCase();
if (
lowerKey.includes("b64") ||
lowerKey.includes("base64") ||
looksLikeHeavyMediaString(value)
) {
stripped += value.length;
return `[IAMCCS stripped heavy draft payload: ${value.length} chars]`;
}
return value;
}
if (Array.isArray(value)) return value.map((item) => visit(item, key));
if (value && typeof value === "object") {
for (const objectKey of Object.keys(value)) {
value[objectKey] = visit(value[objectKey], objectKey);
}
}
return value;
};
const lite = visit(parsed);
return { payload: JSON.stringify(lite), stripped };
} catch {
const text = String(payload || "");
if (!looksLikeHeavyMediaString(text)) return { payload: text, stripped: 0 };
return {
payload: JSON.stringify({
iamccs_lite_draft: true,
note: "Original Comfy draft was too heavy for browser storage.",
stripped_chars: text.length,
}),
stripped: text.length,
};
}
}
function pruneOtherWorkflowDrafts(currentKey) {
let removed = 0;
let chars = 0;
try {
const keys = [];
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (!key || key === currentKey) continue;
if (isWorkflowDraftKey(key)) {
const value = localStorage.getItem(key) || "";
keys.push({ key, length: value.length });
}
}
keys.sort((a, b) => b.length - a.length);
for (const item of keys.slice(0, 6)) {
const value = localStorage.getItem(item.key) || "";
chars += value.length;
localStorage.removeItem(item.key);
removed += 1;
}
} catch (err) {
console.warn("[IAMCCS] Draft pruning during quota rescue failed", err);
}
return { removed, chars };
}
function installLiteDraftFallback() {
if (window.__IAMCCS_LITE_DRAFT_FALLBACK_INSTALLED__) return;
window.__IAMCCS_LITE_DRAFT_FALLBACK_INSTALLED__ = true;
const nativeSetItem = Storage.prototype.setItem;
Storage.prototype.setItem = function iamccsSetItemWithDraftFallback(key, value) {
try {
return nativeSetItem.call(this, key, value);
} catch (err) {
if (!isWorkflowDraftKey(key)) throw err;
const pruned = pruneOtherWorkflowDrafts(String(key || ""));
try {
return nativeSetItem.call(this, key, value);
} catch (secondErr) {
const lite = makeLiteDraftPayload(value);
if (!lite.stripped) throw secondErr;
try {
nativeSetItem.call(this, key, lite.payload);
console.warn("[IAMCCS] Saved lite workflow draft after browser quota error.", {
key,
stripped: lite.stripped,
pruned,
});
return undefined;
} catch (thirdErr) {
console.error("[IAMCCS] Lite workflow draft fallback failed.", { key, pruned, thirdErr });
throw thirdErr;
}
}
}
};
}
app.registerExtension({
name: "iamccs.workflow_persist_cleanup",
async setup() {},
async setup() {
installWorkflowDraftRescue();
},
});