release 1.4.7
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
Binary file not shown.
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+182
-11
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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
+1281
-227
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user