diff --git a/CHANGELOG.md b/CHANGELOG.md index 37a2859..c1fded5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 66200d9..920d140 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/__init__.py b/__init__.py index 965bbf7..4f3d927 100644 --- a/__init__.py +++ b/__init__.py @@ -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: diff --git a/au_tools/__init__.py b/au_tools/__init__.py new file mode 100644 index 0000000..d420417 --- /dev/null +++ b/au_tools/__init__.py @@ -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", +} diff --git a/au_tools/__pycache__/__init__.cpython-312.pyc b/au_tools/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a70170a Binary files /dev/null and b/au_tools/__pycache__/__init__.cpython-312.pyc differ diff --git a/au_tools/__pycache__/audio_board_arranger.cpython-312.pyc b/au_tools/__pycache__/audio_board_arranger.cpython-312.pyc new file mode 100644 index 0000000..5c440c6 Binary files /dev/null and b/au_tools/__pycache__/audio_board_arranger.cpython-312.pyc differ diff --git a/au_tools/__pycache__/audio_board_mixer.cpython-312.pyc b/au_tools/__pycache__/audio_board_mixer.cpython-312.pyc new file mode 100644 index 0000000..c6ee056 Binary files /dev/null and b/au_tools/__pycache__/audio_board_mixer.cpython-312.pyc differ diff --git a/au_tools/__pycache__/audio_bus_out.cpython-312.pyc b/au_tools/__pycache__/audio_bus_out.cpython-312.pyc new file mode 100644 index 0000000..2fa7f07 Binary files /dev/null and b/au_tools/__pycache__/audio_bus_out.cpython-312.pyc differ diff --git a/au_tools/__pycache__/audio_control_efx.cpython-312.pyc b/au_tools/__pycache__/audio_control_efx.cpython-312.pyc new file mode 100644 index 0000000..a225b7e Binary files /dev/null and b/au_tools/__pycache__/audio_control_efx.cpython-312.pyc differ diff --git a/au_tools/__pycache__/dialogue_script_planner.cpython-312.pyc b/au_tools/__pycache__/dialogue_script_planner.cpython-312.pyc new file mode 100644 index 0000000..5aea9d4 Binary files /dev/null and b/au_tools/__pycache__/dialogue_script_planner.cpython-312.pyc differ diff --git a/au_tools/audio_board_arranger.py b/au_tools/audio_board_arranger.py new file mode 100644 index 0000000..d05ad3b --- /dev/null +++ b/au_tools/audio_board_arranger.py @@ -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", +} diff --git a/au_tools/audio_board_mixer.py b/au_tools/audio_board_mixer.py new file mode 100644 index 0000000..ca8028f --- /dev/null +++ b/au_tools/audio_board_mixer.py @@ -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) diff --git a/au_tools/audio_bus_out.pre_multigen_index_fix_20260527.py b/au_tools/audio_bus_out.pre_multigen_index_fix_20260527.py new file mode 100644 index 0000000..7d5d1b1 --- /dev/null +++ b/au_tools/audio_bus_out.pre_multigen_index_fix_20260527.py @@ -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", +} diff --git a/au_tools/audio_bus_out.py b/au_tools/audio_bus_out.py new file mode 100644 index 0000000..7eda5ca --- /dev/null +++ b/au_tools/audio_bus_out.py @@ -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", +} diff --git a/au_tools/audio_control_efx.py b/au_tools/audio_control_efx.py new file mode 100644 index 0000000..e1842cd --- /dev/null +++ b/au_tools/audio_control_efx.py @@ -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", +} diff --git a/au_tools/dialogue_script_planner.py b/au_tools/dialogue_script_planner.py new file mode 100644 index 0000000..97c39ea --- /dev/null +++ b/au_tools/dialogue_script_planner.py @@ -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"") + if style and style != "none": + tags.append(f"") + 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", +} diff --git a/cine_multigeneration/README.md b/cine_multigeneration/README.md new file mode 100644 index 0000000..0df92ec --- /dev/null +++ b/cine_multigeneration/README.md @@ -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. diff --git a/cine_multigeneration/__init__.pre_multigen_ui_fix_20260527.py b/cine_multigeneration/__init__.pre_multigen_ui_fix_20260527.py new file mode 100644 index 0000000..d9cfbc9 --- /dev/null +++ b/cine_multigeneration/__init__.pre_multigen_ui_fix_20260527.py @@ -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", +} diff --git a/cine_multigeneration/__init__.py b/cine_multigeneration/__init__.py new file mode 100644 index 0000000..1847e8d --- /dev/null +++ b/cine_multigeneration/__init__.py @@ -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", +} diff --git a/cine_multigeneration/__pycache__/__init__.cpython-312.pyc b/cine_multigeneration/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..56a2b4b Binary files /dev/null and b/cine_multigeneration/__pycache__/__init__.cpython-312.pyc differ diff --git a/cine_wan_edition_beta/README.md b/cine_wan_edition_beta/README.md new file mode 100644 index 0000000..ab918d6 --- /dev/null +++ b/cine_wan_edition_beta/README.md @@ -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`. + diff --git a/cine_wan_edition_beta/__init__.py b/cine_wan_edition_beta/__init__.py new file mode 100644 index 0000000..0c3148d --- /dev/null +++ b/cine_wan_edition_beta/__init__.py @@ -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 ''}" + ) + 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 "", + "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", +} diff --git a/cine_wan_edition_beta/__pycache__/__init__.cpython-312.pyc b/cine_wan_edition_beta/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..69b28ac Binary files /dev/null and b/cine_wan_edition_beta/__pycache__/__init__.cpython-312.pyc differ diff --git a/iamccs_cine_audio_dialogue.py b/iamccs_cine_audio_dialogue.py new file mode 100644 index 0000000..b7a66f8 --- /dev/null +++ b/iamccs_cine_audio_dialogue.py @@ -0,0 +1,1807 @@ +from __future__ import annotations + +import copy +import json +import math +import os +import re +import sys +import tempfile +from typing import Any, Dict, List, Tuple + +import torch + + +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_cine_audio_dialogue", + "resources": {}, + "outputs": {}, + "chain": [], + "stages": [], + } + + +def _resources(cine_linx: Dict[str, Any]) -> Dict[str, Any]: + resources = cine_linx.setdefault("resources", {}) + return resources if isinstance(resources, dict) else {} + + +def _outputs(cine_linx: Dict[str, Any]) -> Dict[str, Any]: + outputs = cine_linx.setdefault("outputs", {}) + return outputs if isinstance(outputs, dict) else {} + + +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 _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 _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 _normalise_label(value: Any, fallback: str) -> str: + text = re.sub(r"[^\w.-]+", "_", str(value or "").strip(), flags=re.UNICODE).strip("_") + return text or str(fallback) + + +def _extract_quoted_text(text: Any) -> List[str]: + pattern = r'"([^"]*)"|\'([^\']*)\'|\u201c([^\u201d]*)\u201d|\u2018([^\u2019]*)\u2019' + out: List[str] = [] + for match in re.findall(pattern, str(text or "")): + quoted = next((part for part in match if part), "").strip() + if quoted: + out.append(quoted) + return out + + +def _count_spoken_words(text: Any) -> int: + words = str(text or "").split() + return len([word for word in words if word.strip()]) + + +def _estimate_speech_seconds(spoken_text: Any, wpm: float, additional_time: float = 0.0) -> float: + word_count = _count_spoken_words(spoken_text) + if word_count == 0 and float(additional_time) == 0.0: + return 0.0 + return (float(word_count) / max(1.0, float(wpm)) * 60.0) + float(additional_time) + + +def _speech_wpm(speed: str, custom_wpm: float) -> float: + lookup = { + "slow_100_wpm": 100.0, + "average_130_wpm": 130.0, + "fast_160_wpm": 160.0, + } + return lookup.get(str(speed), max(1.0, float(custom_wpm or 130.0))) + + +def _audio_waveform(audio: Any) -> Tuple[torch.Tensor, int]: + current = audio + for _ in range(6): + if isinstance(current, dict): + if "waveform" in current and "sample_rate" in current: + waveform = current["waveform"] + if not torch.is_tensor(waveform): + raise ValueError("audio['waveform'] must be a tensor") + if waveform.ndim == 1: + waveform = waveform.view(1, 1, -1) + elif waveform.ndim == 2: + waveform = waveform.unsqueeze(0) + elif waveform.ndim != 3: + raise ValueError(f"Unsupported audio waveform rank: {waveform.ndim}") + return waveform, int(current["sample_rate"]) + if "audio" in current: + current = current.get("audio") + continue + if isinstance(current, (list, tuple)) and current: + current = current[0] + continue + break + raise ValueError("Expected ComfyUI AUDIO dict") + + +def _gain(audio_waveform: torch.Tensor, db: float) -> torch.Tensor: + return audio_waveform * (10.0 ** (float(db) / 20.0)) + + +def _ensure_channels(waveform: torch.Tensor, channels: int) -> torch.Tensor: + current = int(waveform.shape[1]) + if current == channels: + return waveform + if current == 1 and channels > 1: + return waveform.repeat(1, channels, 1) + if channels == 1 and current > 1: + return waveform.mean(dim=1, keepdim=True) + if current > channels: + return waveform[:, :channels, :] + return torch.cat([waveform, waveform[:, -1:, :].repeat(1, channels - current, 1)], dim=1) + + +def _pad_to(waveform: torch.Tensor, samples: int) -> torch.Tensor: + if int(waveform.shape[-1]) >= int(samples): + return waveform[:, :, : int(samples)] + pad = torch.zeros( + (waveform.shape[0], waveform.shape[1], int(samples) - int(waveform.shape[-1])), + dtype=waveform.dtype, + device=waveform.device, + ) + return torch.cat([waveform, pad], dim=-1) + + +def _resample_waveform(waveform: torch.Tensor, source_rate: int, target_rate: int) -> torch.Tensor: + if int(source_rate) == int(target_rate): + return waveform + try: + import torchaudio.functional as TAF + return TAF.resample(waveform, orig_freq=int(source_rate), new_freq=int(target_rate)) + except Exception as exc: + raise RuntimeError(f"Cannot resample audio from {source_rate}Hz to {target_rate}Hz: {exc}") from exc + + +def _visual_segments(cine_linx: Dict[str, Any], timeline_data: str = "") -> List[Dict[str, Any]]: + resources = _resources(cine_linx) + payload = _payload(cine_linx) + candidates = [ + resources.get("cine_visual_segments_json"), + payload.get("visual_segments"), + timeline_data, + ] + for candidate in candidates: + data = _safe_json_loads(candidate, None) + if isinstance(data, dict): + data = data.get("segments", data.get("visual_segments", [])) + if isinstance(data, list): + return [dict(item) for item in data if isinstance(item, dict)] + return [] + + +def _speaker_from_text(text: str, default: str) -> Tuple[str, str]: + raw = str(text or "").strip() + match = re.match(r"^\s*([ABab12]|speaker\s*[12])\s*[:|-]\s*(.+)$", raw) + if not match: + return default, raw + token = match.group(1).lower().replace(" ", "") + speaker = "B" if token in {"b", "2", "speaker2"} else "A" + return speaker, match.group(2).strip() + + +def _round_up_8n_plus_1(frames: int) -> int: + frames = max(1, int(frames)) + rem = (frames - 1) % 8 + return frames if rem == 0 else frames + (8 - rem) + + +class IAMCCS_CineSpeechLength: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", {"multiline": True, "default": 'Put spoken words inside quotes, for example: "I am ready."'}), + "fps": ("INT", {"default": 24, "min": 1, "max": 120, "step": 1}), + "additional_time": ("FLOAT", {"default": 0.0, "min": 0.0, "step": 0.1}), + }, + "optional": { + "text_input": ("STRING", {"forceInput": True}), + }, + } + + RETURN_TYPES = ("INT", "INT", "INT", "STRING") + RETURN_NAMES = ("slow_frame_count", "average_frame_count", "fast_frame_count", "text") + FUNCTION = "calculate_speech" + CATEGORY = "IAMCCS/Cine/Audio" + + def calculate_speech(self, text, fps, additional_time=0.0, text_input=None): + active_text = text_input if isinstance(text_input, str) and text_input.strip() else text + quoted_text = " ".join(_extract_quoted_text(active_text)) + word_count = _count_spoken_words(quoted_text) + + def calc_frames(wpm: float) -> int: + if word_count == 0 and float(additional_time) == 0.0: + return 0 + seconds = (word_count / float(wpm) * 60.0) + float(additional_time) + return int(math.ceil(seconds * int(fps))) + + return calc_frames(100.0), calc_frames(130.0), calc_frames(160.0), str(active_text or "") + + +class IAMCCS_CineDialogueDurationPlanner: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "cine_linx": (SUPERNODE_LINX_TYPE,), + "fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}), + "speech_speed": (["average_130_wpm", "slow_100_wpm", "fast_160_wpm", "custom_wpm"], {"default": "average_130_wpm"}), + "custom_wpm": ("FLOAT", {"default": 130.0, "min": 40.0, "max": 320.0, "step": 1.0}), + "additional_time_s": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 30.0, "step": 0.05}), + "panel_padding_s": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 10.0, "step": 0.05}), + "min_panel_s": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 60.0, "step": 0.05}), + "max_panel_s": ("FLOAT", {"default": 25.0, "min": 0.5, "max": 600.0, "step": 0.1}), + "speaker_split_mode": (["detect_a_b_prefix", "alternate", "all_to_a"], {"default": "detect_a_b_prefix"}), + "timeline_update_mode": (["report_only", "write_audio_cues_to_cine_linx", "extend_visual_segments_in_cine_linx"], {"default": "write_audio_cues_to_cine_linx"}), + }, + "optional": { + "dialogue_text": ("STRING", {"multiline": True, "forceInput": True}), + "timeline_data": ("STRING", {"multiline": True, "forceInput": True}), + }, + } + + RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "STRING", "STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("cine_linx", "dialogue_cues_json", "tts_text_a", "tts_text_b", "panel_plan_json", "segment_lengths", "report") + FUNCTION = "plan" + CATEGORY = "IAMCCS/Cine/Audio" + + def _cues_from_text(self, text: str, fps: float, wpm: float, additional_time_s: float, padding_s: float, split_mode: str) -> List[Dict[str, Any]]: + cues: List[Dict[str, Any]] = [] + current_time = 0.0 + alternate = "A" + for raw_line in str(text or "").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + speaker = alternate + start_s = current_time + spoken = line + parts = line.split("|", 2) + if len(parts) == 3: + speaker, maybe_time, spoken = parts[0].strip().upper()[:1] or "A", parts[1], parts[2].strip() + start_s = _safe_float(maybe_time, current_time) + elif len(parts) == 2: + speaker, spoken = parts[0].strip().upper()[:1] or "A", parts[1].strip() + elif split_mode == "detect_a_b_prefix": + speaker, spoken = _speaker_from_text(line, alternate) + elif split_mode == "all_to_a": + speaker = "A" + quoted = _extract_quoted_text(spoken) + spoken = quoted[0] if quoted else spoken.strip().strip('"').strip("'") + seconds = _estimate_speech_seconds(spoken, wpm, additional_time_s) + float(padding_s) + cues.append({ + "speaker": "B" if speaker == "B" else "A", + "start_s": round(start_s, 3), + "estimated_duration_s": round(seconds, 3), + "estimated_frames": int(math.ceil(seconds * float(fps))), + "text": spoken, + "source": "dialogue_text", + }) + current_time = max(current_time, start_s + seconds) + alternate = "B" if alternate == "A" else "A" + return cues + + def _cues_from_segments(self, cine_linx: Dict[str, Any], timeline_data: str, fps: float, wpm: float, additional_time_s: float, padding_s: float, split_mode: str) -> List[Dict[str, Any]]: + cues: List[Dict[str, Any]] = [] + alternate = "A" + for index, seg in enumerate(sorted(_visual_segments(cine_linx, timeline_data), key=lambda item: _safe_int(item.get("start", item.get("frame", 0)), 0))): + if str(seg.get("type", "image") or "image").strip().lower() == "audio": + continue + raw_text = " ".join(str(seg.get(key, "") or "") for key in ("dialogue", "audio_or_dialogue", "prompt", "local_prompt", "relay_prompt", "note")) + quoted = _extract_quoted_text(raw_text) + if not quoted: + continue + start_frames = max(0, _safe_int(seg.get("start", seg.get("frame", 0)), 0)) + panel_frames = max(1, _safe_int(seg.get("length", seg.get("len", round(float(fps)))), round(float(fps)))) + for phrase in quoted: + speaker = str(seg.get("speaker", "") or "").strip().upper()[:1] + if speaker not in {"A", "B"}: + speaker = alternate if split_mode != "all_to_a" else "A" + seconds = _estimate_speech_seconds(phrase, wpm, additional_time_s) + float(padding_s) + cues.append({ + "speaker": speaker, + "start_s": round(start_frames / float(fps), 3), + "panel_index": index, + "panel_start_frames": start_frames, + "panel_frames": panel_frames, + "estimated_duration_s": round(seconds, 3), + "estimated_frames": int(math.ceil(seconds * float(fps))), + "text": phrase, + "source": "visual_segments", + "label": str(seg.get("label", seg.get("name", f"segment_{index + 1}")) or f"segment_{index + 1}"), + }) + alternate = "B" if alternate == "A" else "A" + return cues + + def plan( + self, + cine_linx, + fps, + speech_speed, + custom_wpm, + additional_time_s, + panel_padding_s, + min_panel_s, + max_panel_s, + speaker_split_mode, + timeline_update_mode, + dialogue_text=None, + timeline_data=None, + ): + out_linx = _clone_linx(cine_linx) + wpm = _speech_wpm(str(speech_speed), float(custom_wpm)) + text = str(dialogue_text or "").strip() + cues = self._cues_from_text(text, float(fps), wpm, float(additional_time_s), float(panel_padding_s), str(speaker_split_mode)) if text else [] + if not cues: + cues = self._cues_from_segments(out_linx, str(timeline_data or ""), float(fps), wpm, float(additional_time_s), float(panel_padding_s), str(speaker_split_mode)) + + panel_plan: List[Dict[str, Any]] = [] + for cue in cues: + current_panel_s = float(cue.get("panel_frames", 0) or 0) / float(fps) if cue.get("panel_frames") else 0.0 + needed_s = max(float(min_panel_s), min(float(max_panel_s), float(cue["estimated_duration_s"]))) + recommended_s = max(current_panel_s, needed_s) + panel_plan.append({ + **cue, + "current_panel_s": round(current_panel_s, 3), + "recommended_panel_s": round(recommended_s, 3), + "recommended_panel_frames": int(math.ceil(recommended_s * float(fps))), + "needs_more_time": bool(current_panel_s > 0 and recommended_s > current_panel_s + 0.001), + }) + + tts_a = "\n".join(item["text"] for item in cues if item.get("speaker") == "A") + tts_b = "\n".join(item["text"] for item in cues if item.get("speaker") == "B") + segment_lengths = ",".join(str(item["recommended_panel_frames"]) for item in panel_plan) + + resources = _resources(out_linx) + outputs = _outputs(out_linx) + payload = _payload(out_linx) + resources["cine_dialogue_cues_json"] = json.dumps(cues, ensure_ascii=False, indent=2) + resources["cine_dialogue_panel_plan_json"] = json.dumps(panel_plan, ensure_ascii=False, indent=2) + resources["cine_dialogue_tts_text_a"] = tts_a + resources["cine_dialogue_tts_text_b"] = tts_b + resources["cine_dialogue_estimated_segment_lengths"] = segment_lengths + payload["dialogue_duration_planner"] = { + "wpm": float(wpm), + "cue_count": len(cues), + "mode": str(timeline_update_mode), + } + outputs["dialogue_cues_json"] = resources["cine_dialogue_cues_json"] + + extension_report: Dict[str, Any] = {"enabled": False} + if str(timeline_update_mode) == "extend_visual_segments_in_cine_linx" and panel_plan: + segments = _visual_segments(out_linx, str(timeline_data or "")) + panel_by_index = { + int(item["panel_index"]): item + for item in panel_plan + if isinstance(item.get("panel_index"), int) + } + sorted_pairs = sorted(enumerate(segments), key=lambda pair: _safe_int(pair[1].get("start", pair[1].get("frame", 0)), 0)) + cursor = 0 + changed = False + total_frames = 0 + updates = [] + for original_index, seg in sorted_pairs: + if not isinstance(seg, dict): + continue + seg_type = str(seg.get("type", "image") or "image").strip().lower() + original_start = max(0, _safe_int(seg.get("start", seg.get("frame", 0)), 0)) + original_length = max(1, _safe_int(seg.get("length", seg.get("len", round(float(fps)))), round(float(fps)))) + new_start = max(original_start, cursor) + new_length = original_length + plan_item = panel_by_index.get(original_index) + if plan_item and seg_type != "audio": + new_length = max(original_length, int(plan_item["recommended_panel_frames"])) + seg["dialogue_estimated_frames"] = int(plan_item["estimated_frames"]) + seg["dialogue_recommended_frames"] = int(plan_item["recommended_panel_frames"]) + seg["dialogue_needs_more_time"] = bool(plan_item["needs_more_time"]) + if int(seg.get("start", seg.get("frame", 0)) or 0) != new_start: + seg["start"] = int(new_start) + changed = True + if int(seg.get("length", seg.get("len", 0)) or 0) != new_length: + seg["length"] = int(new_length) + changed = True + cursor = int(new_start + new_length) + total_frames = max(total_frames, cursor) + updates.append({ + "index": original_index, + "type": seg_type, + "old_start": original_start, + "new_start": new_start, + "old_length": original_length, + "new_length": new_length, + "changed": bool(new_start != original_start or new_length != original_length), + }) + for item in panel_plan: + idx = item.get("panel_index") + if isinstance(idx, int) and 0 <= idx < len(segments): + segments[idx]["dialogue_estimated_frames"] = int(item["estimated_frames"]) + resources["cine_visual_segments_json"] = json.dumps(segments, ensure_ascii=False, indent=2) + payload["visual_segments"] = segments + if changed and total_frames > 0: + duration_s = float(total_frames) / float(fps) + rounded_frames = _round_up_8n_plus_1(total_frames) + resources["cine_duration_seconds"] = float(duration_s) + resources["cine_max_frames"] = int(rounded_frames) + outputs["duration_seconds"] = float(duration_s) + outputs["max_frames"] = int(rounded_frames) + payload["duration_seconds"] = float(duration_s) + payload["max_frames"] = int(rounded_frames) + extension_report = { + "enabled": True, + "changed": bool(changed), + "total_frames": int(total_frames), + "duration_seconds": float(total_frames) / float(fps) if total_frames else 0.0, + "max_frames_8n_plus_1": int(_round_up_8n_plus_1(total_frames)) if total_frames else 0, + "updates": updates, + } + + _refresh_linx_index(out_linx) + report = _json_report({ + "node": "IAMCCS_CineDialogueDurationPlanner", + "shotboard_touched": False, + "wpm": float(wpm), + "cue_count": len(cues), + "mode": str(timeline_update_mode), + "extension": extension_report, + "tts_a_lines": len([x for x in tts_a.splitlines() if x.strip()]), + "tts_b_lines": len([x for x in tts_b.splitlines() if x.strip()]), + }) + return ( + out_linx, + resources["cine_dialogue_cues_json"], + tts_a, + tts_b, + resources["cine_dialogue_panel_plan_json"], + segment_lengths, + report, + ) + + +class IAMCCS_CineAudioDurationProbe: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio": ("AUDIO",), + "fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}), + "target_panel_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 600.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("FLOAT", "INT", "INT", "INT", "FLOAT", "STRING") + RETURN_NAMES = ("audio_seconds", "audio_frames", "samples", "sample_rate", "delta_vs_target_s", "report") + FUNCTION = "probe" + CATEGORY = "IAMCCS/Cine/Audio" + + def probe(self, audio, fps, target_panel_seconds): + waveform, sample_rate = _audio_waveform(audio) + samples = int(waveform.shape[-1]) + seconds = samples / float(sample_rate) + frames = int(math.ceil(seconds * float(fps))) + delta = seconds - float(target_panel_seconds) + return seconds, frames, samples, int(sample_rate), delta, _json_report({ + "node": "IAMCCS_CineAudioDurationProbe", + "audio_seconds": seconds, + "audio_frames": frames, + "samples": samples, + "sample_rate": int(sample_rate), + "target_panel_seconds": float(target_panel_seconds), + "delta_vs_target_s": delta, + }) + + +class IAMCCS_CineDialogueTimingReconciler: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "cine_linx": (SUPERNODE_LINX_TYPE,), + "dialogue_audio": ("AUDIO",), + "fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}), + "target_duration_s": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 600.0, "step": 0.01}), + "update_mode": (["report_only", "store_audio_duration", "extend_cine_duration_if_needed"], {"default": "store_audio_duration"}), + "tail_padding_s": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 30.0, "step": 0.05}), + } + } + + RETURN_TYPES = (SUPERNODE_LINX_TYPE, "FLOAT", "INT", "FLOAT", "STRING") + RETURN_NAMES = ("cine_linx", "dialogue_audio_seconds", "dialogue_audio_frames", "delta_vs_target_s", "report") + FUNCTION = "reconcile" + CATEGORY = "IAMCCS/Cine/Audio" + + def reconcile(self, cine_linx, dialogue_audio, fps, target_duration_s, update_mode, tail_padding_s): + out_linx = _clone_linx(cine_linx) + waveform, sample_rate = _audio_waveform(dialogue_audio) + seconds = int(waveform.shape[-1]) / float(sample_rate) + frames = int(math.ceil(seconds * float(fps))) + target = float(target_duration_s) + delta = seconds - target if target > 0 else seconds + resources = _resources(out_linx) + payload = _payload(out_linx) + resources["cine_dialogue_audio_duration_s"] = float(seconds) + resources["cine_dialogue_audio_frames"] = int(frames) + payload["dialogue_audio_duration_s"] = float(seconds) + if str(update_mode) == "extend_cine_duration_if_needed": + current = _safe_float(resources.get("cine_duration_seconds", payload.get("duration_seconds", target)), target) + new_duration = max(current, seconds + float(tail_padding_s)) + resources["cine_duration_seconds"] = float(new_duration) + payload["duration_seconds"] = float(new_duration) + _refresh_linx_index(out_linx) + return out_linx, float(seconds), int(frames), float(delta), _json_report({ + "node": "IAMCCS_CineDialogueTimingReconciler", + "mode": str(update_mode), + "audio_seconds": float(seconds), + "audio_frames": int(frames), + "target_duration_s": target, + "delta_vs_target_s": float(delta), + }) + + +class IAMCCS_CineWooshFoleyChunkPlanner: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "cine_linx": (SUPERNODE_LINX_TYPE,), + "fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}), + "max_duration_s": ("FLOAT", {"default": 25.0, "min": 0.1, "max": 120.0, "step": 0.1}), + "chunk_seconds": ("FLOAT", {"default": 8.0, "min": 1.0, "max": 8.0, "step": 0.1}), + "overlap_seconds": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.1}), + "seed": ("INT", {"default": 801, "min": 0, "max": 2147483647, "step": 1}), + "prompt_style": ("STRING", {"default": "cinematic foley, realistic movement, room tone, environmental ambience, object impacts", "multiline": True}), + }, + "optional": { + "visual_summary": ("STRING", {"multiline": True, "forceInput": True}), + }, + } + + RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "INT", "STRING", "STRING") + RETURN_NAMES = ("cine_linx", "chunk_ranges_json", "chunk_count", "global_foley_prompt", "report") + FUNCTION = "plan" + CATEGORY = "IAMCCS/Cine/Audio" + + def plan(self, cine_linx, fps, max_duration_s, chunk_seconds, overlap_seconds, seed, prompt_style, visual_summary=None): + out_linx = _clone_linx(cine_linx) + resources = _resources(out_linx) + payload = _payload(out_linx) + duration = _safe_float(resources.get("cine_duration_seconds", payload.get("duration_seconds", max_duration_s)), max_duration_s) + duration = min(max(0.1, duration), float(max_duration_s)) + chunk = min(8.0, max(1.0, float(chunk_seconds))) + overlap = max(0.0, min(float(overlap_seconds), chunk - 0.1)) + step = max(0.1, chunk - overlap) + ranges = [] + start = 0.0 + index = 0 + while start < duration - 0.001 and index < 32: + end = min(duration, start + chunk) + ranges.append({ + "index": index, + "start_s": round(start, 3), + "end_s": round(end, 3), + "start_frame": int(round(start * float(fps))), + "end_frame": int(round(end * float(fps))), + "duration_s": round(end - start, 3), + "seed": int(seed) + index, + }) + if end >= duration: + break + start += step + index += 1 + prompt = str(prompt_style or "").strip() + if str(visual_summary or "").strip(): + prompt = f"{prompt}, {str(visual_summary).strip()}" + resources["cine_woosh_foley_chunks_json"] = json.dumps(ranges, ensure_ascii=False, indent=2) + resources["cine_woosh_foley_prompt"] = prompt + payload["woosh_foley"] = {"chunk_count": len(ranges), "chunk_seconds": chunk, "overlap_seconds": overlap} + _refresh_linx_index(out_linx) + return out_linx, resources["cine_woosh_foley_chunks_json"], int(len(ranges)), prompt, _json_report({ + "node": "IAMCCS_CineWooshFoleyChunkPlanner", + "duration_s": duration, + "chunk_count": len(ranges), + "chunk_seconds": chunk, + "overlap_seconds": overlap, + "max_single_woosh_seconds": 8.0, + }) + + +class IAMCCS_CineFinalAudioMixer: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "dialogue_audio": ("AUDIO",), + "dialogue_gain_db": ("FLOAT", {"default": 0.0, "min": -60.0, "max": 24.0, "step": 0.1}), + "foley_gain_db": ("FLOAT", {"default": -6.0, "min": -60.0, "max": 24.0, "step": 0.1}), + "normalize_peak": ("BOOLEAN", {"default": True}), + }, + "optional": { + "foley_audio": ("AUDIO",), + }, + } + + RETURN_TYPES = ("AUDIO", "STRING") + RETURN_NAMES = ("final_audio", "report") + FUNCTION = "mix" + CATEGORY = "IAMCCS/Cine/Audio" + + def mix(self, dialogue_audio, dialogue_gain_db, foley_gain_db, normalize_peak, foley_audio=None): + dialogue_wave, sr = _audio_waveform(dialogue_audio) + dialogue_wave = _gain(dialogue_wave.detach().clone(), float(dialogue_gain_db)) + if foley_audio is None: + mixed = dialogue_wave + foley_samples = 0 + else: + foley_wave, foley_sr = _audio_waveform(foley_audio) + if int(foley_sr) != int(sr): + raise ValueError(f"Sample rates do not match: dialogue={sr}, foley={foley_sr}") + foley_wave = _gain(foley_wave.detach().clone(), float(foley_gain_db)) + total = max(int(dialogue_wave.shape[-1]), int(foley_wave.shape[-1])) + channels = max(int(dialogue_wave.shape[1]), int(foley_wave.shape[1])) + dialogue_wave = _ensure_channels(_pad_to(dialogue_wave, total), channels) + foley_wave = _ensure_channels(_pad_to(foley_wave, total), channels) + mixed = dialogue_wave + foley_wave + foley_samples = int(foley_wave.shape[-1]) + peak = float(mixed.abs().max().item()) if mixed.numel() else 0.0 + if bool(normalize_peak) and peak > 1.0: + mixed = mixed / peak + out = {"waveform": mixed, "sample_rate": int(sr)} + return out, _json_report({ + "node": "IAMCCS_CineFinalAudioMixer", + "sample_rate": int(sr), + "dialogue_samples": int(dialogue_wave.shape[-1]), + "foley_samples": int(foley_samples), + "peak_before_normalize": peak, + "normalized": bool(normalize_peak and peak > 1.0), + }) + + +class IAMCCS_CineEmotionButtons: + EMOTIONS = { + "calm": "calm, intimate, controlled breathing, soft eyes", + "fear": "fearful, tense, shallow breathing, guarded expression", + "anger": "angry, clipped diction, tight jaw, contained intensity", + "sad": "sad, fragile voice, lowered gaze, restrained emotion", + "joy": "warm joy, brighter voice, open face, light movement", + "shock": "shocked, broken rhythm, widened eyes, sudden breath", + "whisper": "whispered, close-mic intimacy, low breath, delicate diction", + "urgent": "urgent, fast intent, focused eyes, pressured delivery", + "wonder": "quiet wonder, softened voice, attentive gaze, slow breath", + "resolve": "resolved, steady voice, grounded posture, clear intention", + } + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "selected_emotions": ("STRING", {"default": "", "multiline": True}), + "intensity": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.01}), + "target": (["performance_and_voice", "voice_only", "visual_performance"], {"default": "performance_and_voice"}), + } + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("emotion_prompt", "voice_prompt", "visual_prompt", "report") + FUNCTION = "build" + CATEGORY = "IAMCCS/Cine/Prompt Helpers" + + def build(self, selected_emotions, intensity, target): + raw = str(selected_emotions or "").strip() + parts = [] + for token in re.split(r"[,;\n]+", raw): + key = token.strip().lower().replace(" ", "_") + if not key: + continue + parts.append(self.EMOTIONS.get(key, token.strip())) + prompt = ", ".join(dict.fromkeys(parts)) + voice = prompt if str(target) in {"performance_and_voice", "voice_only"} else "" + visual = prompt if str(target) in {"performance_and_voice", "visual_performance"} else "" + if prompt: + prompt = f"{prompt}, emotional intensity {float(intensity):.2f}" + return prompt, voice, visual, _json_report({ + "node": "IAMCCS_CineEmotionButtons", + "target": str(target), + "intensity": float(intensity), + "button_ui": "web/iamccs_cine_audio_dialogue_ui.js", + }) + + +class IAMCCS_CineDialoguePromptKit: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "subject_identity": ("STRING", {"default": "the same main character, stable identity", "multiline": True}), + "place": ("STRING", {"default": "a grounded cinematic location", "multiline": True}), + "visual_style": ("STRING", {"default": "cinematic realism, coherent lighting, physical camera motion", "multiline": True}), + "camera_language": ("STRING", {"default": "controlled camera movement, readable blocking", "multiline": True}), + "story_action": ("STRING", {"default": "the character performs one clear continuous action", "multiline": True}), + "emotion_prompt": ("STRING", {"default": "", "multiline": True}), + "dialogue_a": ("STRING", {"default": "", "multiline": True}), + "dialogue_b": ("STRING", {"default": "", "multiline": True}), + "duration_seconds": ("FLOAT", {"default": 8.0, "min": 0.1, "max": 600.0, "step": 0.1}), + "frame_rate": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 1.0}), + "beat_count": ("INT", {"default": 3, "min": 1, "max": 8, "step": 1}), + }, + "optional": { + "local_prompt_1": ("STRING", {"multiline": True, "forceInput": True}), + "local_prompt_2": ("STRING", {"multiline": True, "forceInput": True}), + "local_prompt_3": ("STRING", {"multiline": True, "forceInput": True}), + "local_prompt_4": ("STRING", {"multiline": True, "forceInput": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("global_prompt", "local_prompts", "segment_lengths", "dialogue_script", "shotboard_timeline_json", "markdown_preview", "report") + FUNCTION = "build" + CATEGORY = "IAMCCS/Cine/Prompt Helpers" + + def build( + self, + subject_identity, + place, + visual_style, + camera_language, + story_action, + emotion_prompt, + dialogue_a, + dialogue_b, + duration_seconds, + frame_rate, + beat_count, + local_prompt_1="", + local_prompt_2="", + local_prompt_3="", + local_prompt_4="", + ): + locals_in = [str(x or "").strip() for x in (local_prompt_1, local_prompt_2, local_prompt_3, local_prompt_4)] + local_prompts = [x for x in locals_in if x] + while len(local_prompts) < int(beat_count): + local_prompts.append(str(story_action or "the scene continues with coherent cinematic motion")) + local_prompts = local_prompts[: int(beat_count)] + total_frames = max(1, int(round(float(duration_seconds) * float(frame_rate)))) + base = total_frames // max(1, int(beat_count)) + lengths = [base] * int(beat_count) + lengths[-1] += total_frames - sum(lengths) + global_prompt = ", ".join(part.strip() for part in ( + subject_identity, + place, + visual_style, + camera_language, + story_action, + emotion_prompt, + "dialogue is synchronized when present", + ) if str(part or "").strip()) + dialogue_lines = [] + if str(dialogue_a or "").strip(): + dialogue_lines.append(f"A|0.0|{str(dialogue_a).strip()}") + if str(dialogue_b or "").strip(): + dialogue_lines.append(f"B|0.0|{str(dialogue_b).strip()}") + segments = [] + cursor = 0 + for idx, (prompt, length) in enumerate(zip(local_prompts, lengths), start=1): + segments.append({ + "id": f"prompt_{idx}", + "type": "text", + "start": int(cursor), + "length": int(length), + "label": f"prompt_{idx}", + "prompt": prompt, + "use_prompt": True, + "use_guide": False, + }) + cursor += int(length) + timeline = { + "schema": "iamccs.cine.dialogue_prompt_kit", + "frame_rate": float(frame_rate), + "duration_seconds": float(duration_seconds), + "segments": segments, + } + markdown = "\n".join([ + "# IAMCCS Dialogue Prompt Kit", + "", + "## Global Prompt", + global_prompt, + "", + "## Local Prompts", + " | ".join(local_prompts), + "", + "## Dialogue", + "\n".join(dialogue_lines), + ]) + return ( + global_prompt, + " | ".join(local_prompts), + ",".join(str(int(x)) for x in lengths), + "\n".join(dialogue_lines), + json.dumps(timeline, ensure_ascii=False, indent=2), + markdown, + _json_report({ + "node": "IAMCCS_CineDialoguePromptKit", + "beat_count": int(beat_count), + "duration_seconds": float(duration_seconds), + "frame_rate": float(frame_rate), + "dialogue_a": bool(str(dialogue_a or "").strip()), + "dialogue_b": bool(str(dialogue_b or "").strip()), + }), + ) + + +class IAMCCS_BoardMaker_DialogueFoley: + """Dialogue-first board writer with foley metadata carried through cine_linx.""" + + DEFAULT_DIALOGUE_DATA = json.dumps({ + "lines": [ + { + "speaker": "A", + "seconds": 4.0, + "ref": 1, + "label": "campo_A", + "framing": "medium close-up", + "dialogue": "Non guardare la finestra. Se capisce che l'abbiamo visto, siamo finiti.", + "voice": "low tense voice, almost whispered", + "local_prompt": "man A in field shot, tense medium close-up, restrained fear, coherent eyeline", + "foley": "soft breathing, cloth rustle, distant room tone", + }, + { + "speaker": "B", + "seconds": 3.5, + "ref": 2, + "label": "controcampo_B", + "framing": "reverse angle", + "dialogue": "L'ha gia capito. Sta aspettando che uno di noi faccia il primo passo.", + "voice": "controlled low voice, guarded", + "local_prompt": "man B in reverse field shot, listening then answering, controlled fear", + "foley": "small chair creak, quiet breath, low room pressure", + }, + { + "speaker": "A", + "seconds": 3.5, + "ref": 1, + "label": "ritorno_A", + "framing": "close-up", + "dialogue": "Allora il primo passo lo fai tu, lentamente, verso l'uscita.", + "voice": "firm whisper, urgent but contained", + "local_prompt": "return to man A close-up, slight push-in, decision in the eyes", + "foley": "subtle foot shift, jacket movement, controlled breath", + }, + { + "speaker": "B", + "seconds": 4.0, + "ref": 2, + "label": "chiusura_B", + "framing": "reverse close-up", + "dialogue": "No. Se esco da solo, tu resti qui a spiegargli dove siamo.", + "voice": "dry, bitter, quietly direct", + "local_prompt": "man B reverse close-up, hard pause before the last words, tense eye line", + "foley": "distant door tension, faint wood creak, breath held", + }, + ] + }, indent=2, ensure_ascii=False) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "board_name": ("STRING", {"default": "dialogue_foley_board"}), + "global_prompt": ("STRING", { + "default": "Two men in a tense field and reverse-field dialogue scene, stable identity, coherent eyelines, cinematic realism, restrained camera movement.", + "multiline": True, + }), + "scene_context": ("STRING", { + "default": "A quiet interior at night. The two men speak in low voices while something unseen waits beyond the room.", + "multiline": True, + }), + "foley_prompt": ("STRING", { + "default": "realistic cinematic foley, tense room tone, male breathing, cloth movement, subtle chair creaks, distant door pressure, no music", + "multiline": True, + }), + "dialogue_data": ("STRING", { + "default": cls.DEFAULT_DIALOGUE_DATA, + "multiline": True, + "tooltip": "Edited by the IAMCCS Dialogue/Foley BoardMaker UI. JSON lines: speaker, seconds, ref, label, dialogue, voice, local_prompt, foley.", + }), + "frame_rate": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 1.0}), + "image_width": ("INT", {"default": 1280, "min": 64, "max": 8192, "step": 32}), + "image_height": ("INT", {"default": 736, "min": 64, "max": 8192, "step": 32}), + "default_force": ("FLOAT", {"default": 0.65, "min": 0.0, "max": 1.0, "step": 0.01}), + "guide_policy": (["every_checked_row", "safe_core_guides", "prompt_only"], {"default": "every_checked_row"}), + "woosh_chunk_seconds": ("FLOAT", {"default": 8.0, "min": 1.0, "max": 8.0, "step": 0.1}), + "woosh_overlap_seconds": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.1}), + }, + "optional": { + "cine_linx": (SUPERNODE_LINX_TYPE,), + }, + } + + RETURN_TYPES = (SUPERNODE_LINX_TYPE,) + RETURN_NAMES = ("cine_linx",) + FUNCTION = "build" + CATEGORY = "IAMCCS/Cine/01 Prompting" + + @staticmethod + def _clean(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "").strip()) + + @classmethod + def _inject_speech_token(cls, text: Any, dialogue: Any) -> str: + value = cls._clean(text) + speech = cls._clean(dialogue) + for token in ("", "{speech1}", "[speech1]", "", "{Transcript1}", "[Transcript1]"): + value = value.replace(token, speech) + return value + + @classmethod + def _dialogue_lines(cls, dialogue_data: Any) -> List[Dict[str, Any]]: + data = _safe_json_loads(dialogue_data, {}) + raw = data if isinstance(data, list) else data.get("lines", []) if isinstance(data, dict) else [] + if not isinstance(raw, list): + raw = [] + lines: List[Dict[str, Any]] = [] + for index, item in enumerate(raw): + if not isinstance(item, dict): + continue + speaker = cls._clean(item.get("speaker", "A")).upper()[:1] + if speaker not in {"A", "B"}: + speaker = "A" if index % 2 == 0 else "B" + dialogue = cls._clean(item.get("dialogue", item.get("text", ""))) + local_prompt = cls._clean(item.get("local_prompt", item.get("prompt", ""))) + if not dialogue and not local_prompt: + continue + seconds = max(0.25, _safe_float(item.get("seconds", item.get("duration", 3.0)), 3.0)) + lines.append({ + "speaker": speaker, + "seconds": seconds, + "ref": max(1, _safe_int(item.get("ref", 1 if speaker == "A" else 2), 1 if speaker == "A" else 2)), + "label": _normalise_label(cls._clean(item.get("label", "")), f"{'campo_A' if speaker == 'A' else 'controcampo_B'}_{index + 1}"), + "framing": cls._clean(item.get("framing", "medium close-up" if speaker == "A" else "reverse angle")), + "dialogue": dialogue, + "voice": cls._clean(item.get("voice", item.get("voice_direction", ""))), + "local_prompt": local_prompt or f"speaker {speaker} dialogue performance, coherent eyeline, natural facial movement", + "foley": cls._clean(item.get("foley", "")), + }) + return lines + + @staticmethod + def _chunks(duration: float, chunk: float, overlap: float) -> List[Dict[str, float]]: + ranges = [] + start = 0.0 + idx = 1 + chunk = max(1.0, min(8.0, float(chunk))) + overlap = max(0.0, min(float(overlap), chunk - 0.1)) + while start < duration - 0.001 and idx <= 64: + end = min(duration, start + chunk) + ranges.append({"index": idx, "start_s": round(start, 3), "end_s": round(end, 3), "duration_s": round(end - start, 3)}) + if end >= duration: + break + start = max(start + 0.1, end - overlap) + idx += 1 + return ranges + + def build( + self, + board_name, + global_prompt, + scene_context, + foley_prompt, + dialogue_data, + frame_rate, + image_width, + image_height, + default_force, + guide_policy, + woosh_chunk_seconds, + woosh_overlap_seconds, + cine_linx=None, + ): + fps = max(1.0, float(frame_rate)) + lines = self._dialogue_lines(dialogue_data) + if not lines: + lines = self._dialogue_lines(self.DEFAULT_DIALOGUE_DATA) + duration = sum(float(item["seconds"]) for item in lines) + cursor_frames = 0 + segments: List[Dict[str, Any]] = [] + rows: List[Dict[str, Any]] = [] + dialogue_tracks: List[Dict[str, Any]] = [] + local_prompts: List[str] = [] + segment_lengths: List[int] = [] + for index, item in enumerate(lines): + length = max(1, int(round(float(item["seconds"]) * fps))) + start_s = cursor_frames / fps + prompt_parts = [ + item["local_prompt"], + item["framing"], + str(scene_context or "").strip(), + f'{item["speaker"]}: "{item["dialogue"]}"' if item["dialogue"] else "", + item["voice"], + ] + local_prompt = ", ".join(part for part in (self._clean(p) for p in prompt_parts) if part) + local_prompts.append(local_prompt) + segment_lengths.append(length) + dialogue_text = f'{item["speaker"]}: "{item["dialogue"]}"' if item["dialogue"] else "" + segment = { + "id": f"dlg_{index + 1:02d}_{item['speaker'].lower()}", + "type": "image", + "start": int(cursor_frames), + "length": int(length), + "ref": int(item["ref"]), + "label": item["label"], + "prompt": local_prompt, + "note": item["foley"], + "camera": item["framing"], + "transition": "hard_cut" if index else "opening_cut", + "guideStrength": float(default_force), + "imageLockStrength": float(default_force), + "defaultForceSource": float(default_force), + "forceCustom": True, + "use_guide": True, + "use_prompt": True, + "dialogue_pin": bool(item["dialogue"]), + "speaker": item["speaker"], + "dialogue": dialogue_text, + "audio_or_dialogue": dialogue_text, + "voice_direction": item["voice"], + "foley_prompt": item["foley"], + "step_transition_enabled": False, + "step_transition_type": "off", + "step_transition_prompt": "", + "step_transition_duration": 0, + "step_transition_arrival": "auto", + "step_transition_auto_fit": True, + } + segments.append(segment) + rows.append({ + "second": round(start_s, 3), + "frame": int(cursor_frames), + "ref": int(item["ref"]), + "force": float(default_force), + "image_lock_strength": float(default_force), + "label": item["label"], + "camera": item["framing"], + "transition": segment["transition"], + "note": item["foley"], + "relay_prompt": local_prompt, + "use_guide": True, + "use_prompt": True, + "dialogue_pin": bool(item["dialogue"]), + "speaker": item["speaker"], + "dialogue": dialogue_text, + "voice_direction": item["voice"], + "duration": float(item["seconds"]), + }) + dialogue_tracks.append({ + "speaker": item["speaker"], + "start_s": round(start_s, 3), + "duration_s": round(float(item["seconds"]), 3), + "ref": int(item["ref"]), + "label": item["label"], + "dialogue": item["dialogue"], + "voice_direction": item["voice"], + }) + cursor_frames += length + + timeline = { + "schema": "iamccs.cine.filmmaker_timeline", + "schema_version": 2, + "flfrealMode": "iamccs_enhanced", + "flfreal_mode": "iamccs_enhanced", + "global_prompt_only": False, + "use_global_prompt_only": False, + "promptrelay_enabled": True, + "use_custom_audio": False, + "audioSyncMode": "timeline_audio", + "generationStrategy": "single_timeline", + "duration_seconds": round(duration, 3), + "frame_rate": fps, + "image_width": int(image_width), + "image_height": int(image_height), + "image_resize_method": "crop", + "image_multiple_of": 32, + "promptrelay_epsilon": 0.6, + "audioTrackCount": 2, + "segments": segments, + "audioSegments": [], + "rows": rows, + "director_local_prompts": " | ".join(local_prompts), + "director_segment_lengths": ",".join(str(x) for x in segment_lengths), + "local_prompts": " | ".join(local_prompts), + "segment_lengths": ",".join(str(x) for x in segment_lengths), + "dialogue_tracks": dialogue_tracks, + "foley_prompt": str(foley_prompt or ""), + } + timeline_data = json.dumps(timeline, ensure_ascii=False, indent=2) + board = { + "metadata": { + "schema": "iamccs.cine.dialogue_foley_board", + "schema_version": 1, + "node_type": "IAMCCS_BoardMaker_DialogueFoley", + "board_name": str(board_name or "dialogue_foley_board"), + "image_storage": "manual_after_import", + "notes": "Dialogue/Foley board. Import into Shotboard V3, then add reference images manually.", + }, + "global_prompt": str(global_prompt or ""), + "prompt": str(global_prompt or ""), + "timeline_data": timeline_data, + "timeline": timeline, + "rows": rows, + "settings": { + "duration_seconds": round(duration, 3), + "frame_rate": fps, + "guide_policy": str(guide_policy), + "min_guide_gap_seconds": 0.0, + "max_guides": len(rows), + "default_force": float(default_force), + "promptrelay_epsilon": 0.6, + "ltx_round_mode": "up_8n_plus_1", + "image_width": int(image_width), + "image_height": int(image_height), + "image_resize_method": "crop", + "image_multiple_of": 32, + "img_compression": 0, + }, + "duration_seconds": round(duration, 3), + "frame_rate": fps, + "image_width": int(image_width), + "image_height": int(image_height), + "image_paths": "", + "images": [], + } + board_json = json.dumps(board, ensure_ascii=False, indent=2) + tts_a = "\n".join(item["dialogue"] for item in dialogue_tracks if item["speaker"] == "A") + tts_b = "\n".join(item["dialogue"] for item in dialogue_tracks if item["speaker"] == "B") + dialogue_script = "\n".join(f'{item["speaker"]}: "{item["dialogue"]}"' for item in dialogue_tracks) + chunks = self._chunks(duration, float(woosh_chunk_seconds), float(woosh_overlap_seconds)) + + out_linx = _clone_linx(cine_linx) + resources = _resources(out_linx) + outputs = _outputs(out_linx) + payload = _payload(out_linx) + payload.update({ + "boardmaker_dialogue_foley": True, + "board_json": board_json, + "timeline_data": timeline_data, + "global_prompt": str(global_prompt or ""), + "duration_seconds": round(duration, 3), + "frame_rate": fps, + "dialogue_tracks": dialogue_tracks, + "foley_prompt": str(foley_prompt or ""), + "woosh_chunks": chunks, + }) + resources.update({ + "cine_board_json": board_json, + "cine_board_timeline_data": timeline_data, + "cine_payload": payload, + "cine_global_prompt": str(global_prompt or ""), + "cine_duration_seconds": round(duration, 3), + "cine_frame_rate": fps, + "cine_image_width": int(image_width), + "cine_image_height": int(image_height), + "cine_visual_segments_json": json.dumps(segments, ensure_ascii=False), + "cine_dialogue_tracks": dialogue_tracks, + "cine_script_layers": {"dialogue_script": dialogue_script, "tts_text_a": tts_a, "tts_text_b": tts_b}, + "cine_audio_tracks": {"foley_prompt": str(foley_prompt or ""), "woosh_chunks": chunks}, + "cine_helper_patches": {"source": "IAMCCS_BoardMaker_DialogueFoley", "import_into": "Shotboard V3"}, + "cine_group_metadata": {"board_name": str(board_name or ""), "line_count": len(lines)}, + }) + outputs.update({ + "board_json": board_json, + "timeline_data": timeline_data, + "dialogue_script": dialogue_script, + "tts_text_a": tts_a, + "tts_text_b": tts_b, + "foley_prompt": str(foley_prompt or ""), + "duration_seconds": round(duration, 3), + }) + out_linx["type"] = SUPERNODE_LINX_TYPE + out_linx["mode"] = "iamccs_boardmaker_dialogue_foley" + out_linx.setdefault("chain", []).append({"role": "board_writer", "name": "IAMCCS_BoardMaker_DialogueFoley"}) + out_linx.setdefault("stages", []).append({"name": "dialogue_foley_board", "kind": "board_writer", "payload": payload}) + _refresh_linx_index(out_linx) + + report = _json_report({ + "node": "IAMCCS_BoardMaker_DialogueFoley", + "line_count": len(lines), + "duration_seconds": round(duration, 3), + "frame_rate": fps, + "outputs": ["cine_linx"], + "cine_info_3": "Connect cine_linx to IAMCCS_CineInfo3 to expose board_json, timeline_data, dialogue_script, TTS text A/B and foley data.", + "truth": "Small architecture node: writes a Shotboard V3-importable dialogue/foley board and carries the same data through cine_linx.", + }) + resources["cine_report"] = report + outputs["report"] = report + _refresh_linx_index(out_linx) + return (out_linx,) + + +class IAMCCS_CineInfo3: + """Expose BoardMaker dialogue/foley metadata from cine_linx without cluttering the writer node.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "cine_linx": (SUPERNODE_LINX_TYPE,), + } + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "FLOAT", "STRING") + RETURN_NAMES = ( + "board_json", + "timeline_data", + "dialogue_script", + "tts_text_a", + "tts_text_b", + "foley_prompt", + "woosh_chunks_json", + "duration_seconds", + "report", + ) + FUNCTION = "extract" + CATEGORY = "IAMCCS/Cine/Info" + + def extract(self, cine_linx): + linx = _clone_linx(cine_linx) + resources = _resources(linx) + outputs = _outputs(linx) + payload = _payload(linx) + scripts = resources.get("cine_script_layers") if isinstance(resources.get("cine_script_layers"), dict) else {} + audio_tracks = resources.get("cine_audio_tracks") if isinstance(resources.get("cine_audio_tracks"), dict) else {} + + board_json = str(outputs.get("board_json") or resources.get("cine_board_json") or payload.get("board_json") or "") + timeline_data = str(outputs.get("timeline_data") or resources.get("cine_board_timeline_data") or payload.get("timeline_data") or "") + dialogue_script = str(outputs.get("dialogue_script") or scripts.get("dialogue_script") or "") + tts_text_a = str(outputs.get("tts_text_a") or scripts.get("tts_text_a") or resources.get("cine_dialogue_tts_text_a") or "") + tts_text_b = str(outputs.get("tts_text_b") or scripts.get("tts_text_b") or resources.get("cine_dialogue_tts_text_b") or "") + foley_prompt = str(outputs.get("foley_prompt") or audio_tracks.get("foley_prompt") or payload.get("foley_prompt") or resources.get("cine_woosh_foley_prompt") or "") + woosh_chunks = audio_tracks.get("woosh_chunks") or payload.get("woosh_chunks") or _safe_json_loads(resources.get("cine_woosh_foley_chunks_json"), []) + duration = _safe_float(outputs.get("duration_seconds", resources.get("cine_duration_seconds", payload.get("duration_seconds", 0.0))), 0.0) + woosh_chunks_json = json.dumps(woosh_chunks if isinstance(woosh_chunks, list) else [], ensure_ascii=False, indent=2) + report = _json_report({ + "node": "IAMCCS_CineInfo3", + "source_mode": linx.get("mode", ""), + "resource_keys": sorted(resources.keys()), + "has_board_json": bool(board_json), + "has_timeline_data": bool(timeline_data), + "duration_seconds": duration, + "woosh_chunk_count": len(_safe_json_loads(woosh_chunks_json, [])), + }) + return board_json, timeline_data, dialogue_script, tts_text_a, tts_text_b, foley_prompt, woosh_chunks_json, float(duration), report + + +class IAMCCS_CineSpeech1PromptCompiler: + """Compile BoardMaker dialogue rows by replacing inside local prompts.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "cine_linx": (SUPERNODE_LINX_TYPE,), + "speech_token": ("STRING", {"default": ""}), + "append_scene_context": ("BOOLEAN", {"default": True}), + "append_voice_style": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "STRING", "STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("cine_linx", "global_prompt", "local_prompts", "segment_lengths", "dialogue_script", "foley_prompt", "report") + FUNCTION = "compile" + CATEGORY = "IAMCCS/Cine/01 Prompting" + + @staticmethod + def _text(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "").strip()) + + @classmethod + def _replace_token(cls, text: Any, speech: Any, token: str) -> str: + value = cls._text(text) + speech_value = cls._text(speech) + tokens = [str(token or ""), "", "{speech1}", "[speech1]", "", "{Transcript1}", "[Transcript1]"] + for item in dict.fromkeys(tokens): + value = value.replace(item, speech_value) + return value + + @staticmethod + def _timeline_from_linx(linx: Dict[str, Any]) -> Dict[str, Any]: + resources = _resources(linx) + outputs = _outputs(linx) + payload = _payload(linx) + for value in ( + outputs.get("timeline_data"), + resources.get("cine_board_timeline_data"), + payload.get("timeline_data"), + ): + data = _safe_json_loads(value, {}) + if isinstance(data, dict): + return data + return {} + + @staticmethod + def _dialogue_from_linx(linx: Dict[str, Any], timeline: Dict[str, Any]) -> List[Dict[str, Any]]: + resources = _resources(linx) + payload = _payload(linx) + raw = resources.get("cine_dialogue_tracks") + if not isinstance(raw, list): + raw = payload.get("dialogue_tracks") + if not isinstance(raw, list): + raw = timeline.get("dialogue_tracks") + if not isinstance(raw, list): + raw = timeline.get("dialogue_lines") + if not isinstance(raw, list): + raw = [] + rows = timeline.get("rows") if isinstance(timeline.get("rows"), list) else [] + result = [] + for index, item in enumerate(raw): + if not isinstance(item, dict): + continue + row = rows[index] if index < len(rows) and isinstance(rows[index], dict) else {} + result.append({ + "speaker": str(item.get("speaker", row.get("speaker", "A" if index % 2 == 0 else "B"))).upper()[:1], + "dialogue": str(item.get("dialogue", row.get("dialogue", ""))).strip().strip('"'), + "start_s": _safe_float(item.get("start_s", row.get("second", 0.0)), 0.0), + "duration_s": _safe_float(item.get("duration_s", row.get("duration", item.get("seconds", 3.0))), 3.0), + "ref": _safe_int(item.get("ref", row.get("ref", 1)), 1), + "label": str(item.get("label", row.get("label", f"line_{index + 1}"))), + "voice_direction": str(item.get("voice_direction", item.get("voice", row.get("voice_direction", "")))), + }) + if result: + return result + for index, row in enumerate(rows): + if not isinstance(row, dict): + continue + dialogue = str(row.get("dialogue", "")).strip().strip('"') + result.append({ + "speaker": str(row.get("speaker", "A" if index % 2 == 0 else "B")).upper()[:1], + "dialogue": dialogue, + "start_s": _safe_float(row.get("second", 0.0), 0.0), + "duration_s": _safe_float(row.get("duration", 3.0), 3.0), + "ref": _safe_int(row.get("ref", 1), 1), + "label": str(row.get("label", f"line_{index + 1}")), + "voice_direction": str(row.get("voice_direction", "")), + }) + return result + + def compile(self, cine_linx, speech_token="", append_scene_context=True, append_voice_style=True): + linx = _clone_linx(cine_linx) + resources = _resources(linx) + outputs = _outputs(linx) + payload = _payload(linx) + timeline = self._timeline_from_linx(linx) + rows = timeline.get("rows") if isinstance(timeline.get("rows"), list) else [] + segments = timeline.get("segments") if isinstance(timeline.get("segments"), list) else [] + dialogue = self._dialogue_from_linx(linx, timeline) + global_prompt = str(outputs.get("global_prompt") or resources.get("cine_global_prompt") or payload.get("global_prompt") or timeline.get("cine_global_prompt") or "") + scene_context = str(payload.get("scene_context") or timeline.get("scene_context") or "") + audio_resource = resources.get("cine_audio_tracks") if isinstance(resources.get("cine_audio_tracks"), dict) else {} + foley_prompt = str(outputs.get("foley_prompt") or audio_resource.get("foley_prompt") or "") + if not foley_prompt: + foley_prompt = str(payload.get("foley_prompt") or timeline.get("foley_prompt") or "") + fps = max(1.0, _safe_float(timeline.get("frame_rate", resources.get("cine_frame_rate", payload.get("frame_rate", 24))), 24.0)) + + compiled_prompts: List[str] = [] + segment_lengths: List[int] = [] + compiled_tracks: List[Dict[str, Any]] = [] + for index, item in enumerate(dialogue): + row = rows[index] if index < len(rows) and isinstance(rows[index], dict) else {} + segment = segments[index] if index < len(segments) and isinstance(segments[index], dict) else {} + base_prompt = row.get("relay_prompt") or row.get("local_prompt") or row.get("prompt") or segment.get("prompt") or "" + compiled = self._replace_token(base_prompt, item.get("dialogue", ""), str(speech_token or "")) + extras = [] + if bool(append_scene_context) and scene_context: + extras.append(scene_context) + if bool(append_voice_style) and item.get("voice_direction"): + extras.append(str(item.get("voice_direction"))) + if extras: + compiled = ", ".join(part for part in [compiled, *[self._text(v) for v in extras]] if part) + compiled_prompts.append(compiled) + duration_s = max(0.01, _safe_float(item.get("duration_s"), 3.0)) + frames = max(1, int(round(duration_s * fps))) + segment_lengths.append(frames) + item = dict(item) + item["local_prompt_compiled"] = compiled + item["duration_s"] = round(duration_s, 3) + item["frames"] = frames + compiled_tracks.append(item) + if index < len(rows) and isinstance(rows[index], dict): + rows[index]["relay_prompt"] = compiled + rows[index]["local_prompt"] = compiled + rows[index]["prompt"] = compiled + rows[index]["use_prompt"] = True + rows[index]["dialogue"] = f'{item.get("speaker", "")}: "{item.get("dialogue", "")}"'.strip() + if index < len(segments) and isinstance(segments[index], dict): + segments[index]["prompt"] = compiled + segments[index]["dialogue"] = f'{item.get("speaker", "")}: "{item.get("dialogue", "")}"'.strip() + segments[index]["length"] = frames + + if rows: + timeline["rows"] = rows + if segments: + timeline["segments"] = segments + local_prompts = " | ".join(compiled_prompts) + segment_lengths_s = ",".join(str(item) for item in segment_lengths) + dialogue_script = "\n".join(f'{item.get("speaker", "")}: "{item.get("dialogue", "")}"' for item in compiled_tracks) + timeline.update({ + "cine_global_prompt": global_prompt, + "global_prompt": global_prompt, + "promptrelay_enabled": bool(local_prompts.strip()), + "director_local_prompts": local_prompts, + "director_segment_lengths": segment_lengths_s, + "local_prompts": local_prompts, + "segment_lengths": segment_lengths_s, + "dialogue_tracks": compiled_tracks, + "foley_prompt": foley_prompt, + }) + timeline_data = json.dumps(timeline, ensure_ascii=False, indent=2) + board = _safe_json_loads(outputs.get("board_json") or resources.get("cine_board_json") or payload.get("board_json"), {}) + if isinstance(board, dict): + board["global_prompt"] = global_prompt + board["prompt"] = global_prompt + board["timeline_data"] = timeline_data + board["timeline"] = timeline + board["rows"] = rows + board_json = json.dumps(board, ensure_ascii=False, indent=2) + else: + board_json = "" + + scripts = {"dialogue_script": dialogue_script} + for speaker in ("A", "B"): + scripts[f"tts_text_{speaker.lower()}"] = "\n".join(item.get("dialogue", "") for item in compiled_tracks if item.get("speaker") == speaker) + resources.update({ + "cine_board_json": board_json, + "cine_board_timeline_data": timeline_data, + "cine_global_prompt": global_prompt, + "cine_local_prompts": local_prompts, + "cine_segment_lengths": segment_lengths_s, + "cine_dialogue_tracks": compiled_tracks, + "cine_script_layers": scripts, + "cine_speech1_compiled": True, + }) + audio_tracks = resources.get("cine_audio_tracks") if isinstance(resources.get("cine_audio_tracks"), dict) else {} + audio_tracks["foley_prompt"] = foley_prompt + resources["cine_audio_tracks"] = audio_tracks + payload.update({ + "board_json": board_json, + "timeline_data": timeline_data, + "global_prompt": global_prompt, + "local_prompts": local_prompts, + "segment_lengths": segment_lengths_s, + "dialogue_tracks": compiled_tracks, + "foley_prompt": foley_prompt, + }) + outputs.update({ + "board_json": board_json, + "timeline_data": timeline_data, + "global_prompt": global_prompt, + "local_prompts": local_prompts, + "segment_lengths": segment_lengths_s, + "dialogue_script": dialogue_script, + "foley_prompt": foley_prompt, + }) + linx["mode"] = "iamccs_speech1_compiled" + linx.setdefault("chain", []).append({"role": "speech1_compiler", "name": "IAMCCS_CineSpeech1PromptCompiler", "token": str(speech_token or "")}) + report = _json_report({ + "node": "IAMCCS_CineSpeech1PromptCompiler", + "token": str(speech_token or ""), + "compiled_lines": len(compiled_tracks), + "local_prompts": len([p for p in compiled_prompts if p]), + "segment_lengths": segment_lengths_s, + "truth": "This is the inject node: BoardMaker writes dialogue and raw local prompts; this node compiles them into Shotboard/TTS/Woosh cine_linx.", + }) + resources["cine_report"] = report + outputs["report"] = report + _refresh_linx_index(linx) + return linx, global_prompt, local_prompts, segment_lengths_s, dialogue_script, foley_prompt, report + + +class IAMCCS_CineDialogueLineRouter: + """Split dialogue/foley board metadata into queue-ready line prompts.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "cine_linx": (SUPERNODE_LINX_TYPE,), + "line_fallback": ("STRING", {"default": "", "multiline": True}), + } + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("line_1_text", "line_2_text", "line_3_text", "line_4_text", "foley_prompt", "report") + FUNCTION = "route" + CATEGORY = "IAMCCS/Cine/Audio" + + def route(self, cine_linx, line_fallback=""): + linx = _clone_linx(cine_linx) + resources = _resources(linx) + tracks = resources.get("cine_dialogue_tracks") + if not isinstance(tracks, list): + tracks = _safe_json_loads(_payload(linx).get("dialogue_tracks"), []) + if not isinstance(tracks, list): + tracks = [] + lines = [str(item.get("dialogue", "") if isinstance(item, dict) else "").strip() for item in tracks[:4]] + fallback_lines = [part.strip() for part in str(line_fallback or "").splitlines() if part.strip()] + while len(lines) < 4: + lines.append(fallback_lines[len(lines)] if len(fallback_lines) > len(lines) else "") + audio_tracks = resources.get("cine_audio_tracks") if isinstance(resources.get("cine_audio_tracks"), dict) else {} + foley_prompt = str(audio_tracks.get("foley_prompt") or _payload(linx).get("foley_prompt") or "") + return ( + lines[0], + lines[1], + lines[2], + lines[3], + foley_prompt, + _json_report({ + "node": "IAMCCS_CineDialogueLineRouter", + "line_count": len([line for line in lines if line]), + "foley_prompt": bool(foley_prompt), + }), + ) + + +class IAMCCS_CineTimelineAudioMixer: + """Place up to four dialogue line audios on the BoardMaker timeline and mix foley.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "cine_linx": (SUPERNODE_LINX_TYPE,), + "target_sample_rate": ("INT", {"default": 48000, "min": 8000, "max": 192000, "step": 1000}), + "dialogue_gain_db": ("FLOAT", {"default": 0.0, "min": -60.0, "max": 24.0, "step": 0.1}), + "foley_gain_db": ("FLOAT", {"default": -9.0, "min": -60.0, "max": 24.0, "step": 0.1}), + "normalize_peak": ("BOOLEAN", {"default": True}), + }, + "optional": { + "line_1_audio": ("AUDIO",), + "line_2_audio": ("AUDIO",), + "line_3_audio": ("AUDIO",), + "line_4_audio": ("AUDIO",), + "foley_audio": ("AUDIO",), + }, + } + + RETURN_TYPES = ("AUDIO", "AUDIO", "STRING") + RETURN_NAMES = ("final_audio", "dialogue_only", "report") + FUNCTION = "mix" + CATEGORY = "IAMCCS/Cine/Audio" + + def _tracks(self, cine_linx: Dict[str, Any]) -> List[Dict[str, Any]]: + resources = _resources(cine_linx) + tracks = resources.get("cine_dialogue_tracks") + if not isinstance(tracks, list): + tracks = _safe_json_loads(_payload(cine_linx).get("dialogue_tracks"), []) + return [dict(item) for item in tracks if isinstance(item, dict)] + + def _place(self, canvas: torch.Tensor, audio: Any, start_s: float, gain_db: float, target_rate: int) -> int: + if audio is None: + return 0 + wave, sr = _audio_waveform(audio) + wave = _resample_waveform(wave.detach().cpu(), int(sr), int(target_rate)) + wave = _ensure_channels(wave, int(canvas.shape[1])) + wave = _gain(wave, float(gain_db)) + start = max(0, int(round(float(start_s) * int(target_rate)))) + end = min(int(canvas.shape[-1]), start + int(wave.shape[-1])) + if end <= start: + return 0 + canvas[:, :, start:end] += wave[:, :, : end - start] + return int(end - start) + + def mix( + self, + cine_linx, + target_sample_rate, + dialogue_gain_db, + foley_gain_db, + normalize_peak, + line_1_audio=None, + line_2_audio=None, + line_3_audio=None, + line_4_audio=None, + foley_audio=None, + ): + linx = _clone_linx(cine_linx) + tracks = self._tracks(linx) + duration = _safe_float(_resources(linx).get("cine_duration_seconds", _payload(linx).get("duration_seconds", 15.0)), 15.0) + rate = int(target_sample_rate) + channels = 2 + total_samples = max(1, int(math.ceil(duration * rate))) + dialogue_canvas = torch.zeros((1, channels, total_samples), dtype=torch.float32) + final_canvas = torch.zeros_like(dialogue_canvas) + audios = [line_1_audio, line_2_audio, line_3_audio, line_4_audio] + placements = [] + for index, audio in enumerate(audios): + start_s = _safe_float(tracks[index].get("start_s", 0.0), 0.0) if index < len(tracks) else 0.0 + samples = self._place(dialogue_canvas, audio, start_s, float(dialogue_gain_db), rate) + placements.append({"line": index + 1, "start_s": round(start_s, 3), "samples": samples}) + final_canvas += dialogue_canvas + foley_samples = self._place(final_canvas, foley_audio, 0.0, float(foley_gain_db), rate) + peak = float(final_canvas.abs().max().item()) if final_canvas.numel() else 0.0 + if bool(normalize_peak) and peak > 1.0: + final_canvas = final_canvas / peak + return ( + {"waveform": final_canvas, "sample_rate": rate}, + {"waveform": dialogue_canvas, "sample_rate": rate}, + _json_report({ + "node": "IAMCCS_CineTimelineAudioMixer", + "duration_seconds": duration, + "target_sample_rate": rate, + "placements": placements, + "foley_samples": foley_samples, + "peak_before_normalize": peak, + "normalized": bool(normalize_peak and peak > 1.0), + }), + ) + + + +from .au_tools.audio_board_arranger import IAMCCS_AudioBoardArranger +from .au_tools.audio_bus_out import IAMCCS_BusOut +class IAMCCS_CineVideoToWooshInputs: + """Convert a generated Comfy VIDEO to IMAGE frames and WOOSH_VIDEO for foley generation.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "video": ("VIDEO",), + "max_duration_s": ("FLOAT", {"default": 15.0, "min": 1.0, "max": 60.0, "step": 0.5}), + "max_frames": ("INT", {"default": 144, "min": 16, "max": 801, "step": 1}), + "max_side": ("INT", {"default": 384, "min": 128, "max": 1280, "step": 32}), + } + } + + RETURN_TYPES = ("IMAGE", "WOOSH_VIDEO", "FLOAT", "STRING") + RETURN_NAMES = ("image_batch", "woosh_video", "frame_rate", "report") + FUNCTION = "convert" + CATEGORY = "IAMCCS/Cine/Audio" + + @staticmethod + def _thin_and_resize(frames: torch.Tensor, max_frames: int, max_side: int) -> Tuple[torch.Tensor, Dict[str, Any]]: + if not torch.is_tensor(frames): + frames = torch.from_numpy(frames) + original_shape = tuple(frames.shape) + if frames.ndim != 4: + raise ValueError(f"Expected video frames as [T,H,W,C], got {original_shape}") + total = int(frames.shape[0]) + max_frames = max(1, int(max_frames)) + if total > max_frames: + indices = torch.linspace(0, total - 1, steps=max_frames).round().long() + frames = frames.index_select(0, indices) + h = int(frames.shape[1]) + w = int(frames.shape[2]) + max_side = max(64, int(max_side)) + if max(h, w) > max_side: + scale = float(max_side) / float(max(h, w)) + new_h = max(16, int(round(h * scale / 16.0)) * 16) + new_w = max(16, int(round(w * scale / 16.0)) * 16) + as_float = frames.float() + if as_float.max().item() > 1.5: + as_float = as_float / 255.0 + nchw = as_float.permute(0, 3, 1, 2).contiguous() + resized = torch.nn.functional.interpolate(nchw, size=(new_h, new_w), mode="bilinear", align_corners=False) + frames = (resized.permute(0, 2, 3, 1).clamp(0.0, 1.0) * 255.0).round().to(torch.uint8) + elif frames.dtype != torch.uint8: + as_float = frames.float() + if as_float.max().item() <= 1.5: + as_float = as_float * 255.0 + frames = as_float.clamp(0.0, 255.0).round().to(torch.uint8) + report = { + "original_shape": original_shape, + "original_frames": total, + "output_shape": tuple(frames.shape), + "output_frames": int(frames.shape[0]), + "max_frames": max_frames, + "max_side": max_side, + } + return frames.cpu(), report + + def convert(self, video, max_duration_s, max_frames, max_side): + try: + source = video.get_stream_source() + except Exception: + source = None + temp_path = None + if isinstance(source, (str, os.PathLike)) and os.path.exists(source): + video_path = str(source) + else: + tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) + tmp.close() + temp_path = tmp.name + video.save_to(temp_path) + video_path = temp_path + custom_nodes_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + woosh_pkg = os.path.join(custom_nodes_root, "ComfyUI-Woosh", "Woosh") + if os.path.isdir(woosh_pkg) and woosh_pkg not in sys.path: + sys.path.insert(0, woosh_pkg) + try: + from woosh.utils.videoio import extract_video_frames + frames, rate, _pts = extract_video_frames(video_path, start_time=0, end_time=float(max_duration_s)) + finally: + if temp_path: + try: + os.remove(temp_path) + except OSError: + pass + frames, prep_report = self._thin_and_resize(frames, int(max_frames), int(max_side)) + if frames.dtype != torch.float32: + frames_float = frames.float() / 255.0 + else: + frames_float = frames.clamp(0.0, 1.0) + if frames_float.ndim != 4: + raise ValueError(f"Expected video frames as [T,H,W,C], got {tuple(frames_float.shape)}") + return ( + frames_float.cpu(), + {"frames": frames.cpu(), "rate": float(rate)}, + float(rate), + _json_report({ + "node": "IAMCCS_CineVideoToWooshInputs", + "low_vram": True, + "frames": int(frames.shape[0]), + "frame_rate": float(rate), + "max_duration_s": float(max_duration_s), + **prep_report, + }), + ) + + +NODE_CLASS_MAPPINGS = { + "IAMCCS_BoardMaker_DialogueFoley": IAMCCS_BoardMaker_DialogueFoley, + "IAMCCS_CineInfo3": IAMCCS_CineInfo3, + "IAMCCS_CineSpeech1PromptCompiler": IAMCCS_CineSpeech1PromptCompiler, + "IAMCCS_CineDialogueLineRouter": IAMCCS_CineDialogueLineRouter, + "IAMCCS_CineTimelineAudioMixer": IAMCCS_CineTimelineAudioMixer, + "IAMCCS_AudioBoardArranger": IAMCCS_AudioBoardArranger, + "IAMCCS_BusOut": IAMCCS_BusOut, + "IAMCCS_CineVideoToWooshInputs": IAMCCS_CineVideoToWooshInputs, + "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, +} + + +NODE_DISPLAY_NAME_MAPPINGS = { + "IAMCCS_BoardMaker_DialogueFoley": "IAMCCS BoardMaker Dialogue Foley", + "IAMCCS_CineInfo3": "IAMCCS Cine Info 3", + "IAMCCS_CineSpeech1PromptCompiler": "IAMCCS Speech1 Prompt Compiler", + "IAMCCS_CineDialogueLineRouter": "IAMCCS Dialogue Line Router", + "IAMCCS_CineTimelineAudioMixer": "IAMCCS Timeline Audio Mixer", + "IAMCCS_AudioBoardArranger": "IAMCCS AudioBoard Arranger", + "IAMCCS_BusOut": "IAMCCS BusOut", + "IAMCCS_CineVideoToWooshInputs": "IAMCCS Video To Woosh Inputs", + "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", +} diff --git a/iamccs_cine_nodes.py b/iamccs_cine_nodes.py index 6585d90..ccd0f17 100644 --- a/iamccs_cine_nodes.py +++ b/iamccs_cine_nodes.py @@ -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}" diff --git a/iamccs_supernodes_auimg2vid_exec_backend.py b/iamccs_supernodes_auimg2vid_exec_backend.py index c63a8bc..e7aa7c2 100644 --- a/iamccs_supernodes_auimg2vid_exec_backend.py +++ b/iamccs_supernodes_auimg2vid_exec_backend.py @@ -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: diff --git a/version.json b/version.json index 68920ff..73f7a26 100644 --- a/version.json +++ b/version.json @@ -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." } diff --git a/web/iamccs_cine_audio_dialogue_ui.js b/web/iamccs_cine_audio_dialogue_ui.js new file mode 100644 index 0000000..eb1197b --- /dev/null +++ b/web/iamccs_cine_audio_dialogue_ui.js @@ -0,0 +1,6246 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +console.info("[IAMCCS AudioDialogueUI] module loaded", { ts: new Date().toISOString() }); + +const EMOTIONS = [ + ["calm", "Calm", "Restrained delivery, steady breath"], + ["fear", "Fear", "Tension, fragile voice"], + ["anger", "Anger", "Compressed energy, hard consonants"], + ["sad", "Sad", "Soft pace, low intensity"], + ["joy", "Joy", "Warm and open"], + ["shock", "Shock", "Interrupted breath, sudden reaction"], + ["whisper", "Whisper", "Close microphone, low volume"], + ["urgent", "Urgent", "Faster delivery, forward pressure"], + ["wonder", "Wonder", "Curious, suspended tone"], + ["resolve", "Resolve", "Firm, controlled confidence"], +]; + +const PRESETS = [ + ["tense_dialogue", "Tense Dialogue", ["urgent", "whisper", "fear"]], + ["quiet_drama", "Quiet Drama", ["calm", "sad", "resolve"]], + ["reveal", "Reveal", ["shock", "wonder", "whisper"]], + ["conflict", "Conflict", ["anger", "urgent", "resolve"]], +]; + +function ensureStyles() { + if (document.getElementById("iamccs-cine-audio-dialogue-style")) return; + const style = document.createElement("style"); + style.id = "iamccs-cine-audio-dialogue-style"; + style.textContent = ` + .iamccs-emotion-panel { + box-sizing: border-box; + width: 100%; + padding: 10px; + color: #d9e2ea; + font: 12px/1.35 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: linear-gradient(180deg, rgba(24,31,39,.98), rgba(15,19,24,.98)); + border: 1px solid rgba(133, 154, 177, .22); + border-radius: 8px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.035); + } + .iamccs-emotion-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; + } + .iamccs-emotion-title { + color: #f2f6f9; + font-size: 13px; + font-weight: 700; + letter-spacing: 0; + } + .iamccs-emotion-subtitle { + margin-top: 2px; + color: #8ea0ae; + font-size: 10px; + } + .iamccs-emotion-clear { + flex: 0 0 auto; + padding: 5px 8px; + color: #b9c7d2; + background: rgba(255,255,255,.055); + border: 1px solid rgba(160,176,194,.18); + border-radius: 6px; + cursor: pointer; + font-size: 10px; + } + .iamccs-emotion-clear:hover { + color: #fff; + border-color: rgba(213,226,239,.32); + background: rgba(255,255,255,.09); + } + .iamccs-emotion-selected { + min-height: 26px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + margin-bottom: 9px; + padding: 7px; + background: rgba(0,0,0,.24); + border: 1px solid rgba(255,255,255,.07); + border-radius: 7px; + } + .iamccs-emotion-empty { + color: #728292; + font-size: 10px; + } + .iamccs-emotion-chip { + padding: 3px 7px; + color: #dfe8f0; + background: rgba(93, 134, 181, .18); + border: 1px solid rgba(119, 164, 215, .26); + border-radius: 999px; + font-size: 10px; + } + .iamccs-emotion-section { + margin-top: 9px; + margin-bottom: 5px; + color: #9cadba; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .04em; + } + .iamccs-emotion-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + } + .iamccs-emotion-btn, + .iamccs-emotion-preset { + min-height: 34px; + text-align: left; + color: #c6d3df; + background: rgba(255,255,255,.045); + border: 1px solid rgba(160,176,194,.16); + border-radius: 7px; + padding: 6px 8px; + cursor: pointer; + } + .iamccs-emotion-btn:hover, + .iamccs-emotion-preset:hover { + color: #f5f8fb; + background: rgba(255,255,255,.075); + border-color: rgba(194,211,229,.30); + } + .iamccs-emotion-btn.is-active { + color: #ffffff; + background: linear-gradient(180deg, rgba(44, 113, 184, .52), rgba(32, 73, 129, .50)); + border-color: rgba(137, 190, 245, .60); + } + .iamccs-emotion-btn strong, + .iamccs-emotion-preset strong { + display: block; + font-size: 11px; + font-weight: 700; + letter-spacing: 0; + } + .iamccs-emotion-btn span, + .iamccs-emotion-preset span { + display: block; + margin-top: 1px; + color: #8fa0af; + font-size: 9px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .iamccs-emotion-btn.is-active span { + color: #cde5ff; + } + .iamccs-emotion-presets { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + } + `; + document.head.appendChild(style); +} + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget.name === name); +} + +function parseSelected(value) { + return String(value || "") + .split(/[,;\n]+/) + .map((part) => part.trim()) + .filter(Boolean); +} + +function writeSelected(node, values) { + const widget = findWidget(node, "selected_emotions"); + if (!widget) return; + const unique = [...new Set(values.filter(Boolean))]; + widget.value = unique.join(", "); + widget.callback?.(widget.value); + node.setDirtyCanvas?.(true, true); + app.graph?.setDirtyCanvas?.(true, true); +} + +function hideWidget(widget) { + if (!widget) return; + widget.hidden = true; + widget.serialize = true; + widget.type = "hidden"; + widget.computeSize = () => [0, -4]; + widget.draw = () => {}; + widget.options = { ...(widget.options || {}), hidden: true }; + if (widget.inputEl) { + widget.inputEl.style.display = "none"; + widget.inputEl.style.height = "0"; + widget.inputEl.style.opacity = "0"; + } +} + +function widgetValue(node, name, fallback = "") { + const widget = findWidget(node, name); + return widget ? widget.value : fallback; +} + +function setWidgetValue(node, name, value) { + const widget = findWidget(node, name); + if (!widget) return false; + widget.value = value; + widget.callback?.(value); + node.setDirtyCanvas?.(true, true); + app.graph?.setDirtyCanvas?.(true, true); + return true; +} + +function addText(parent, className, text, tag = "div") { + const el = document.createElement(tag); + el.className = className; + el.textContent = text; + parent.appendChild(el); + return el; +} + +function ensureDialogueFoleyStyles() { + if (document.getElementById("iamccs-dialogue-foley-boardmaker-style")) return; + const style = document.createElement("style"); + style.id = "iamccs-dialogue-foley-boardmaker-style"; + style.textContent = ` + .iamccs-dfb { + box-sizing: border-box; + width: 100%; + min-height: 720px; + padding: 8px; + color: #ecf2f4; + --dfb-text-scale: 1; + font: 12px/1.35 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: linear-gradient(180deg, #0f171a 0%, #101315 100%); + border: 1px solid rgba(154, 184, 190, .22); + border-radius: 8px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.045); + } + .iamccs-dfb * { box-sizing: border-box; letter-spacing: 0; } + .iamccs-dfb-head { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: #182428; + border: 1px solid rgba(162, 198, 206, .18); + border-radius: 7px; + } + .iamccs-dfb-title { font-size: 15px; font-weight: 800; color: #ffffff; } + .iamccs-dfb-subtitle { margin-top: 2px; font-size: 11px; color: #9fb1b7; font-weight: 650; } + .iamccs-dfb-actions { margin-left: auto; display: flex; gap: 7px; flex-wrap: wrap; } + .iamccs-dfb-btn { + height: 28px; + padding: 0 9px; + color: #eef6f7; + background: #24343a; + border: 1px solid rgba(172, 206, 214, .22); + border-radius: 6px; + font-weight: 750; + cursor: pointer; + white-space: nowrap; + } + .iamccs-dfb-btn:hover { background: #2e444b; border-color: rgba(205, 230, 235, .38); } + .iamccs-dfb-btn.primary { background: #2a6370; border-color: #70aeba; color: #fff; } + .iamccs-dfb-btn.danger { background: #523033; border-color: #9c5b61; } + .iamccs-dfb-btn.is-active { background: #f1eee8; border-color: #d7c7b7; color: #111; } + .iamccs-dfb-btn.inject { + height: 38px; + padding: 0 16px; + color: #ffffff; + background: linear-gradient(180deg, #2f7f8d 0%, #21545f 100%); + border-color: #8fd1dc; + font-size: 12px; + font-weight: 900; + box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 0 0 1px rgba(143,209,220,.12); + } + .iamccs-dfb-btn.inject:hover { background: linear-gradient(180deg, #3a94a4 0%, #286470 100%); } + .iamccs-dfb-top { + display: grid; + grid-template-columns: minmax(240px, 1fr) 76px 98px 98px 92px 98px 98px 150px; + gap: 7px; + margin-top: 7px; + padding: 7px; + background: #121c1f; + border: 1px solid rgba(156, 184, 190, .14); + border-radius: 7px; + } + .iamccs-dfb-field { display: flex; flex-direction: column; gap: 3px; min-width: 0; color: #aebec3; font-size: 9px; font-weight: 800; text-transform: uppercase; } + .iamccs-dfb-input, .iamccs-dfb-text, .iamccs-dfb-select { + width: 100%; + color: #f3f7f8; + background: #0b1113; + border: 1px solid rgba(156, 184, 190, .22); + border-radius: 6px; + outline: none; + font: calc(11px * var(--dfb-text-scale)) / 1.28 Inter, ui-sans-serif, system-ui, sans-serif; + } + .iamccs-dfb-input, .iamccs-dfb-select { height: 28px; padding: 0 7px; } + .iamccs-dfb-text { min-height: 48px; padding: 6px 7px; resize: vertical; } + .iamccs-dfb.paper .iamccs-dfb-input, + .iamccs-dfb.paper .iamccs-dfb-text { + color: #090909; + background: #f8f4ed; + border-color: #b7a795; + font-family: "Courier New", ui-monospace, monospace; + font-weight: 700; + } + .iamccs-dfb.paper .iamccs-dfb-input::placeholder, + .iamccs-dfb.paper .iamccs-dfb-text::placeholder { color: #685f55; } + .iamccs-dfb-input:focus, .iamccs-dfb-text:focus, .iamccs-dfb-select:focus { border-color: #75bac6; box-shadow: 0 0 0 1px rgba(117,186,198,.18); } + .iamccs-dfb-panels { + display: grid; + grid-template-columns: minmax(420px, .9fr) minmax(520px, 1.1fr); + gap: 7px; + margin-top: 7px; + } + .iamccs-dfb-panel { + padding: 7px; + background: #131d20; + border: 1px solid rgba(156, 184, 190, .14); + border-radius: 7px; + } + .iamccs-dfb-panel-title { + margin-bottom: 5px; + color: #eff7f8; + font-size: 10px; + font-weight: 850; + text-transform: uppercase; + } + .iamccs-dfb-status { + min-height: 26px; + margin-top: 7px; + padding: 6px 8px; + color: #b8c8cc; + background: #0b1113; + border: 1px solid rgba(156, 184, 190, .15); + border-radius: 7px; + font-weight: 700; + } + .iamccs-dfb-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 7px; + max-height: 500px; + overflow: auto; + padding-right: 3px; + } + .iamccs-dfb-card { + display: grid; + grid-template-columns: 44px 96px 150px minmax(240px, 1fr) minmax(260px, 1fr) minmax(180px, .75fr) 58px; + gap: 6px; + padding: 6px; + background: #172327; + border: 1px solid rgba(156, 184, 190, .18); + border-radius: 7px; + } + .iamccs-dfb-badge { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + white-space: pre-line; + color: #fff; + background: linear-gradient(180deg, #2a6370, #172327); + border: 1px solid #70aeba; + border-radius: 6px; + font-weight: 850; + line-height: 1.16; + font-size: 10px; + } + .iamccs-dfb-card-actions { display: grid; gap: 5px; align-content: start; } + .iamccs-speech1 { + box-sizing: border-box; + width: 100%; + padding: 10px; + color: #ecf2f4; + font: 12px/1.35 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: linear-gradient(180deg, #121c1f 0%, #0d1315 100%); + border: 1px solid rgba(154, 184, 190, .24); + border-radius: 8px; + } + .iamccs-speech1-head { display: grid; gap: 3px; margin-bottom: 9px; } + .iamccs-speech1-title { font-size: 14px; font-weight: 900; color: #fff; } + .iamccs-speech1-subtitle { font-size: 10px; color: #9fb1b7; font-weight: 700; } + .iamccs-speech1-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-bottom: 8px; } + .iamccs-speech1-field { display: grid; gap: 3px; color: #aebec3; font-size: 9px; font-weight: 850; text-transform: uppercase; } + .iamccs-speech1-input { + height: 30px; + padding: 0 8px; + color: #f3f7f8; + background: #0b1113; + border: 1px solid rgba(156, 184, 190, .22); + border-radius: 6px; + outline: none; + } + .iamccs-speech1-check { display: flex; align-items: center; gap: 6px; min-height: 30px; color: #d8e3e6; text-transform: none; font-size: 11px; } + .iamccs-speech1-inject { + width: 100%; + height: 42px; + color: #fff; + background: linear-gradient(180deg, #2f7f8d 0%, #21545f 100%); + border: 1px solid #8fd1dc; + border-radius: 7px; + font-size: 12px; + font-weight: 950; + cursor: pointer; + box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 0 0 1px rgba(143,209,220,.12); + } + .iamccs-speech1-inject:hover { background: linear-gradient(180deg, #3a94a4 0%, #286470 100%); } + .iamccs-speech1-status { + min-height: 28px; + margin-top: 8px; + padding: 6px 8px; + color: #b8c8cc; + background: #0b1113; + border: 1px solid rgba(156, 184, 190, .15); + border-radius: 7px; + font-weight: 750; + } + `; + document.head.appendChild(style); +} + +function protectDrag(el) { + for (const eventName of ["pointerdown", "mousedown", "dblclick", "wheel"]) { + el.addEventListener(eventName, (event) => event.stopPropagation(), { capture: true }); + } + return el; +} + +function makeInput(value, onInput, opts = {}) { + const el = document.createElement(opts.multiline ? "textarea" : "input"); + if (!opts.multiline) el.type = opts.type || "text"; + el.className = opts.multiline ? "iamccs-dfb-text" : "iamccs-dfb-input"; + el.value = value ?? ""; + if (opts.placeholder) el.placeholder = opts.placeholder; + if (opts.type === "number") { + el.step = opts.step ?? "0.1"; + if (opts.min != null) el.min = String(opts.min); + if (opts.max != null) el.max = String(opts.max); + } + el.oninput = () => onInput(opts.type === "number" ? Number(el.value) : el.value); + return protectDrag(el); +} + +function makeSelect(value, options, onChange) { + const el = document.createElement("select"); + el.className = "iamccs-dfb-select"; + for (const option of options) { + const opt = document.createElement("option"); + opt.value = option; + opt.textContent = option; + el.appendChild(opt); + } + el.value = options.includes(String(value)) ? String(value) : options[0]; + el.onchange = () => onChange(el.value); + return protectDrag(el); +} + +function field(label, control) { + const wrap = document.createElement("label"); + wrap.className = "iamccs-dfb-field"; + const span = document.createElement("span"); + span.textContent = label; + wrap.append(span, control); + return wrap; +} + +function parseDialogueRows(text) { + try { + const data = JSON.parse(String(text || "").trim() || "{}"); + const raw = Array.isArray(data) ? data : Array.isArray(data.lines) ? data.lines : []; + return raw.length ? raw.map((row, index) => normalizeDialogueRow(row, index)) : defaultDialogueRows(); + } catch { + return defaultDialogueRows(); + } +} + +function normalizeDialogueRow(row = {}, index = 0) { + const speaker = String(row.speaker || (index % 2 ? "B" : "A")).toUpperCase().startsWith("B") ? "B" : "A"; + return { + speaker, + seconds: Math.max(0.25, Number(row.seconds ?? row.duration ?? 3.5) || 3.5), + ref: Math.max(1, Math.round(Number(row.ref ?? (speaker === "A" ? 1 : 2)) || 1)), + label: String(row.label || (speaker === "A" ? `campo_A_${index + 1}` : `controcampo_B_${index + 1}`)), + framing: String(row.framing || (speaker === "A" ? "medium close-up" : "reverse angle")), + dialogue: String(row.dialogue || row.text || ""), + voice: String(row.voice || row.voice_direction || ""), + local_prompt: String(row.local_prompt || row.prompt || ""), + foley: String(row.foley || ""), + }; +} + +function defaultDialogueRows() { + return [ + normalizeDialogueRow({ speaker: "A", seconds: 4, ref: 1, label: "campo_A", dialogue: "Non guardare la finestra. Se capisce che l'abbiamo visto, siamo finiti.", voice: "low tense voice, almost whispered", local_prompt: "man A in field shot, tense medium close-up, restrained fear, coherent eyeline", foley: "soft breathing, cloth rustle, distant room tone" }, 0), + normalizeDialogueRow({ speaker: "B", seconds: 3.5, ref: 2, label: "controcampo_B", dialogue: "L'ha gia capito. Sta aspettando che uno di noi faccia il primo passo.", voice: "controlled low voice, guarded", local_prompt: "man B in reverse field shot, listening then answering, controlled fear", foley: "small chair creak, quiet breath, low room pressure" }, 1), + normalizeDialogueRow({ speaker: "A", seconds: 3.5, ref: 1, label: "ritorno_A", dialogue: "Allora il primo passo lo fai tu, lentamente, verso l'uscita.", voice: "firm whisper, urgent but contained", local_prompt: "return to man A close-up, slight push-in, decision in the eyes", foley: "subtle foot shift, jacket movement, controlled breath" }, 2), + normalizeDialogueRow({ speaker: "B", seconds: 4, ref: 2, label: "chiusura_B", dialogue: "No. Se esco da solo, tu resti qui a spiegargli dove siamo.", voice: "dry, bitter, quietly direct", local_prompt: "man B reverse close-up, hard pause before the last words, tense eye line", foley: "distant door tension, faint wood creak, breath held" }, 3), + ]; +} + +function renderDialogueFoleyBoardMaker(node) { + if (node.__iamccsDialogueFoleyReady) return; + node.__iamccsDialogueFoleyReady = true; + ensureDialogueFoleyStyles(); + + const widgetNames = [ + "board_name", "global_prompt", "scene_context", "foley_prompt", "dialogue_data", + "frame_rate", "image_width", "image_height", "default_force", "guide_policy", + "woosh_chunk_seconds", "woosh_overlap_seconds", + ]; + const hideNativeWidgets = () => widgetNames.forEach((name) => hideWidget(findWidget(node, name))); + hideNativeWidgets(); + requestAnimationFrame?.(hideNativeWidgets); + setTimeout(hideNativeWidgets, 50); + node.color = "#1c3035"; + node.bgcolor = "#101719"; + node.size = [Math.max(node.size?.[0] || 1480, 1540), Math.max(node.size?.[1] || 760, 900)]; + + let rows = parseDialogueRows(widgetValue(node, "dialogue_data", "")); + let paperMode = Boolean(node.properties?.iamccs_dfb_paper_mode); + let textScale = Number(node.properties?.iamccs_dfb_text_scale || 1); + + const root = document.createElement("div"); + root.className = "iamccs-dfb"; + root.classList.toggle("paper", paperMode); + root.style.setProperty("--dfb-text-scale", String(textScale)); + + const head = document.createElement("div"); + head.className = "iamccs-dfb-head"; + const headText = document.createElement("div"); + addText(headText, "iamccs-dfb-title", "IAMCCS BoardMaker Dialogue Foley"); + addText(headText, "iamccs-dfb-subtitle", "Scrivi board A/B. Il cine_linx e l'inject verso Shotboard, TTS e Woosh."); + const actions = document.createElement("div"); + actions.className = "iamccs-dfb-actions"; + const addBtn = document.createElement("button"); + addBtn.className = "iamccs-dfb-btn primary"; + addBtn.textContent = "Add Line"; + const presetBtn = document.createElement("button"); + presetBtn.className = "iamccs-dfb-btn"; + presetBtn.textContent = "Preset A/B"; + const injectBtn = document.createElement("button"); + injectBtn.className = "iamccs-dfb-btn inject"; + injectBtn.textContent = "INJECT TO SHOTBOARD"; + injectBtn.title = "Sync this BoardMaker into cine_linx. The connected Speech1 Compiler injects compiled global/local prompt, dialogue and foley into Shotboard/TTS/Woosh on queue."; + const paperBtn = document.createElement("button"); + paperBtn.className = "iamccs-dfb-btn"; + paperBtn.textContent = "Paper"; + const fontDownBtn = document.createElement("button"); + fontDownBtn.className = "iamccs-dfb-btn"; + fontDownBtn.textContent = "A-"; + const fontUpBtn = document.createElement("button"); + fontUpBtn.className = "iamccs-dfb-btn"; + fontUpBtn.textContent = "A+"; + const fitBtn = document.createElement("button"); + fitBtn.className = "iamccs-dfb-btn"; + fitBtn.textContent = "Fit"; + const resetBtn = document.createElement("button"); + resetBtn.className = "iamccs-dfb-btn danger"; + resetBtn.textContent = "Reset"; + actions.append(addBtn, presetBtn, injectBtn, paperBtn, fontDownBtn, fontUpBtn, fitBtn, resetBtn); + head.append(headText, actions); + root.appendChild(head); + + const top = document.createElement("div"); + top.className = "iamccs-dfb-top"; + const boardName = makeInput(widgetValue(node, "board_name", "dialogue_foley_board"), (v) => setWidgetValue(node, "board_name", v)); + const fps = makeInput(widgetValue(node, "frame_rate", 24), (v) => setWidgetValue(node, "frame_rate", v), { type: "number", step: "1", min: 1, max: 120 }); + const width = makeInput(widgetValue(node, "image_width", 1280), (v) => setWidgetValue(node, "image_width", v), { type: "number", step: "32", min: 64 }); + const height = makeInput(widgetValue(node, "image_height", 736), (v) => setWidgetValue(node, "image_height", v), { type: "number", step: "32", min: 64 }); + const force = makeInput(widgetValue(node, "default_force", 0.65), (v) => setWidgetValue(node, "default_force", v), { type: "number", step: "0.01", min: 0, max: 1 }); + const chunk = makeInput(widgetValue(node, "woosh_chunk_seconds", 8), (v) => setWidgetValue(node, "woosh_chunk_seconds", v), { type: "number", step: "0.1", min: 1, max: 8 }); + const overlap = makeInput(widgetValue(node, "woosh_overlap_seconds", 1), (v) => setWidgetValue(node, "woosh_overlap_seconds", v), { type: "number", step: "0.1", min: 0, max: 4 }); + const policy = makeSelect(widgetValue(node, "guide_policy", "every_checked_row"), ["every_checked_row", "safe_core_guides", "prompt_only"], (v) => setWidgetValue(node, "guide_policy", v)); + top.append(field("Board", boardName), field("FPS", fps), field("Width", width), field("Height", height), field("Force", force), field("Woosh max", chunk), field("Overlap", overlap), field("Guide policy", policy)); + root.appendChild(top); + + const panels = document.createElement("div"); + panels.className = "iamccs-dfb-panels"; + const globalPanel = document.createElement("div"); + globalPanel.className = "iamccs-dfb-panel"; + addText(globalPanel, "iamccs-dfb-panel-title", "Scene architecture"); + const global = makeInput(widgetValue(node, "global_prompt", ""), (v) => setWidgetValue(node, "global_prompt", v), { multiline: true, placeholder: "Global prompt..." }); + const scene = makeInput(widgetValue(node, "scene_context", ""), (v) => setWidgetValue(node, "scene_context", v), { multiline: true, placeholder: "Scene context..." }); + globalPanel.append(field("Global prompt", global), field("Scene context", scene)); + const foleyPanel = document.createElement("div"); + foleyPanel.className = "iamccs-dfb-panel"; + addText(foleyPanel, "iamccs-dfb-panel-title", "Foley plan"); + const foley = makeInput(widgetValue(node, "foley_prompt", ""), (v) => setWidgetValue(node, "foley_prompt", v), { multiline: true, placeholder: "Global foley prompt..." }); + foleyPanel.append(field("Global foley prompt", foley)); + panels.append(globalPanel, foleyPanel); + root.appendChild(panels); + + const status = document.createElement("div"); + status.className = "iamccs-dfb-status"; + root.appendChild(status); + const list = document.createElement("div"); + list.className = "iamccs-dfb-list"; + root.appendChild(list); + + function sync() { + rows = rows.map(normalizeDialogueRow); + setWidgetValue(node, "dialogue_data", JSON.stringify({ lines: rows }, null, 2)); + const total = rows.reduce((sum, row) => sum + Number(row.seconds || 0), 0); + const aCount = rows.filter((row) => row.speaker === "A").length; + const bCount = rows.filter((row) => row.speaker === "B").length; + status.textContent = `Lines: ${rows.length} | A: ${aCount} | B: ${bCount} | Duration: ${total.toFixed(1)}s | Inject: cine_linx | Token local prompt: `; + } + + function draw() { + list.innerHTML = ""; + rows.forEach((row, index) => { + const card = document.createElement("div"); + card.className = "iamccs-dfb-card"; + const badge = document.createElement("div"); + badge.className = "iamccs-dfb-badge"; + badge.textContent = `${row.speaker}\nLINE\n${String(index + 1).padStart(2, "0")}`; + + const meta = document.createElement("div"); + const speaker = makeSelect(row.speaker, ["A", "B"], (v) => { row.speaker = v; row.ref = v === "A" ? 1 : 2; sync(); draw(); }); + const seconds = makeInput(row.seconds, (v) => { row.seconds = Math.max(0.25, Number(v) || 0.25); sync(); }, { type: "number", step: "0.1", min: 0.25 }); + const ref = makeInput(row.ref, (v) => { row.ref = Math.max(1, Math.round(Number(v) || 1)); sync(); }, { type: "number", step: "1", min: 1 }); + meta.append(field("Speaker", speaker), field("Seconds", seconds), field("Ref", ref)); + + const shot = document.createElement("div"); + const rowLabelInput = makeInput(row.label, (v) => { row.label = v; sync(); }); + const framing = makeInput(row.framing, (v) => { row.framing = v; sync(); }); + shot.append(field("Label", rowLabelInput), field("Framing", framing)); + + const dialogue = makeInput(row.dialogue, (v) => { row.dialogue = v; sync(); }, { multiline: true, placeholder: "Dialogue line..." }); + const prompt = makeInput(row.local_prompt, (v) => { row.local_prompt = v; sync(); }, { multiline: true, placeholder: "Local visual prompt..." }); + const voice = makeInput(row.voice, (v) => { row.voice = v; sync(); }, { multiline: true, placeholder: "Voice direction..." }); + + const rowActions = document.createElement("div"); + rowActions.className = "iamccs-dfb-card-actions"; + const copy = document.createElement("button"); + copy.className = "iamccs-dfb-btn"; + copy.textContent = "Copy"; + copy.onclick = () => { rows.splice(index + 1, 0, normalizeDialogueRow({ ...row, label: `${row.label}_copy` }, index + 1)); sync(); draw(); }; + const del = document.createElement("button"); + del.className = "iamccs-dfb-btn danger"; + del.textContent = "Del"; + del.onclick = () => { rows.splice(index, 1); if (!rows.length) rows = defaultDialogueRows(); sync(); draw(); }; + rowActions.append(copy, del); + + card.append(badge, meta, shot, field("Dialogue / speech1", dialogue), field("Local prompt / Shotboard", prompt), field("Voice / TTS style", voice), rowActions); + list.appendChild(card); + }); + sync(); + } + + function updateVisualMode() { + paperBtn.classList.toggle("is-active", paperMode); + root.classList.toggle("paper", paperMode); + root.style.setProperty("--dfb-text-scale", String(textScale)); + node.properties = node.properties || {}; + node.properties.iamccs_dfb_paper_mode = paperMode; + node.properties.iamccs_dfb_text_scale = textScale; + node.setDirtyCanvas?.(true, true); + app.graph?.setDirtyCanvas?.(true, true); + } + + addBtn.onclick = () => { + rows.push(normalizeDialogueRow({ speaker: rows.length % 2 ? "B" : "A", dialogue: "New line.", local_prompt: "new dialogue beat, coherent eyeline", voice: "natural low voice", foley: "room tone and cloth movement" }, rows.length)); + sync(); + draw(); + }; + presetBtn.onclick = () => { rows = defaultDialogueRows(); sync(); draw(); }; + injectBtn.onclick = () => { + sync(); + status.textContent = "Inject armato: BoardMaker -> Speech1 Compiler -> Shotboard/TTS/Woosh tramite cine_linx. Premi Queue per eseguire l'iniezione."; + }; + paperBtn.onclick = () => { paperMode = !paperMode; updateVisualMode(); }; + fontDownBtn.onclick = () => { textScale = Math.max(0.85, Math.round((textScale - 0.1) * 100) / 100); updateVisualMode(); }; + fontUpBtn.onclick = () => { textScale = Math.min(1.45, Math.round((textScale + 0.1) * 100) / 100); updateVisualMode(); }; + fitBtn.onclick = () => { const total = rows.reduce((sum, row) => sum + Number(row.seconds || 0), 0); status.textContent = `Board duration will be ${total.toFixed(1)}s when queued.`; }; + resetBtn.onclick = () => { rows = defaultDialogueRows(); sync(); draw(); }; + + updateVisualMode(); + draw(); + const domWidget = node.addDOMWidget("Dialogue Foley BoardMaker", "iamccs_dialogue_foley_boardmaker", root, { serialize: false }); + domWidget.computeSize = (width) => [Math.max(1480, Number(width || 1480)), 840]; +} + +function renderSpeech1Compiler(node) { + if (node.__iamccsSpeech1CompilerReady) return; + node.__iamccsSpeech1CompilerReady = true; + ensureDialogueFoleyStyles(); + + const widgetNames = ["speech_token", "append_scene_context", "append_voice_style"]; + const hideNativeWidgets = () => widgetNames.forEach((name) => hideWidget(findWidget(node, name))); + hideNativeWidgets(); + requestAnimationFrame?.(hideNativeWidgets); + setTimeout(hideNativeWidgets, 50); + + node.color = "#253236"; + node.bgcolor = "#11191b"; + node.size = [Math.max(node.size?.[0] || 430, 460), Math.max(node.size?.[1] || 230, 250)]; + + const root = document.createElement("div"); + root.className = "iamccs-speech1"; + + const head = document.createElement("div"); + head.className = "iamccs-speech1-head"; + addText(head, "iamccs-speech1-title", "Speech1 Prompt Compiler"); + addText(head, "iamccs-speech1-subtitle", "Compila e passa il cine_linx allo Shotboard."); + root.appendChild(head); + + const grid = document.createElement("div"); + grid.className = "iamccs-speech1-grid"; + + const tokenInput = document.createElement("input"); + tokenInput.className = "iamccs-speech1-input"; + tokenInput.value = widgetValue(node, "speech_token", "") || ""; + tokenInput.oninput = () => setWidgetValue(node, "speech_token", tokenInput.value || ""); + protectDrag(tokenInput); + const tokenWrap = document.createElement("label"); + tokenWrap.className = "iamccs-speech1-field"; + tokenWrap.append(addText(document.createElement("span"), "", "Token"), tokenInput); + + const sceneCheck = document.createElement("input"); + sceneCheck.type = "checkbox"; + sceneCheck.checked = Boolean(widgetValue(node, "append_scene_context", true)); + sceneCheck.onchange = () => setWidgetValue(node, "append_scene_context", Boolean(sceneCheck.checked)); + protectDrag(sceneCheck); + const sceneWrap = document.createElement("label"); + sceneWrap.className = "iamccs-speech1-check"; + sceneWrap.append(sceneCheck, document.createTextNode("append scene context")); + + const voiceCheck = document.createElement("input"); + voiceCheck.type = "checkbox"; + voiceCheck.checked = Boolean(widgetValue(node, "append_voice_style", true)); + voiceCheck.onchange = () => setWidgetValue(node, "append_voice_style", Boolean(voiceCheck.checked)); + protectDrag(voiceCheck); + const voiceWrap = document.createElement("label"); + voiceWrap.className = "iamccs-speech1-check"; + voiceWrap.append(voiceCheck, document.createTextNode("append voice style")); + + grid.append(tokenWrap, sceneWrap, voiceWrap); + root.appendChild(grid); + + const inject = document.createElement("button"); + inject.type = "button"; + inject.className = "iamccs-speech1-inject"; + inject.textContent = "COMPILE + INJECT TO SHOTBOARD"; + inject.title = "On Queue this node replaces , writes local_prompts/segment_lengths/dialogue_script, and sends compiled cine_linx to Shotboard, TTS and Woosh."; + protectDrag(inject); + root.appendChild(inject); + + const status = document.createElement("div"); + status.className = "iamccs-speech1-status"; + status.textContent = "Ready: BoardMaker cine_linx in, compiled cine_linx out."; + root.appendChild(status); + + inject.onclick = () => { + setWidgetValue(node, "speech_token", tokenInput.value || ""); + setWidgetValue(node, "append_scene_context", Boolean(sceneCheck.checked)); + setWidgetValue(node, "append_voice_style", Boolean(voiceCheck.checked)); + status.textContent = "Inject armato: in Queue questo nodo compila e invia global/local/dialogue/foley allo Shotboard."; + }; + + const domWidget = node.addDOMWidget("Speech1 Compiler", "iamccs_speech1_compiler", root, { serialize: false }); + domWidget.computeSize = (width) => [Math.max(430, Number(width || 430)), 230]; +} + +function renderEmotionPanel(node) { + if (node.__iamccsEmotionButtonsReady) return; + node.__iamccsEmotionButtonsReady = true; + ensureStyles(); + + const selectedWidget = findWidget(node, "selected_emotions"); + hideWidget(selectedWidget); + + node.color = "#26323d"; + node.bgcolor = "#141a20"; + node.size = [ + Math.max(node.size?.[0] || 380, 420), + Math.max(node.size?.[1] || 360, 500), + ]; + + const root = document.createElement("div"); + root.className = "iamccs-emotion-panel"; + + const head = document.createElement("div"); + head.className = "iamccs-emotion-head"; + const titleWrap = document.createElement("div"); + addText(titleWrap, "iamccs-emotion-title", "Emotion Palette"); + addText(titleWrap, "iamccs-emotion-subtitle", "Seleziona il tono per voce, performance e prompt."); + const clear = document.createElement("button"); + clear.type = "button"; + clear.className = "iamccs-emotion-clear"; + clear.textContent = "Clear"; + head.append(titleWrap, clear); + root.appendChild(head); + + const selected = document.createElement("div"); + selected.className = "iamccs-emotion-selected"; + root.appendChild(selected); + + addText(root, "iamccs-emotion-section", "Preset rapidi"); + const presets = document.createElement("div"); + presets.className = "iamccs-emotion-presets"; + root.appendChild(presets); + + addText(root, "iamccs-emotion-section", "Emozioni"); + const grid = document.createElement("div"); + grid.className = "iamccs-emotion-grid"; + root.appendChild(grid); + + const buttons = new Map(); + + function currentValues() { + return parseSelected(findWidget(node, "selected_emotions")?.value); + } + + function refresh() { + const values = currentValues(); + const set = new Set(values); + selected.innerHTML = ""; + if (!values.length) { + addText(selected, "iamccs-emotion-empty", "Nessuna emozione selezionata"); + } else { + for (const value of values) addText(selected, "iamccs-emotion-chip", value); + } + for (const [key, button] of buttons) { + button.classList.toggle("is-active", set.has(key)); + } + } + + function toggle(key) { + const values = currentValues(); + const found = values.includes(key); + writeSelected(node, found ? values.filter((value) => value !== key) : [...values, key]); + refresh(); + } + + function applyPreset(values) { + writeSelected(node, values); + refresh(); + } + + for (const [, label, values] of PRESETS) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "iamccs-emotion-preset"; + button.innerHTML = `${label}${values.join(", ")}`; + button.onclick = () => applyPreset(values); + presets.appendChild(button); + } + + for (const [key, label, description] of EMOTIONS) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "iamccs-emotion-btn"; + button.title = description; + button.innerHTML = `${label}${description}`; + button.onclick = () => toggle(key); + buttons.set(key, button); + grid.appendChild(button); + } + + clear.onclick = () => { + writeSelected(node, []); + refresh(); + }; + + refresh(); + const domWidget = node.addDOMWidget("Emotion Palette", "iamccs_emotion_palette", root, { + serialize: false, + }); + domWidget.computeSize = (width) => [width, 390]; +} + + +function ensureAudioBoardArrangerStyles() { + let style = document.getElementById("iamccs-audio-board-arranger-style"); + if (!style) { + style = document.createElement("style"); + style.id = "iamccs-audio-board-arranger-style"; + document.head.appendChild(style); + } + style.textContent = ` + .iamccs-audio-board { + box-sizing: border-box; + width: 100%; + height: 1150px; + min-height: 1150px; + max-height: 1150px; + padding: 10px 10px 18px; + overflow: hidden; + contain: layout paint style; + content-visibility: visible; + contain-intrinsic-size: 1880px 1220px; + transform: translateZ(0); + color: #dbe7ea; + background: linear-gradient(180deg, #151b1f, #0f1317); + border: 1px solid rgba(150, 174, 184, .22); + border-radius: 8px; + font: 11px/1.25 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + .iamccs-audio-board.is-fullscreen { + min-height: calc(100vh - 96px); + height: calc(100vh - 96px); + border: 0; + box-shadow: none; + } + .iamccs-audio-board.is-fullscreen .iamccs-audio-board-dynamic { + height: calc(100vh - 118px); + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; + } + .iamccs-audio-board-dynamic { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding-bottom: 4px; + } + .iamccs-audio-board * { box-sizing: border-box; letter-spacing: 0; } + .iamccs-audio-board-head, + .iamccs-audio-board-tools, + .iamccs-audio-board-status, + .iamccs-audio-board-transport, + .iamccs-audio-board-master { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + } + .iamccs-audio-board-head { justify-content: space-between; margin-bottom: 7px; } + .iamccs-audio-board-title { color: #f4f8f9; font-size: 13px; font-weight: 900; } + .iamccs-audio-board-sub { color: #8fa5ac; font-size: 10px; font-weight: 750; } + .iamccs-audio-board button { + min-height: 25px; + padding: 0 8px; + border: 1px solid rgba(143, 208, 204, .35); + border-radius: 5px; + background: linear-gradient(180deg, #284a4e, #1a3034); + color: #ecffff; + cursor: pointer; + font-size: 10px; + font-weight: 850; + white-space: nowrap; + } + .iamccs-audio-board button:hover { border-color: rgba(244, 212, 158, .65); } + .iamccs-audio-board button.is-active { + color: #101315; + background: linear-gradient(180deg, #f2d79a, #c79e59); + border-color: #ffe6ae; + } + .iamccs-audio-board button.is-values { + color: #fff4c8; + background: linear-gradient(180deg, rgba(133,93,37,.96), rgba(79,53,24,.96)); + border-color: rgba(255,219,139,.52); + } + .iamccs-audio-board button.danger { + background: linear-gradient(180deg, #65342f, #42221f); + border-color: rgba(216, 105, 90, .6); + } + .iamccs-audio-board-multi { + display: grid; + grid-template-columns: auto repeat(5, minmax(88px, 1fr)) auto auto; + gap: 7px; + align-items: end; + margin-top: 7px; + padding: 8px; + border: 1px solid rgba(244,212,158,.22); + border-radius: 7px; + background: linear-gradient(180deg, rgba(33,38,31,.94), rgba(9,14,13,.94)); + } + .iamccs-audio-board-multi-title { + display: grid; + gap: 1px; + min-width: 120px; + color: #fff1ba; + font-size: 11px; + font-weight: 950; + } + .iamccs-audio-board-multi-title small { + color: #8fb7b5; + font-size: 9px; + font-weight: 800; + } + .iamccs-audio-board-multi label { + display: grid; + gap: 3px; + color: #91a5ac; + font-size: 8px; + font-weight: 950; + text-transform: uppercase; + } + .iamccs-audio-board-multi select, + .iamccs-audio-board-multi input { + min-width: 0; + height: 26px; + border: 1px solid rgba(143,208,204,.38); + border-radius: 5px; + background: #081115; + color: #ecffff; + font-size: 10px; + font-weight: 850; + padding: 0 7px; + } + .iamccs-audio-board-multi button.is-primary { + color: #151005; + background: linear-gradient(180deg, #f2d79a, #c79e59); + border-color: #ffe6ae; + } + .iamccs-audio-board-multi-map { + grid-column: 1 / -1; + min-height: 18px; + padding: 4px 6px; + border: 1px solid rgba(255,255,255,.08); + border-radius: 5px; + background: #05090a; + color: #b8fff1; + font: 10px Consolas, monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .iamccs-audio-board-transport, + .iamccs-audio-board-master { + margin-top: 7px; + padding: 8px; + border: 1px solid rgba(255,255,255,.10); + border-radius: 7px; + background: rgba(0,0,0,.20); + } + .iamccs-audio-board-transport { + display: grid; + grid-template-columns: minmax(190px, 1fr) auto minmax(260px, 1fr); + align-items: center; + } + .iamccs-transport-left, + .iamccs-transport-center, + .iamccs-transport-right, + .iamccs-clip-action-bar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + } + .iamccs-transport-center { + justify-content: center; + padding: 4px 10px; + border: 1px solid rgba(244,212,158,.22); + border-radius: 7px; + background: linear-gradient(180deg, rgba(31,42,45,.94), rgba(9,13,16,.94)); + } + .iamccs-transport-center button { min-width: 44px; min-height: 31px; } + .iamccs-transport-center button.is-play { + min-width: 64px; + color: #101315; + background: linear-gradient(180deg, #f2d79a, #c79e59); + border-color: #ffe6ae; + font-size: 11px; + } + .iamccs-transport-center button.is-loop { + min-width: 54px; + } + .iamccs-transport-center button.is-marker { + min-width: 34px; + color: #132017; + background: linear-gradient(180deg, #f8fff2, #cfeec6); + border-color: rgba(80,180,92,.55); + } + .iamccs-transport-right { justify-content: flex-end; min-width: 0; } + .iamccs-transport-time { + min-width: 96px; + color: #ffe2a8; + font: 900 13px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + } + .iamccs-helper-monitor { + flex: 1 1 220px; + min-height: 25px; + padding: 6px 8px; + color: #b9cbd0; + background: #0a0e11; + border: 1px solid rgba(255,255,255,.10); + border-radius: 5px; + font: 10px/1.25 ui-monospace, SFMono-Regular, Consolas, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .iamccs-audio-board-timeline { + position: relative; + margin-top: 8px; + height: 360px; + overflow: auto; + min-width: 0; + contain: strict; + border: 1px solid rgba(255,255,255,.10); + border-radius: 7px; + background: #0a0e11; + } + .iamccs-audio-board-ruler { + position: sticky; + top: 0; + z-index: 8; + height: 28px; + background: linear-gradient(180deg, rgba(26,35,39,.98), rgba(13,17,20,.96)); + border-bottom: 1px solid rgba(255,255,255,.08); + cursor: crosshair; + touch-action: none; + } + .iamccs-loop-range, + .iamccs-loop-range-ruler { + position: absolute; + pointer-events: none; + background: linear-gradient(90deg, rgba(106,229,132,.14), rgba(244,212,158,.18)); + border-left: 1px solid rgba(98,232,121,.72); + border-right: 1px solid rgba(244,212,158,.72); + box-shadow: inset 0 0 16px rgba(94,225,111,.08); + } + .iamccs-loop-range-ruler { + top: 0; + bottom: 0; + z-index: 1; + } + .iamccs-loop-range { + top: 0; + bottom: 0; + z-index: 1; + } + .iamccs-loop-marker { + position: absolute; + top: 2px; + z-index: 10; + height: 23px; + width: 31px; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(54,153,71,.68); + border-radius: 4px; + color: #0d7a24; + background: #fafff3; + box-shadow: 0 0 0 1px rgba(255,255,255,.18), inset 0 0 0 1px rgba(15,102,24,.18); + font: 950 9px/1 "Courier New", ui-monospace, Consolas, monospace; + pointer-events: none; + } + .iamccs-loop-marker.out { + color: #9b5b00; + border-color: rgba(210,147,52,.75); + } + .iamccs-audio-board.is-fullscreen .iamccs-audio-board-timeline { + flex: 0 0 min(38vh, 410px); + height: min(38vh, 410px); + } + .iamccs-audio-board-track { + position: relative; + height: var(--iamccs-track-height, 72px); + border-bottom: 2px solid rgba(255,255,255,.10); + background-image: + linear-gradient(180deg, rgba(255,255,255,.018), rgba(0,0,0,.10)), + linear-gradient(90deg, rgba(244,212,158,.10) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px); + background-size: 100% 100%, calc(var(--iamccs-px-per-frame, 2px) * 24) 100%, calc(var(--iamccs-px-per-frame, 2px) * 6) 100%; + background-position: 0 0, 224px 0, 224px 0; + } + .iamccs-audio-board-track:nth-child(even) { background-color: rgba(255,255,255,.012); } + .iamccs-audio-board-track-label { + position: sticky; + left: 0; + z-index: 7; + width: 224px; + height: var(--iamccs-track-height, 144px); + display: grid; + grid-template-rows: 21px 26px 40px 25px; + align-content: start; + gap: 4px; + padding: 8px 11px; + color: #f3e2c0; + background: + linear-gradient(90deg, rgba(244,212,158,.12), transparent 34%), + linear-gradient(180deg, rgba(31,39,37,.99), rgba(9,13,15,.99)); + border-right: 2px solid rgba(244,212,158,.28); + border-bottom: 1px solid rgba(244,212,158,.12); + box-shadow: inset -10px 0 18px rgba(0,0,0,.22); + font-size: 9px; + font-weight: 900; + overflow: hidden; + } + .iamccs-audio-board-track-label:hover { + background: + linear-gradient(90deg, rgba(72,122,118,.20), transparent 38%), + linear-gradient(180deg, rgba(35,48,45,.99), rgba(10,15,16,.99)); + } + .iamccs-audio-board-track-label.is-selected { + background: + linear-gradient(90deg, rgba(205,151,76,.32), transparent 45%), + linear-gradient(180deg, rgba(68,48,26,.98), rgba(11,13,14,.99)); + border-right-color: rgba(255,218,151,.82); + outline: 1px solid rgba(255,218,151,.30); + } + .iamccs-track-strip-top, + .iamccs-track-strip-controls, + .iamccs-track-strip-bottom { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + } + .iamccs-track-strip-top { + justify-content: space-between; + padding-bottom: 2px; + border-bottom: 1px solid rgba(255,255,255,.08); + } + .iamccs-track-name { + display: grid; + grid-template-columns: auto 1fr; + align-items: baseline; + gap: 5px; + min-width: 96px; + line-height: 1; + } + .iamccs-track-name span { font-size: 12px; color: #fff0c8; } + .iamccs-track-name small { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + height: 15px; + padding: 0 6px; + color: #aebec2; + background: rgba(255,255,255,.055); + border: 1px solid rgba(255,255,255,.07); + border-radius: 999px; + font-size: 7px; + white-space: nowrap; + } + .iamccs-track-strip-controls { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 4px; + } + .iamccs-track-mini { + min-width: 0; + width: 100%; + height: 24px; + min-height: 24px; + padding: 0; + border: 1px solid rgba(143,208,204,.34); + border-radius: 6px; + background: linear-gradient(180deg, #193236, #112226); + color: #dff7f8; + box-shadow: inset 0 1px 0 rgba(255,255,255,.08), inset 0 -8px 12px rgba(0,0,0,.18); + font: 950 9px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + cursor: pointer; + } + .iamccs-track-mini.is-active { + color: #161109; + border-color: #ffe0a4; + background: linear-gradient(180deg, #f3d99b, #b98343); + } + .iamccs-track-color-chip { + min-width: 0; + width: 100%; + height: 24px; + min-height: 24px; + padding: 0; + border: 1px solid rgba(255,226,168,.42); + border-radius: 6px; + background: + linear-gradient(180deg, rgba(255,255,255,.18), rgba(0,0,0,.18)), + var(--iamccs-track-color, #315f8f); + color: #fff8d8; + font: 950 9px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + cursor: pointer; + box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 0 0 1px rgba(0,0,0,.18); + } + .iamccs-track-fx-select { + position: relative; + z-index: 20; + width: 100%; + min-width: 0; + height: 25px; + min-height: 25px; + padding: 0 8px; + border: 1px solid rgba(143,208,204,.38); + border-radius: 6px; + background: #071012; + color: #e9ffff; + font: 900 9px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + } + .iamccs-audio-board-meter, + .iamccs-master-meter { + position: relative; + height: 9px; + border: 1px solid rgba(255,255,255,.18); + border-radius: 999px; + overflow: hidden; + background: linear-gradient(90deg, rgba(255,255,255,.06), rgba(0,0,0,.45)); + } + .iamccs-audio-board-meter { width: 50px; } + .iamccs-master-meter { width: 180px; height: 12px; } + .iamccs-audio-board-master > .iamccs-master-meter { flex: 0 0 180px; } + .iamccs-audio-board-master { + min-height: 58px; + border-color: rgba(244,212,158,.24); + background: linear-gradient(180deg, rgba(25,32,35,.94), rgba(7,10,12,.94)); + flex-wrap: nowrap; + } + .iamccs-master-title { + min-width: 92px; + color: #ffe2a8; + font-size: 11px; + font-weight: 950; + } + .iamccs-master-controls { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; + flex: 1 1 auto; + min-width: 0; + } + .iamccs-master-fx-select { + position: relative; + z-index: 20; + width: 118px; + height: 25px; + border: 1px solid rgba(143,208,204,.30); + border-radius: 5px; + background: #091113; + color: #e9ffff; + font: 850 9px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + } + .iamccs-master-toggle { + min-height: 25px; + padding: 0 8px; + border: 1px solid rgba(143,208,204,.32); + border-radius: 5px; + background: rgba(15,26,28,.95); + color: #a9c2c5; + font-size: 9px; + font-weight: 900; + cursor: pointer; + } + .iamccs-master-toggle.is-active { + color: #101315; + border-color: #ffe6ae; + background: linear-gradient(180deg, #f2d79a, #c79e59); + } + .iamccs-audio-board-meter > i, + .iamccs-master-meter > i { + display: block; + width: 0%; + height: 100%; + background: linear-gradient(90deg, #55c7b9 0%, #cfe37c 58%, #f0b857 78%, #dc5c42 100%); + transition: width .045s linear; + } + .iamccs-meter-readout { + min-width: 52px; + color: #a9bec4; + font: 900 10px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + } + .iamccs-master-crt { + margin-left: auto; + flex: 0 0 132px; + width: 132px; + min-width: 132px; + height: 34px; + display: grid; + place-items: center; + padding: 3px 7px; + color: #008a1f; + background: #f8fff0; + border: 2px solid #b7cfad; + border-radius: 5px; + box-shadow: inset 0 0 0 1px rgba(0,130,28,.22), inset 0 -6px 12px rgba(37,100,32,.08), 0 1px 0 rgba(255,255,255,.14); + font: 950 11px/1 "Courier New", ui-monospace, Consolas, monospace; + white-space: nowrap; + text-align: center; + overflow: hidden; + text-shadow: 0 0 3px rgba(0,142,31,.22); + } + .iamccs-audio-clip { + position: absolute; + top: 14px; + height: calc(var(--iamccs-track-height, 72px) - 28px); + min-width: 18px; + border: 1px solid rgba(143, 208, 204, .56); + border-radius: 6px; + overflow: hidden; + background: linear-gradient(180deg, rgba(40,82,116,.98), rgba(24,54,81,.98)); + box-shadow: inset 0 1px 0 rgba(255,255,255,.10), 0 4px 10px rgba(0,0,0,.22); + cursor: grab; + user-select: none; + touch-action: none; + } + .iamccs-audio-clip:active { cursor: grabbing; } + .iamccs-audio-clip.is-trimming { cursor: ew-resize; } + .iamccs-audio-clip.is-moving { cursor: grabbing; } + .iamccs-audio-clip.is-selected { border-color: #f4d49e; box-shadow: 0 0 0 1px rgba(244,212,158,.45), 0 4px 12px rgba(0,0,0,.30); } + .iamccs-audio-clip.is-muted { opacity: .46; } + .iamccs-audio-clip-name { + position: absolute; + left: 12px; + top: 5px; + right: 12px; + z-index: 2; + color: #fff2cf; + font: 9px/1 monospace; + font-weight: 900; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-shadow: 0 1px 2px #000; + } + .iamccs-audio-clip-time { + position: absolute; + left: 12px; + bottom: 5px; + z-index: 2; + color: #b6ced1; + font: 8px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + text-shadow: 0 1px 2px #000; + } + .iamccs-audio-clip-trim { + position: absolute; + right: 10px; + bottom: 5px; + z-index: 2; + color: #ffe2a8; + font: 8px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + text-shadow: 0 1px 2px #000; + } + .iamccs-clip-source-marker { + position: absolute; + top: 0; + bottom: 0; + z-index: 28; + display: none; + min-width: 72px; + padding: 4px 6px; + color: #111712; + background: linear-gradient(180deg, rgba(255,226,168,.98), rgba(211,151,70,.92)); + border-left: 1px solid rgba(255,255,255,.75); + border-right: 1px solid rgba(0,0,0,.35); + font: 900 8px/1.1 ui-monospace, SFMono-Regular, Consolas, monospace; + pointer-events: none; + text-shadow: none; + box-shadow: 0 0 12px rgba(244,212,158,.38); + } + .iamccs-audio-clip svg, + .iamccs-audio-clip canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: .92; + pointer-events: none; + } + .iamccs-audio-clip canvas { opacity: 1; } + .iamccs-clip-handle { + position: absolute; + top: 0; + bottom: 0; + z-index: 30; + width: 24px; + background: linear-gradient(90deg, rgba(244,212,158,.34), rgba(244,212,158,.08)); + cursor: ew-resize; + touch-action: none; + pointer-events: auto; + } + .iamccs-clip-handle.left { left: 0; border-right: 1px solid rgba(244,212,158,.50); } + .iamccs-clip-handle.right { right: 0; border-left: 1px solid rgba(244,212,158,.50); } + .iamccs-clip-handle::after { + content: ""; + position: absolute; + top: 8px; + bottom: 8px; + width: 2px; + background: rgba(255,226,168,.82); + box-shadow: 5px 0 0 rgba(255,226,168,.35); + } + .iamccs-clip-handle.left::after { left: 6px; } + .iamccs-clip-handle.right::after { right: 11px; } + .iamccs-playhead { + position: absolute; + top: 0; + bottom: 0; + z-index: 6; + width: 2px; + background: #ffdc87; + box-shadow: 0 0 0 1px rgba(0,0,0,.45), 0 0 10px rgba(255,220,135,.35); + pointer-events: none; + } + .iamccs-playhead::before { + content: ""; + position: absolute; + top: 0; + left: -5px; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 8px solid #ffdc87; + } + .iamccs-clip-action-bar { + margin-top: 8px; + padding: 7px; + background: linear-gradient(180deg, rgba(23,31,35,.94), rgba(8,12,15,.94)); + border: 1px solid rgba(244,212,158,.16); + border-radius: 7px; + } + .iamccs-clip-action-bar strong { + color: #ffe2a8; + font-size: 10px; + font-weight: 950; + margin-right: 4px; + } + .iamccs-audio-board-editor { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(78px, 1fr)); + gap: 6px; + margin-top: 8px; + padding: 8px; + border: 1px solid rgba(255,226,168,.48); + border-radius: 7px; + background: + linear-gradient(180deg, rgba(126,91,39,.96), rgba(57,40,18,.97)), + repeating-linear-gradient(90deg, rgba(255,255,255,.035) 0 1px, transparent 1px 16px); + box-shadow: inset 0 1px 0 rgba(255,247,205,.10), 0 6px 18px rgba(0,0,0,.18); + } + .iamccs-audio-board-editor::before { + content: "CLIP VALUES"; + grid-column: 1 / -1; + color: #fff1ba; + font: 950 9px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + letter-spacing: .02em; + text-transform: uppercase; + text-shadow: 0 1px 0 rgba(0,0,0,.45); + } + .iamccs-audio-board-editor label { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + color: #f9ddb0; + font-size: 8px; + font-weight: 900; + text-transform: uppercase; + } + .iamccs-audio-board-editor input, + .iamccs-audio-board-editor select { + width: 100%; + min-width: 0; + height: 23px; + border: 1px solid rgba(255,232,169,.28); + border-radius: 4px; + background: #10120d; + color: #fff7d8; + font-size: 10px; + padding: 2px 5px; + } + .iamccs-audio-board-editor input[type="number"], + .iamccs-master-controls input[type="number"] { + cursor: ew-resize; + } + .iamccs-audio-board-editor input[type="checkbox"] { width: 16px; height: 16px; } + .iamccs-editor-wide { grid-column: span 2; } + .iamccs-audio-board-lower { + display: grid; + grid-template-columns: minmax(280px, .36fr) minmax(900px, 1.64fr); + gap: 8px; + margin: 8px 0 12px; + flex: 0 0 390px; + height: 390px; + min-height: 0; + min-width: 0; + overflow: hidden; + } + .iamccs-audio-board-lower.no-monitor { grid-template-columns: 1fr; } + .iamccs-audio-board.is-fullscreen .iamccs-audio-board-lower { + flex: 1 1 auto; + height: auto; + min-height: 0; + overflow: hidden; + } + .iamccs-event-console, + .iamccs-inline-efx { + min-width: 0; + min-height: 0; + border: 1px solid rgba(244,212,158,.16); + border-radius: 7px; + background: + linear-gradient(180deg, rgba(18,26,27,.88), rgba(4,8,10,.94)), + repeating-linear-gradient(0deg, rgba(255,255,255,.025) 0 1px, transparent 1px 4px); + box-shadow: inset 0 0 0 1px rgba(255,255,255,.035); + overflow: hidden; + } + .iamccs-audio-board.is-fullscreen .iamccs-inline-efx, + .iamccs-audio-board.is-fullscreen .iamccs-event-console { + height: 100%; + } + .iamccs-event-console-head, + .iamccs-inline-efx-head { + height: 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 8px; + color: #ffe2a8; + background: linear-gradient(180deg, rgba(42,55,51,.94), rgba(13,17,19,.94)); + border-bottom: 1px solid rgba(244,212,158,.12); + font: 950 9px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + text-transform: uppercase; + } + .iamccs-event-console-body { + height: calc(100% - 24px); + min-height: 110px; + padding: 8px; + color: #a7f1ce; + font: 10px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; + overflow: auto; + text-shadow: 0 0 6px rgba(85,199,185,.15); + white-space: pre-wrap; + } + .iamccs-event-console-body b { color: #f8d890; } + .iamccs-inline-efx-grid { + display: block; + padding: 8px; + min-width: 0; + height: calc(100% - 24px); + overflow: hidden; + } + .iamccs-device-chain { + min-width: 0; + width: 100%; + height: 100%; + display: flex; + gap: 7px; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 8px; + align-items: stretch; + contain: layout paint; + } + .iamccs-device-module { + flex: 0 0 780px; + min-width: 780px; + display: flex; + gap: 9px; + align-items: stretch; + } + .iamccs-audio-device { + flex: 0 0 330px; + min-height: 340px; + padding: 9px; + border: 1px solid rgba(244,212,158,.24); + border-radius: 7px; + background: + radial-gradient(circle at 26px 20px, rgba(255,224,164,.20), transparent 22px), + linear-gradient(180deg, var(--device-hi, #5a3c29), var(--device-mid, #2c261f) 56%, var(--device-lo, #0b0d0c)); + box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 5px 14px rgba(0,0,0,.24); + color: #f7e6be; + } + .iamccs-audio-device.iamccs-device-eq { + flex-basis: 350px; + } + .iamccs-audio-device.iamccs-device-eq .iamccs-device-knobs { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + } + .iamccs-audio-device.iamccs-device-eq .iamccs-device-knob i { + width: 34px; + height: 34px; + } + .iamccs-audio-device.iamccs-device-eq .iamccs-device-knob input[type="range"] { + width: 58px; + } + .iamccs-device-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + margin-bottom: 8px; + font: 950 9px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + text-transform: uppercase; + } + .iamccs-device-head-actions { + display: flex; + align-items: center; + gap: 5px; + } + .iamccs-device-power { + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid rgba(255,226,168,.48); + background: radial-gradient(circle at 40% 35%, #ffe6a7, #b97536 42%, #2d1d13 72%); + box-shadow: 0 0 9px rgba(244,178,88,.28); + cursor: pointer; + } + .iamccs-device-power.is-off { + background: radial-gradient(circle at 40% 35%, #6d7774, #263033 48%, #101315 74%); + box-shadow: none; + } + .iamccs-device-lamp { + width: 9px; + height: 9px; + border-radius: 50%; + border: 1px solid rgba(255,226,168,.32); + background: radial-gradient(circle at 35% 35%, #eaffc8, #72ff8d 45%, #214122 78%); + box-shadow: 0 0 9px rgba(114,255,141,.72), 0 0 2px rgba(255,255,255,.48) inset; + } + .iamccs-device-lamp.is-off { + background: radial-gradient(circle at 35% 35%, #68716b, #1c2422 58%, #090d0c 82%); + box-shadow: inset 0 0 2px rgba(255,255,255,.18); + opacity: .72; + } + .iamccs-device-remove { + width: 20px; + height: 20px; + min-height: 20px; + padding: 0; + border-radius: 5px; + color: #ffdfd7; + background: linear-gradient(180deg, rgba(112,49,43,.95), rgba(56,20,18,.95)); + border-color: rgba(255,128,112,.48); + font: 950 12px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + cursor: pointer; + } + .iamccs-track-knob-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + align-items: center; + min-height: 42px; + } + .iamccs-track-knob-wrap { + display: grid; + grid-template-columns: 30px 1fr; + grid-template-rows: 12px 14px; + align-items: center; + column-gap: 6px; + color: #d9c79f; + font: 850 7px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + text-transform: uppercase; + min-width: 0; + } + .iamccs-track-knob-label { + grid-column: 2; + grid-row: 1; + color: #9fb7bc; + font-size: 7px; + letter-spacing: 0; + } + .iamccs-track-knob { + grid-column: 1; + grid-row: 1 / span 2; + width: 30px; + height: 30px; + min-height: 30px; + padding: 0; + border-radius: 50%; + border: 1px solid rgba(255,226,168,.42); + background: + conic-gradient(from 220deg, rgba(255,226,168,.12) 0 18%, rgba(255,226,168,.72) var(--iamccs-knob-fill, 50%), rgba(0,0,0,.38) var(--iamccs-knob-fill, 50%) 78%, transparent 78% 100%), + radial-gradient(circle at 35% 28%, #8a8270, #32342a 55%, #090a09 100%); + box-shadow: inset 0 1px 2px rgba(255,255,255,.16), inset 0 -3px 6px rgba(0,0,0,.52), 0 1px 4px rgba(0,0,0,.38); + cursor: ew-resize; + position: relative; + } + .iamccs-track-knob::after { + content: ""; + position: absolute; + left: 50%; + top: 4px; + width: 2px; + height: 9px; + border-radius: 999px; + background: #ffe2a8; + transform-origin: 50% 11px; + transform: translateX(-50%) rotate(var(--iamccs-knob-angle, 0deg)); + box-shadow: 0 0 4px rgba(255,226,168,.45); + } + .iamccs-track-knob-readout { + grid-column: 2; + grid-row: 2; + color:#ffe2a8; + font-size:9px; + white-space:nowrap; + overflow:hidden; + text-overflow:ellipsis; + } + .iamccs-eq-switches { + display: flex; + gap: 5px; + margin: 0 0 8px; + } + .iamccs-eq-switches button { + flex: 1 1 0; + min-height: 22px; + padding: 0 5px; + color: #a7bec1; + background: linear-gradient(180deg, rgba(18,28,31,.94), rgba(7,12,13,.96)); + border-color: rgba(143,208,204,.25); + font-size: 8px; + } + .iamccs-eq-switches button.is-active { + color: #161109; + background: linear-gradient(180deg, #f2d79a, #b9823f); + border-color: #ffe6ae; + } + .iamccs-device-knobs { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 7px 9px; + } + .iamccs-device-knob { + display: grid; + justify-items: center; + gap: 3px; + color: #c9b890; + font: 800 8px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + text-transform: uppercase; + } + .iamccs-device-knob i { + display: block; + width: 38px; + height: 38px; + border-radius: 50%; + border: 1px solid rgba(255,226,168,.36); + background: + linear-gradient(var(--knob-angle, 140deg), transparent 0 49%, rgba(255,226,168,.75) 50% 53%, transparent 54%), + radial-gradient(circle at 35% 28%, #5d5b50, #24251f 58%, #090a09 100%); + box-shadow: inset 0 2px 2px rgba(255,255,255,.10), inset 0 -3px 5px rgba(0,0,0,.42); + } + .iamccs-device-knob input[type="range"] { + width: 68px; + height: 12px; + min-height: 12px; + padding: 0; + accent-color: #d6a75e; + background: transparent; + border: 0; + cursor: ew-resize; + } + .iamccs-device-knob em { + color: #ffe2a8; + font-style: normal; + font-size: 8px; + } + .iamccs-inline-efx-panel { + flex: 0 0 435px; + min-width: 435px; + border: 1px solid rgba(255,255,255,.09); + border-radius: 6px; + background: #071012; + overflow: hidden; + } + .iamccs-inline-efx-panel label { + display: block; + padding: 5px 7px 0; + color: #ffe2a8; + font: 950 8px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + text-transform: uppercase; + } + .iamccs-inline-efx-canvas { + display: block; + width: 100%; + height: 318px; + cursor: crosshair; + } + .iamccs-efx-placeholder { + min-height: 220px; + display: grid; + place-items: center; + color: #8fa5ac; + border: 1px dashed rgba(244,212,158,.20); + border-radius: 6px; + background: repeating-linear-gradient(135deg, rgba(255,255,255,.025) 0 1px, transparent 1px 9px); + font: 900 10px/1.35 ui-monospace, SFMono-Regular, Consolas, monospace; + text-align: center; + } + .iamccs-audio-board.is-fullscreen .iamccs-inline-efx-canvas, + .iamccs-audio-board.is-fullscreen .iamccs-efx-placeholder { height: 340px; } + .iamccs-audio-board-track-label.is-selected { + border-right-color: #ffe2a8; + box-shadow: inset 0 0 0 1px rgba(244,212,158,.38), 0 0 16px rgba(244,212,158,.12); + background: linear-gradient(180deg, rgba(65,52,35,.99), rgba(13,15,15,.99)); + } + @media (max-width: 980px) { + .iamccs-audio-board-lower { grid-template-columns: 1fr; } + .iamccs-inline-efx-grid { grid-template-columns: 1fr; } + } + `; + document.head.appendChild(style); +} + +const IAMCCS_AUDIO_BOARD_FIXED_WIDTH = 1880; +const IAMCCS_AUDIO_BOARD_FIXED_HEIGHT = 1290; + +function renderAudioBoardArranger(node) { + ensureAudioBoardArrangerStyles(); + if (node._iamccsAudioBoardReady) { + const fixed = [IAMCCS_AUDIO_BOARD_FIXED_WIDTH, IAMCCS_AUDIO_BOARD_FIXED_HEIGHT]; + node._iamccsAudioBoardFixedSize = fixed; + node.resizable = false; + node.resizeable = false; + node.flags = { ...(node.flags || {}), resizable: false }; + node.min_size = fixed.slice(); + const domWidget = (node.widgets || []).find((w) => w?.type === "iamccs_audio_board_arranger" || w?.name === "AudioBoard Arranger"); + const existingRoot = domWidget?.element || domWidget?.inputEl || null; + if (domWidget) { + domWidget.computeSize = () => existingRoot?._iamccsAudioFullscreenState + ? [IAMCCS_AUDIO_BOARD_FIXED_WIDTH, 24] + : [IAMCCS_AUDIO_BOARD_FIXED_WIDTH, IAMCCS_AUDIO_BOARD_FIXED_HEIGHT - 70]; + } + if (Number(node.size?.[0] || 0) !== fixed[0] || Number(node.size?.[1] || 0) !== fixed[1]) { + if (typeof node.setSize === "function") node.setSize(fixed.slice()); + else node.size = fixed.slice(); + } + try { node.setDirtyCanvas?.(true, true); app.graph?.setDirtyCanvas?.(true, true); } catch {} + return; + } + console.info("[IAMCCS AudioBoardArranger] render start", { + nodeId: node?.id, + type: node?.type, + comfyClass: node?.comfyClass, + addDOMWidget: typeof node?.addDOMWidget, + widgets: (node?.widgets || []).map((w) => ({ name: w?.name, type: w?.type, hidden: w?.hidden })), + }); + const dataWidget = findWidget(node, "arranger_data"); + console.info("[IAMCCS AudioBoardArranger] arranger_data widget", { + found: Boolean(dataWidget), + type: dataWidget?.type, + valueLength: String(dataWidget?.value || "").length, + }); + hideWidget(dataWidget); + const fpsWidget = findWidget(node, "frame_rate"); + const syncPolicyWidget = findWidget(node, "sync_policy"); + hideWidget(fpsWidget); + hideWidget(syncPolicyWidget); + const root = document.createElement("div"); + root.className = "iamccs-audio-board"; + root.tabIndex = 0; + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = "audio/*"; + fileInput.multiple = true; + fileInput.style.display = "none"; + root.appendChild(fileInput); + + const LABEL_W = 224; + const VIEWPORT_SECONDS = 26; + const DEFAULT_SECONDS = VIEWPORT_SECONDS; + const AUDIO_BOARD_FIXED_WIDTH = IAMCCS_AUDIO_BOARD_FIXED_WIDTH; + const AUDIO_BOARD_FIXED_HEIGHT = IAMCCS_AUDIO_BOARD_FIXED_HEIGHT; + const EFFECT_CHOICES = [ + ["eq", "EQ"], + ["compressor", "Compressor"], + ["limiter", "Limiter"], + ["gate", "Gate"], + ["reverb", "Reverb"], + ["delay", "Delay"], + ["saturator", "Saturator"], + ["utility", "Utility"], + ["stereo", "Stereo"], + ["deesser", "De-esser"], + ["transient", "Transient"], + ["tape", "Tape"], + ["chorus", "Chorus"], + ]; + const TRACK_COLORS = ["#315f8f", "#2f7f71", "#7a5a34", "#6b4c80", "#8a4b45", "#556b36", "#3c6478", "#7a6738"]; + const normalizeTrackColor = (color, index = 0) => /^#[0-9a-f]{6}$/i.test(String(color || "")) ? String(color) : TRACK_COLORS[index % TRACK_COLORS.length]; + const nextTrackColor = (color, index = 0) => { + const current = normalizeTrackColor(color, index); + const pos = TRACK_COLORS.findIndex((item) => item.toLowerCase() === current.toLowerCase()); + return TRACK_COLORS[((pos >= 0 ? pos : index) + 1) % TRACK_COLORS.length]; + }; + const DEFAULT_MASTER_CHAIN = [ + { id: "master_eq", type: "eq", enabled: true, amount: .5, params: { low: 0, mid: 0, high: 0, q: 1.2 } }, + { id: "master_comp", type: "compressor", enabled: true, amount: .45, params: { threshold: -18, ratio: 4, attack: 6, release: 180, knee: 12, makeup: 0 } }, + { id: "master_limit", type: "limiter", enabled: true, amount: 1, params: { input: 0, ceiling: -1, lookahead: 3, release: 120, output: 0, softclip: .25 } }, + ]; + const audioBuffers = new Map(); + const audioUrls = new Map(); + const waveformLoading = new Set(); + let selectedId = ""; + let audioContext = null; + let shotboardSyncTimer = 0; + let liveStateWriteTimer = 0; + const shotboardSyncSignatures = new Map(); + let transport = { + playing: false, + playhead: 0, + startedAt: 0, + sources: [], + analysers: new Map(), + masterAnalyser: null, + lastMeterSnapshot: { tracks: {}, master: { peak: 0, rms: 0 }, playing: false, playhead: 0 }, + raf: 0, + efxFrame: 0, + lastMeterAt: 0, + pendingLoopRestart: false, + dom: null, + helper: "Ready. Import audio, drag clips, trim with handles, then play.", + }; + const markCanvasDirty = (full = false) => { + try { + node.setDirtyCanvas?.(true, Boolean(full)); + app.graph?.setDirtyCanvas?.(true, Boolean(full)); + } catch {} + }; + const isLiveSyncReason = (reason) => /^(live_|fx_|eq_point|compressor_point)/.test(String(reason || "")); + const scheduleSilentStateWrite = (reason = "live") => { + if (liveStateWriteTimer) window.clearTimeout(liveStateWriteTimer); + liveStateWriteTimer = window.setTimeout(() => { + liveStateWriteTimer = 0; + writeState(reason, false, { quiet: true }); + }, 140); + }; + const toggleAudioFullscreen = () => { + if (root._iamccsAudioFullscreenState) { + const stateFs = root._iamccsAudioFullscreenState; + root.classList.remove("is-fullscreen"); + root.style.cssText = stateFs.rootCss; + if (stateFs.placeholder?.parentNode) { + stateFs.placeholder.parentNode.insertBefore(root, stateFs.placeholder); + stateFs.placeholder.remove(); + } + stateFs.overlay?.remove(); + document.removeEventListener("keydown", stateFs.keyHandler); + root._iamccsAudioFullscreenState = null; + root.dispatchEvent(new CustomEvent("iamccs:audio-fullscreen", { detail: { open: false } })); + markCanvasDirty(true); + return; + } + const parent = root.parentNode; + if (!parent) return; + const placeholder = document.createComment("iamccs-audio-board-home"); + parent.insertBefore(placeholder, root); + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;inset:0;z-index:999991;background:rgba(4,8,11,.84);display:flex;flex-direction:column;padding:18px;box-sizing:border-box;pointer-events:auto;"; + const bar = document.createElement("div"); + bar.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 0 10px;color:#e8eef2;font:12px Inter,Arial,sans-serif;"; + const title = document.createElement("div"); + title.textContent = "IAMCCS AudioBoard Arranger Full Editor"; + title.style.cssText = "font-weight:900;color:#fff;"; + const close = document.createElement("button"); + close.type = "button"; + close.textContent = "Close Editor"; + close.style.cssText = "min-height:30px;padding:0 12px;border:1px solid rgba(244,212,158,.55);border-radius:6px;background:#284a4e;color:#fff;font-weight:900;cursor:pointer;"; + bar.append(title, close); + const panel = document.createElement("div"); + panel.style.cssText = "flex:1;min-height:0;overflow:hidden;border:1px solid #557062;border-radius:8px;background:#12191f;box-shadow:0 22px 80px rgba(0,0,0,.65);padding:10px;box-sizing:border-box;"; + const keyHandler = (event) => { if (event.key === "Escape") toggleAudioFullscreen(); }; + document.addEventListener("keydown", keyHandler); + close.onclick = () => toggleAudioFullscreen(); + root._iamccsAudioFullscreenState = { overlay, placeholder, rootCss: root.style.cssText, keyHandler }; + root.classList.add("is-fullscreen"); + root.style.cssText += "max-height:none!important;height:calc(100vh - 92px)!important;min-height:calc(100vh - 92px)!important;overflow:hidden!important;"; + panel.appendChild(root); + overlay.append(bar, panel); + document.body.appendChild(overlay); + root.dispatchEvent(new CustomEvent("iamccs:audio-fullscreen", { detail: { open: true } })); + markCanvasDirty(true); + }; + + const newId = (prefix) => `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; + const fps = () => Math.max(1, Number(fpsWidget?.value || state.frame_rate || 24) || 24); + const framesToSeconds = (frames) => Number(frames || 0) / fps(); + const secondsToFrames = (seconds) => Math.max(0, Math.round(Number(seconds || 0) * fps())); + const fmtTime = (frames) => { + const total = Math.max(0, framesToSeconds(frames)); + const minutes = Math.floor(total / 60); + const seconds = Math.floor(total % 60); + const hundredths = Math.floor((total - Math.floor(total)) * 100); + return `${minutes}:${String(seconds).padStart(2, "0")}.${String(hundredths).padStart(2, "0")}`; + }; + const parseState = () => { + const fallback = { + schema: "iamccs.audio_board_arranger", + schema_version: 1, + audioSegments: [], + audioTrackCount: 4, + masterAudioGain: 1, + masterAudioNormalize: false, + masterBus: { limiter: true, ceilingDb: -1, compressor: .45, width: 1, reverbSend: 0, delaySend: 0, effectChain: JSON.parse(JSON.stringify(DEFAULT_MASTER_CHAIN)) }, + duration_seconds: DEFAULT_SECONDS, + frame_rate: Number(fpsWidget?.value || 24), + status: { edits: [] }, + view: { timeZoom: 1, trackHeight: 144, tool: "cursor", visibleSeconds: VIEWPORT_SECONDS }, + showEventMonitor: false, + showClipValues: false, + showMultiGeneration: false, + trackSettings: [], + audioBusMode: "all_tracks", + onlyFirstTrack: false, + loopEnabled: false, + loopInFrame: 0, + loopOutFrame: 0, + selectedMixer: { type: "master", track: 0 }, + }; + try { + const data = JSON.parse(String(dataWidget?.value || "")); + const out = { ...fallback, ...(data && typeof data === "object" ? data : {}) }; + const storedDuration = Math.max(0, Number(out.duration_seconds || 0)); + const storedFps = Math.max(1, Number(out.frame_rate || fallback.frame_rate || 24) || 24); + const clipEndFrames = Math.max(0, ...(Array.isArray(out.audioSegments) ? out.audioSegments : []).map((seg) => { + return Math.max(0, Number(seg?.start || 0)) + Math.max(1, Number(seg?.length || 1)); + })); + const clipDuration = clipEndFrames > 0 ? clipEndFrames / storedFps : 0; + const computedDuration = Math.max(VIEWPORT_SECONDS, clipDuration); + out.duration_seconds = storedDuration > 0 && storedDuration <= computedDuration * 1.35 + ? Math.max(VIEWPORT_SECONDS, storedDuration) + : computedDuration; + out.audioTrackCount = Math.max(1, Number(out.audioTrackCount || 4)); + out.masterBus = { ...fallback.masterBus, ...(out.masterBus && typeof out.masterBus === "object" ? out.masterBus : {}) }; + out.masterBus.effectChain = Array.isArray(out.masterBus.effectChain) + ? out.masterBus.effectChain + : JSON.parse(JSON.stringify(DEFAULT_MASTER_CHAIN)); + out.trackSettings = Array.isArray(out.trackSettings) ? out.trackSettings : []; + out.audioBusMode = (out.audioBusMode === "only_first" || out.onlyFirstTrack) ? "only_first" : "all_tracks"; + out.onlyFirstTrack = out.audioBusMode === "only_first"; + out.loopEnabled = Boolean(out.loopEnabled); + out.loopInFrame = Math.max(0, Math.round(Number(out.loopInFrame || 0))); + out.loopOutFrame = Math.max(0, Math.round(Number(out.loopOutFrame || 0))); + out.selectedMixer = out.selectedMixer && typeof out.selectedMixer === "object" ? out.selectedMixer : fallback.selectedMixer; + out.showEventMonitor = Boolean(out.showEventMonitor); + out.showClipValues = Boolean(out.showClipValues); + out.showMultiGeneration = Boolean(out.showMultiGeneration); + return out; + } catch { + return fallback; + } + }; + let state = parseState(); + const segments = () => Array.isArray(state.audioSegments) ? state.audioSegments : (state.audioSegments = []); + const trackSettings = (track) => { + state.trackSettings = Array.isArray(state.trackSettings) ? state.trackSettings : []; + const index = Math.max(0, Number(track || 0)); + state.trackSettings[index] = { + mute: false, + solo: false, + normalize: false, + reverb: false, + reverbSend: 0, + volume: 1, + pan: 0, + color: normalizeTrackColor("", index), + lock: false, + effectChain: [{ id: `track_${index}_eq`, type: "eq", enabled: true, amount: .5, params: { low: 0, mid: 0, high: 0, q: 1.2 } }], + ...(state.trackSettings[index] && typeof state.trackSettings[index] === "object" ? state.trackSettings[index] : {}), + }; + state.trackSettings[index].effectChain = Array.isArray(state.trackSettings[index].effectChain) + ? state.trackSettings[index].effectChain + : [{ id: `track_${index}_eq`, type: "eq", enabled: true, amount: .5, params: { low: 0, mid: 0, high: 0, q: 1.2 } }]; + return state.trackSettings[index]; + }; + const selectedMixer = () => { + state.selectedMixer = state.selectedMixer && typeof state.selectedMixer === "object" ? state.selectedMixer : { type: "master", track: 0 }; + state.selectedMixer.type = state.selectedMixer.type === "track" ? "track" : "master"; + state.selectedMixer.track = Math.max(0, Number(state.selectedMixer.track || 0)); + return state.selectedMixer; + }; + const effectParamSpecs = (type) => ({ + eq: [ + ["low", "LOW", -24, 24, .5, 0, "dB"], + ["mid", "MID", -24, 24, .5, 0, "dB"], + ["high", "HIGH", -24, 24, .5, 0, "dB"], + ["q", "Q", .2, 8, .1, 1.2, ""], + ["lowCutFreq", "LC Hz", 20, 400, 5, 80, "Hz"], + ["lowCutLevel", "LC LVL", -48, 0, 1, -30, "dB"], + ["highCutFreq", "HC Hz", 2000, 22000, 250, 12000, "Hz"], + ["highCutLevel", "HC LVL", -48, 0, 1, -30, "dB"], + ], + compressor: [ + ["threshold", "THR", -60, 0, 1, -18, "dB"], + ["ratio", "RATIO", 1, 20, .5, 4, ":1"], + ["attack", "ATK", 1, 100, 1, 6, "ms"], + ["release", "REL", 20, 800, 10, 180, "ms"], + ["knee", "KNEE", 0, 36, 1, 12, "dB"], + ["makeup", "MAKE", -12, 12, .5, 0, "dB"], + ], + limiter: [ + ["input", "IN", -12, 12, .5, 0, "dB"], + ["ceiling", "CEIL", -12, 0, .5, -1, "dB"], + ["lookahead", "LOOK", 0, 10, .5, 3, "ms"], + ["release", "REL", 20, 800, 10, 120, "ms"], + ["output", "OUT", -12, 6, .5, 0, "dB"], + ["softclip", "SOFT", 0, 1, .05, .25, ""], + ], + gate: [ + ["threshold", "THR", -80, 0, 1, -45, "dB"], + ["attack", "ATK", 1, 80, 1, 8, "ms"], + ["hold", "HOLD", 0, 400, 10, 80, "ms"], + ["release", "REL", 20, 1200, 10, 260, "ms"], + ], + reverb: [ + ["size", "SIZE", 0, 1, .01, .45, ""], + ["decay", "DECAY", .1, 8, .1, 1.8, "s"], + ["damp", "DAMP", 0, 1, .01, .35, ""], + ["mix", "MIX", 0, 1, .01, .18, ""], + ], + delay: [ + ["time", "TIME", .03, 1.5, .01, .18, "s"], + ["feedback", "FDBK", 0, .9, .01, .28, ""], + ["filter", "FILT", 200, 12000, 100, 4200, "Hz"], + ["mix", "MIX", 0, 1, .01, .2, ""], + ], + saturator: [ + ["drive", "DRIVE", 0, 24, .5, 4, "dB"], + ["color", "COLOR", 0, 1, .01, .45, ""], + ["tone", "TONE", 200, 12000, 100, 2600, "Hz"], + ["mix", "MIX", 0, 1, .01, .5, ""], + ], + utility: [ + ["gain", "GAIN", -24, 24, .5, 0, "dB"], + ["width", "WIDTH", 0, 2, .05, 1, ""], + ["pan", "PAN", -1, 1, .05, 0, ""], + ["mono", "MONO", 0, 1, 1, 0, ""], + ], + stereo: [ + ["width", "WIDTH", 0, 2, .05, 1.15, ""], + ["angle", "ANGLE", -45, 45, 1, 0, "deg"], + ["bassMono", "BASS", 0, 300, 10, 120, "Hz"], + ["mix", "MIX", 0, 1, .01, .5, ""], + ], + deesser: [ + ["freq", "FREQ", 2500, 10000, 100, 6200, "Hz"], + ["threshold", "THR", -60, 0, 1, -26, "dB"], + ["range", "RANGE", 0, 24, .5, 8, "dB"], + ["mix", "MIX", 0, 1, .01, .75, ""], + ], + transient: [ + ["attack", "ATK", -1, 1, .05, .15, ""], + ["sustain", "SUS", -1, 1, .05, 0, ""], + ["drive", "DRIVE", 0, 12, .5, 0, "dB"], + ["mix", "MIX", 0, 1, .01, .6, ""], + ], + tape: [ + ["bias", "BIAS", 0, 1, .01, .45, ""], + ["wow", "WOW", 0, 1, .01, .08, ""], + ["sat", "SAT", 0, 1, .01, .35, ""], + ["hiss", "HISS", 0, 1, .01, .04, ""], + ], + chorus: [ + ["rate", "RATE", .05, 8, .05, .8, "Hz"], + ["depth", "DEPTH", 0, 1, .01, .35, ""], + ["phase", "PHASE", 0, 180, 1, 90, "deg"], + ["mix", "MIX", 0, 1, .01, .22, ""], + ], + }[type] || [ + ["amount", "AMT", 0, 1, .01, .5, ""], + ["mix", "MIX", 0, 1, .01, .5, ""], + ]); + const normalizeEffect = (fx) => { + const out = fx && typeof fx === "object" ? fx : {}; + out.id = out.id || newId(`fx_${out.type || "device"}`); + out.type = String(out.type || "utility"); + out.enabled = out.enabled !== false; + out.params = out.params && typeof out.params === "object" ? out.params : {}; + for (const [key, , , , , fallback] of effectParamSpecs(out.type)) { + if (out.params[key] == null || Number.isNaN(Number(out.params[key]))) out.params[key] = fallback; + } + if (out.type === "eq") { + out.params.lowCut = Boolean(out.params.lowCut); + out.params.highCut = Boolean(out.params.highCut); + out.params.lowCutFreq = Math.max(20, Math.min(400, Number(out.params.lowCutFreq || 80))); + out.params.highCutFreq = Math.max(2000, Math.min(22000, Number(out.params.highCutFreq || 12000))); + out.params.lowCutLevel = Math.max(-48, Math.min(0, Number(out.params.lowCutLevel ?? -30))); + out.params.highCutLevel = Math.max(-48, Math.min(0, Number(out.params.highCutLevel ?? -30))); + } + out.amount = Math.max(0, Math.min(1, Number(out.amount ?? .5))); + return out; + }; + const chainForTarget = () => { + const target = selectedMixer(); + if (target.type === "track") { + const chain = trackSettings(target.track).effectChain; + if (!trackSettings(target.track).noAutoEq && !chain.some((fx) => fx.type === "eq")) chain.unshift({ id: `track_${target.track}_eq`, type: "eq", enabled: true, amount: .5, params: { low: 0, mid: 0, high: 0, q: 1.2, lowCut: false, highCut: false } }); + chain.forEach(normalizeEffect); + return chain; + } + state.masterBus = state.masterBus && typeof state.masterBus === "object" ? state.masterBus : {}; + state.masterBus.effectChain = Array.isArray(state.masterBus.effectChain) + ? state.masterBus.effectChain + : JSON.parse(JSON.stringify(DEFAULT_MASTER_CHAIN)); + if (!state.masterBus.noAutoEq && !state.masterBus.effectChain.some((fx) => fx.type === "eq")) state.masterBus.effectChain.unshift({ id: "master_eq", type: "eq", enabled: true, amount: .5, params: { low: 0, mid: 0, high: 0, q: 1.2, lowCut: false, highCut: false } }); + state.masterBus.effectChain.forEach(normalizeEffect); + return state.masterBus.effectChain; + }; + const addEffectToChain = (type, target = selectedMixer()) => { + const cleanType = String(type || "").trim(); + if (!cleanType) return; + const chain = target.type === "track" ? trackSettings(target.track).effectChain : chainForTarget(); + chain.push(normalizeEffect({ id: newId(`fx_${cleanType}`), type: cleanType, enabled: true, amount: cleanType === "limiter" ? 1 : .5 })); + if (target.type === "master") { + if (cleanType === "compressor") state.masterBus.compressor = Math.max(.45, Number(state.masterBus.compressor || 0)); + if (cleanType === "limiter") state.masterBus.limiter = true; + } + addEdit(`Inserted ${cleanType} on ${target.type === "track" ? `A${target.track + 1}` : "Master"}.`); + writeState("insert_effect"); + draw(); + }; + const removeEffectFromChain = (fx, target = selectedMixer()) => { + const chain = target.type === "track" ? trackSettings(target.track).effectChain : chainForTarget(); + const index = chain.findIndex((item) => item === fx || item.id === fx.id); + if (index < 0) return; + const [removed] = chain.splice(index, 1); + if (removed?.type === "eq") { + if (target.type === "track") trackSettings(target.track).noAutoEq = true; + else state.masterBus.noAutoEq = true; + } + addEdit(`Removed ${String(removed?.type || "device")} from ${target.type === "track" ? `A${target.track + 1}` : "Master"}.`); + writeState("remove_effect"); + draw(); + }; + const hasMedia = (seg) => Boolean(seg && (String(seg.audioFile || "").trim() || String(seg.audioB64 || "").trim())); + const effectiveGain = (seg) => Math.max(0, Math.min(4, Number(seg?.gain ?? 1) || 1)); + const peakFor = (seg) => { + const peaks = Array.isArray(seg?.waveformPeaks) ? seg.waveformPeaks.map((item) => { + if (item && typeof item === "object") return Math.max(Math.abs(Number(item.min) || 0), Math.abs(Number(item.max) || 0)); + return Math.abs(Number(item) || 0); + }) : []; + return peaks.length ? Math.min(1, Math.max(...peaks) * effectiveGain(seg)) : 0; + }; + const linkedShotboardNodes = () => { + const out = []; + for (const output of node.outputs || []) { + for (const linkId of output.links || []) { + const link = app.graph?.links?.[linkId]; + const target = link ? app.graph?.getNodeById?.(link.target_id) : null; + const type = String(target?.comfyClass || target?.type || ""); + if (target && type === "IAMCCS_CineShotboardPlannerV3") out.push(target); + } + } + return out; + }; + const shotboardDurationFrames = () => { + let maxFrames = 0; + for (const board of linkedShotboardNodes()) { + const widget = findWidget(board, "timeline_data"); + try { + const data = JSON.parse(String(widget?.value || "{}")); + const boardFps = Math.max(1, Number(data.frame_rate || fps()) || fps()); + const byDuration = Math.round(Math.max(0, Number(data.duration_seconds || 0)) * boardFps); + const byVisual = Math.max(0, ...(Array.isArray(data.segments) ? data.segments : []).map((seg) => Number(seg.start || 0) + Number(seg.length || 1))); + maxFrames = Math.max(maxFrames, byDuration, byVisual); + } catch {} + } + return maxFrames; + }; + const totalFrames = () => { + const end = Math.max(1, ...segments().map((seg) => Math.max(0, Number(seg.start || 0)) + Math.max(1, Number(seg.length || 1)))); + return Math.max(secondsToFrames(VIEWPORT_SECONDS), shotboardDurationFrames(), Math.ceil(end)); + }; + const view = () => { + state.view = state.view && typeof state.view === "object" ? state.view : {}; + state.view.timeZoom = Math.max(0.35, Math.min(8, Number(state.view.timeZoom || 1))); + state.view.trackHeight = Math.max(144, Math.min(200, Number(state.view.trackHeight || 144))); + state.view.tool = String(state.view.tool || "cursor"); + if (!["cursor", "move", "trim", "cut"].includes(state.view.tool)) state.view.tool = "cursor"; + state.view.visibleSeconds = VIEWPORT_SECONDS; + return state.view; + }; + const visibleTimelineWidth = () => Math.max(820, Math.round((root.clientWidth || 1280) - LABEL_W - 48)); + const pxPerFrame = () => Math.max(0.08, (visibleTimelineWidth() / Math.max(1, secondsToFrames(VIEWPORT_SECONDS))) * view().timeZoom); + const contentWidth = () => LABEL_W + Math.max(visibleTimelineWidth(), Math.ceil(totalFrames() * pxPerFrame())) + 8; + const frameToX = (frame) => LABEL_W + Math.round(Math.max(0, Number(frame || 0)) * pxPerFrame()); + const xToFrame = (x) => Math.max(0, Math.round((Number(x || 0) - LABEL_W) / pxPerFrame())); + const loopRange = () => { + const start = Math.max(0, Math.min(totalFrames(), Math.round(Number(state.loopInFrame || 0)))); + const end = Math.max(0, Math.min(totalFrames(), Math.round(Number(state.loopOutFrame || 0)))); + return end > start ? { start, end } : null; + }; + const setLoopIn = () => { + state.loopInFrame = Math.max(0, Math.min(totalFrames() - 1, Math.round(Number(transport.playhead || 0)))); + if (!state.loopOutFrame || state.loopOutFrame <= state.loopInFrame) state.loopOutFrame = Math.min(totalFrames(), state.loopInFrame + secondsToFrames(2)); + addEdit(`Loop IN set at ${fmtTime(state.loopInFrame)}.`); + writeState("loop_in", false); + draw(); + }; + const setLoopOut = () => { + state.loopOutFrame = Math.max(Math.round(Number(state.loopInFrame || 0)) + 1, Math.min(totalFrames(), Math.round(Number(transport.playhead || 0)))); + if (state.loopOutFrame <= state.loopInFrame) state.loopInFrame = Math.max(0, state.loopOutFrame - secondsToFrames(2)); + addEdit(`Loop OUT set at ${fmtTime(state.loopOutFrame)}.`); + writeState("loop_out", false); + draw(); + }; + const toggleLoop = () => { + const range = loopRange(); + if (!range) { + state.loopInFrame = Math.max(0, Math.min(totalFrames() - 1, Math.round(Number(transport.playhead || 0)))); + state.loopOutFrame = Math.min(totalFrames(), state.loopInFrame + secondsToFrames(4)); + } + state.loopEnabled = !state.loopEnabled; + addEdit(`Loop ${state.loopEnabled ? "enabled" : "disabled"} ${loopRange() ? `${fmtTime(state.loopInFrame)}-${fmtTime(state.loopOutFrame)}` : ""}.`); + writeState("loop_toggle", false); + draw(); + }; + const collectTransportDom = () => { + const trackBars = new Map(); + root.querySelectorAll(".iamccs-track-meter").forEach((meter) => { + const track = Number(meter.dataset.track || 0); + const fill = meter.querySelector("i"); + if (!fill) return; + if (!trackBars.has(track)) trackBars.set(track, []); + trackBars.get(track).push(fill); + }); + transport.dom = { + playheads: Array.from(root.querySelectorAll(".iamccs-playhead")), + time: root.querySelector(".iamccs-transport-time"), + masterBars: Array.from(root.querySelectorAll(".iamccs-master-meter i")), + masterReadouts: Array.from(root.querySelectorAll(".iamccs-master-readout")), + trackBars, + }; + }; + const addEdit = (text) => { + state.status = state.status && typeof state.status === "object" ? state.status : {}; + state.status.edits = Array.isArray(state.status.edits) ? state.status.edits : []; + state.status.edits.unshift(`${new Date().toLocaleTimeString()} ${text}`); + state.status.edits = state.status.edits.slice(0, 14); + transport.helper = text; + }; + const visualOptions = () => { + const board = linkedShotboardNodes()[0]; + const widget = board ? findWidget(board, "timeline_data") : null; + try { + const data = JSON.parse(String(widget?.value || "{}")); + return (Array.isArray(data.segments) ? data.segments : []) + .filter((seg) => String(seg.type || "image") !== "audio" && !seg.placeholder) + .map((seg, index) => ({ id: String(seg.id || ""), label: String(seg.label || seg.name || `shot_${index + 1}`), start: Number(seg.start || 0) })); + } catch { + return []; + } + }; + const multiTimelineIdForTake = (takeIndex) => `T${String(Math.max(1, Math.round(Number(takeIndex) || 1))).padStart(2, "0")}`; + const normalizeMultiTimelineId = (value, fallbackTake = 1) => { + const raw = String(value || "").trim(); + const take = Math.max(1, Math.round(Number(raw.replace(/\D/g, "")) || Number(fallbackTake) || 1)); + return multiTimelineIdForTake(take); + }; + const shotboardAudioForActiveTake = (allAudioSegments, activeTake, activeTimelineId) => { + const take = Math.max(1, Math.round(Number(activeTake) || 1)); + const timelineId = normalizeMultiTimelineId(activeTimelineId, take); + const all = Array.isArray(allAudioSegments) ? allAudioSegments : []; + const multiMatches = all.filter((seg) => { + const segTake = Math.max(0, Math.round(Number(seg?.multiTakeIndex || 0))); + const rawTimelineId = String(seg?.timelineId || "").trim(); + const segTimelineId = rawTimelineId ? normalizeMultiTimelineId(rawTimelineId, segTake || take) : ""; + const isMulti = Boolean(seg?.multiGenerationClip) || /^T\d+/i.test(rawTimelineId) || segTake > 0; + if (!isMulti) return false; + return segTimelineId === timelineId || segTake === take; + }); + const source = multiMatches.length + ? multiMatches + : (take === 1 ? all.filter((seg) => Number(seg?.track || 0) === 0) : []); + return source.map((seg) => { + const localStart = Number(seg?.localStart); + const next = JSON.parse(JSON.stringify(seg || {})); + next.track = 0; + next.start = Number.isFinite(localStart) + ? Math.max(0, Math.round(localStart)) + : (Boolean(seg?.multiGenerationClip) || /^T\d+/i.test(String(seg?.timelineId || "")) + ? 0 + : Math.max(0, Math.round(Number(seg?.start || 0)))); + next.timelineId = timelineId; + next.multiTakeIndex = take; + next.shotboardActiveTakeAudio = true; + next.sourceTrackOriginal = Math.max(0, Math.round(Number(seg?.track || 0))); + return next; + }); + }; + const syncToShotboard = (reason = "sync", options = {}) => { + const liveSync = isLiveSyncReason(reason); + const renderTarget = options.render !== false && !liveSync; + const callbackTarget = options.callback !== false && !liveSync; + const boards = linkedShotboardNodes(); + for (const board of boards) { + const widget = findWidget(board, "timeline_data"); + if (!widget) continue; + let data = {}; + try { data = JSON.parse(String(widget.value || "{}")); } catch { data = {}; } + if (!data || typeof data !== "object") data = {}; + data.schema = data.schema || "iamccs.cine.filmmaker_timeline"; + data.schema_version = Math.max(2, Number(data.schema_version || 2)); + const allAudioSegments = JSON.parse(JSON.stringify(segments())); + const shotboardOnlyFirst = state.audioBusMode === "only_first" || state.onlyFirstTrack; + let nextShotboardAudioSegments = shotboardOnlyFirst + ? allAudioSegments.filter((seg) => Number(seg.track || 0) === 0) + : allAudioSegments; + data.audioTrackCount = shotboardOnlyFirst ? 1 : Math.max(1, Number(state.audioTrackCount || 4)); + data.masterAudioGain = Math.max(0, Math.min(2, Number(state.masterAudioGain ?? 1) || 1)); + data.masterAudioNormalize = Boolean(state.masterAudioNormalize); + data.masterBus = JSON.parse(JSON.stringify(state.masterBus || {})); + const allTrackSettings = JSON.parse(JSON.stringify(state.trackSettings || [])); + data.trackSettings = shotboardOnlyFirst ? allTrackSettings.slice(0, 1) : allTrackSettings; + data.selectedMixer = shotboardOnlyFirst ? { type: "track", track: 0 } : JSON.parse(JSON.stringify(selectedMixer())); + data.audioBusMode = shotboardOnlyFirst ? "shotboard_only_first" : "all_tracks"; + data.onlyFirstTrack = shotboardOnlyFirst; + data.audioSyncMode = String(state.audioSyncMode || "timeline_audio"); + const arrangerMulti = state.multiGeneration && typeof state.multiGeneration === "object" ? state.multiGeneration : {}; + const boardMulti = data.multiGeneration && typeof data.multiGeneration === "object" ? data.multiGeneration : {}; + if (arrangerMulti.enabled) { + const clonedMulti = JSON.parse(JSON.stringify(arrangerMulti)); + if (clonedMulti.sourceSegment && typeof clonedMulti.sourceSegment === "object") { + const source = clonedMulti.sourceSegment; + clonedMulti.sourceSegment = { + id: String(source.id || ""), + name: String(source.name || source.fileName || source.audioFile || "audio"), + track: Number(source.track || 0), + start: Number(source.start || 0), + length: Number(source.length || source.audioDurationFrames || 0), + trimStart: Number(source.trimStart || 0), + audioDurationFrames: Number(source.audioDurationFrames || source.length || 0), + }; + } + const visualTimelines = boardMulti.visualTimelines && typeof boardMulti.visualTimelines === "object" + ? boardMulti.visualTimelines + : (clonedMulti.visualTimelines && typeof clonedMulti.visualTimelines === "object" ? clonedMulti.visualTimelines : {}); + const firstTimeline = Array.isArray(clonedMulti.timelineIds) && clonedMulti.timelineIds.length ? String(clonedMulti.timelineIds[0]) : "T01"; + data.multiGeneration = { + ...boardMulti, + ...clonedMulti, + audioSegmentsAll: allAudioSegments, + visualTimelines, + activeTake: Math.max(1, Number(boardMulti.activeTake || clonedMulti.activeTake || 1)), + activeTimelineId: String(boardMulti.activeTimelineId || clonedMulti.activeTimelineId || firstTimeline), + shotboardDurationPolicy: "chunk_duration_from_arranger", + }; + if (shotboardOnlyFirst) { + nextShotboardAudioSegments = shotboardAudioForActiveTake( + allAudioSegments, + data.multiGeneration.activeTake, + data.multiGeneration.activeTimelineId + ); + const activeSourceTrack = Math.max(0, Math.round(Number(nextShotboardAudioSegments[0]?.sourceTrackOriginal || 0))); + data.trackSettings = [allTrackSettings[activeSourceTrack] || allTrackSettings[0] || {}]; + } + } else if (Object.keys(boardMulti).length) { + data.multiGeneration = boardMulti; + } + data.audioSegments = nextShotboardAudioSegments; + data.use_custom_audio = data.audioSegments.some((seg) => hasMedia(seg) && !seg.mute); + data.audio_data = JSON.stringify({ + audioSegments: data.audioSegments, + audioTrackCount: data.audioTrackCount, + use_custom_audio: data.use_custom_audio, + masterAudioGain: data.masterAudioGain, + masterAudioNormalize: data.masterAudioNormalize, + masterBus: data.masterBus, + trackSettings: data.trackSettings, + audioBusMode: data.audioBusMode, + onlyFirstTrack: data.onlyFirstTrack, + audioSyncMode: data.audioSyncMode, + }); + const syncSignature = JSON.stringify({ + audioSegments: data.audioSegments, + audioTrackCount: data.audioTrackCount, + masterAudioGain: data.masterAudioGain, + masterAudioNormalize: data.masterAudioNormalize, + masterBus: data.masterBus, + trackSettings: data.trackSettings, + selectedMixer: data.selectedMixer, + audioBusMode: data.audioBusMode, + onlyFirstTrack: data.onlyFirstTrack, + audioSyncMode: data.audioSyncMode, + multiGeneration: data.multiGeneration || {}, + }); + const cacheKey = `${board.id || "board"}:${renderTarget ? "render" : "silent"}`; + if (shotboardSyncSignatures.get(cacheKey) === syncSignature && reason !== "manual_sync") continue; + shotboardSyncSignatures.set(cacheKey, syncSignature); + const boardDurationWidget = findWidget(board, "duration_seconds"); + const boardWidgetDuration = Math.max(0, Number(boardDurationWidget?.value || 0)); + const audioEndSeconds = Math.max(0, ...data.audioSegments.map((seg) => { + return (Math.max(0, Number(seg.start || 0)) + Math.max(1, Number(seg.length || 1))) / fps(); + })); + const visualEndSeconds = Math.max(0, ...(Array.isArray(data.segments) ? data.segments : []).map((seg) => { + return (Math.max(0, Number(seg.start || seg.frame || 0)) + Math.max(1, Number(seg.length || seg.len || 1))) / fps(); + })); + const multiDurationSeconds = data.multiGeneration?.enabled + ? Math.max(0, Number(data.multiGeneration.chunkSeconds || 0)) + : 0; + const visualTimelineDuration = data.multiGeneration?.visualTimelines && typeof data.multiGeneration.visualTimelines === "object" + ? Math.max(0, ...Object.values(data.multiGeneration.visualTimelines).map((item) => Math.max(0, Number(item?.duration_seconds || 0)))) + : 0; + const visualOverflow = multiDurationSeconds > 0 + && Math.max(visualEndSeconds, visualTimelineDuration) > multiDurationSeconds + 0.05; + const mediaDuration = Math.max(VIEWPORT_SECONDS, audioEndSeconds, visualEndSeconds, multiDurationSeconds); + const storedBoardDuration = Math.max(0, Number(data.duration_seconds || 0)); + if (multiDurationSeconds > 0 && !visualOverflow) { + data.duration_seconds = multiDurationSeconds; + if (boardDurationWidget) { + boardDurationWidget.value = Number(multiDurationSeconds.toFixed(3)); + if (callbackTarget) { + try { boardDurationWidget.callback?.(boardDurationWidget.value); } catch {} + } + } + if (data.multiGeneration) data.multiGeneration.durationWarning = ""; + if (state.multiGeneration && typeof state.multiGeneration === "object") state.multiGeneration.lastDurationWarning = ""; + } else { + data.duration_seconds = boardWidgetDuration > 0 + ? boardWidgetDuration + : (storedBoardDuration > 0 && storedBoardDuration <= mediaDuration * 1.35 ? storedBoardDuration : mediaDuration); + if (visualOverflow && data.multiGeneration) { + const warning = `MULTI duration warning: arranger split is ${multiDurationSeconds.toFixed(2)}s, but one Shotboard visual timeline is longer. Resize/split that timeline before queueing indexed takes.`; + data.multiGeneration.durationWarning = warning; + if (state.multiGeneration && typeof state.multiGeneration === "object" && state.multiGeneration.lastDurationWarning !== warning) { + state.multiGeneration.lastDurationWarning = warning; + addEdit(warning); + } + } + } + data.frame_rate = fps(); + data.truth_revision = Math.max(Number(data.truth_revision || 0), Number(board.properties?.iamccs_v3_timeline_revision || 0), 0) + 1; + data.truth_updated_at = new Date().toISOString(); + const nextValue = JSON.stringify(data, null, 2); + widget.value = nextValue; + if (callbackTarget) widget.callback?.(widget.value); + board.properties = board.properties || {}; + board.properties.iamccs_v3_timeline_revision = data.truth_revision; + board.properties.iamccs_v3_timeline_data_backup = widget.value; + board.properties.iamccs_v3_timeline_updated_at = data.truth_updated_at; + if (renderTarget) { + board._iamccsCineShotboardV3Ready = false; + board._iamccsCineShotboardV3Version = ""; + document.dispatchEvent(new CustomEvent("iamccs:planner_rows_updated", { detail: { node_id: board.id, source: "IAMCCS_AudioBoardArranger", reason, render: true } })); + } + } + }; + const scheduleShotboardSync = (reason = "live") => { + if (shotboardSyncTimer) window.clearTimeout(shotboardSyncTimer); + shotboardSyncTimer = window.setTimeout(() => { + shotboardSyncTimer = 0; + syncToShotboard(reason, { render: false, callback: false }); + }, 650); + }; + const writeState = (reason = "edit", sync = true, options = {}) => { + if (!options.quiet && liveStateWriteTimer) { + window.clearTimeout(liveStateWriteTimer); + liveStateWriteTimer = 0; + } + state.schema = "iamccs.audio_board_arranger"; + state.schema_version = 1; + state.frame_rate = fps(); + state.duration_seconds = Math.max(VIEWPORT_SECONDS, framesToSeconds(totalFrames())); + state.masterBus = state.masterBus && typeof state.masterBus === "object" ? state.masterBus : {}; + state.masterBus.effectChain = Array.isArray(state.masterBus.effectChain) + ? state.masterBus.effectChain + : JSON.parse(JSON.stringify(DEFAULT_MASTER_CHAIN)); + state.trackSettings = Array.isArray(state.trackSettings) ? state.trackSettings : []; + state.audioBusMode = (state.audioBusMode === "only_first" || state.onlyFirstTrack) ? "only_first" : "all_tracks"; + state.onlyFirstTrack = state.audioBusMode === "only_first"; + state.loopEnabled = Boolean(state.loopEnabled); + state.loopInFrame = Math.max(0, Math.min(totalFrames(), Math.round(Number(state.loopInFrame || 0)))); + state.loopOutFrame = Math.max(0, Math.min(totalFrames(), Math.round(Number(state.loopOutFrame || 0)))); + if (state.loopOutFrame && state.loopOutFrame <= state.loopInFrame) state.loopOutFrame = Math.min(totalFrames(), state.loopInFrame + Math.max(1, secondsToFrames(1))); + state.selectedClipId = selectedId || ""; + selectedMixer(); + view(); + state.audioSegments = segments().sort((a, b) => (Number(a.track || 0) - Number(b.track || 0)) || (Number(a.start || 0) - Number(b.start || 0))); + if (dataWidget) { + dataWidget.value = JSON.stringify(state, null, 2); + if (!options.quiet) dataWidget.callback?.(dataWidget.value); + } + if (sync) syncToShotboard(reason); + if (!options.quiet) { + markCanvasDirty(false); + } + }; + const pullFromShotboard = () => { + const board = linkedShotboardNodes()[0]; + const widget = board ? findWidget(board, "timeline_data") : null; + if (!widget) return; + try { + const data = JSON.parse(String(widget.value || "{}")); + state.audioSegments = Array.isArray(data.audioSegments) ? data.audioSegments : []; + state.audioTrackCount = Math.max(1, Number(data.audioTrackCount || 4)); + state.masterAudioGain = Math.max(0, Math.min(2, Number(data.masterAudioGain ?? 1) || 1)); + state.masterAudioNormalize = Boolean(data.masterAudioNormalize); + state.masterBus = data.masterBus && typeof data.masterBus === "object" ? data.masterBus : state.masterBus; + state.trackSettings = Array.isArray(data.trackSettings) ? data.trackSettings : state.trackSettings; + state.selectedMixer = data.selectedMixer && typeof data.selectedMixer === "object" ? data.selectedMixer : state.selectedMixer; + state.audioSyncMode = String(data.audioSyncMode || "timeline_audio"); + addEdit("Pulled audio lanes from connected Shotboard V3."); + writeState("pull", false); + draw(); + } catch {} + }; + const ensureAudioContext = async () => { + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + if (!AudioContextClass) throw new Error("WebAudio unavailable"); + if (!audioContext) audioContext = new AudioContextClass(); + if (audioContext.state === "suspended") await audioContext.resume(); + return audioContext; + }; + const uploadAudioFile = async (file) => { + const body = new FormData(); + body.append("image", file); + const resp = await api.fetchApi("/upload/image", { method: "POST", body }); + if (!resp || resp.status !== 200) throw new Error(`upload failed: ${resp?.status || "no response"}`); + const data = await resp.json(); + const filename = data?.name || file.name; + const subfolder = data?.subfolder || ""; + return { path: subfolder ? `${subfolder}/${filename}` : filename, type: data?.type || "input" }; + }; + const audioViewUrl = (seg) => { + const file = String(seg.audioFile || "").trim(); + if (!file) return ""; + const parts = file.split(/[\\/]+/).filter(Boolean); + const filename = parts.pop() || file; + const subfolder = parts.join("/"); + return `/view?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(seg.audioUploadType || "input")}&subfolder=${encodeURIComponent(subfolder)}`; + }; + const getBuffer = async (seg) => { + if (!seg) return null; + if (audioBuffers.has(seg.id)) return audioBuffers.get(seg.id); + const ctx = await ensureAudioContext(); + const url = audioUrls.get(seg.id) || audioViewUrl(seg); + if (!url) return null; + const resp = await fetch(url); + if (!resp.ok) throw new Error(`audio fetch failed ${resp.status}`); + const arrayBuffer = await resp.arrayBuffer(); + const decoded = await ctx.decodeAudioData(arrayBuffer.slice(0)); + audioBuffers.set(seg.id, decoded); + return decoded; + }; + const peaksFromBuffer = (decoded, count = 1200) => { + const channelCount = Math.max(1, decoded?.numberOfChannels || 1); + const frameCount = Math.max(1, decoded?.length || 1); + const buckets = Math.max(180, Math.min(2400, Number(count || 1200))); + const peaks = []; + for (let i = 0; i < buckets; i += 1) { + const start = Math.floor((i / buckets) * frameCount); + const end = Math.max(start + 1, Math.floor(((i + 1) / buckets) * frameCount)); + let min = 0; + let max = 0; + let sum = 0; + let n = 0; + for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) { + const channel = decoded.getChannelData(channelIndex); + for (let j = start; j < end; j += 1) { + const v = Number(channel[j] || 0); + min = Math.min(min, v); + max = Math.max(max, v); + sum += v * v; + n += 1; + } + } + peaks.push({ + min: Number(min.toFixed(5)), + max: Number(max.toFixed(5)), + rms: Number(Math.sqrt(sum / Math.max(1, n)).toFixed(5)), + }); + } + return peaks; + }; + const ensureSegmentWaveform = (seg) => { + if (!seg || !hasMedia(seg) || seg.waveformReal === true || waveformLoading.has(seg.id)) return; + waveformLoading.add(seg.id); + getBuffer(seg) + .then((buffer) => { + if (!buffer) return; + seg.audioDurationFrames = Math.max(1, Math.round(buffer.duration * fps())); + seg.waveformPeaks = peaksFromBuffer(buffer, Math.max(900, Math.min(2200, Math.round(buffer.duration * 70)))); + seg.waveformReal = true; + waveformLoading.delete(seg.id); + writeState("waveform_decode", false, { quiet: true }); + draw(); + }) + .catch((err) => { + waveformLoading.delete(seg.id); + console.warn("[IAMCCS AudioBoardArranger] waveform decode failed", seg?.audioFile || seg?.fileName || seg?.id, err); + }); + }; + const decodeAudioInfo = async (file) => { + const ctx = await ensureAudioContext(); + const arrayBuffer = await file.arrayBuffer(); + const decoded = await ctx.decodeAudioData(arrayBuffer.slice(0)); + const peaks = peaksFromBuffer(decoded, Math.max(900, Math.min(2200, Math.round(decoded.duration * 70)))); + return { durationFrames: Math.max(1, Math.round(decoded.duration * fps())), peaks, buffer: decoded }; + }; + const importFiles = async (files) => { + const list = Array.from(files || []).filter((file) => String(file.type || "").startsWith("audio/")); + const firstVisual = visualOptions()[0] || null; + let cursor = firstVisual ? Number(firstVisual.start || 0) : Math.max(0, ...segments().map((seg) => Number(seg.start || 0) + Number(seg.length || 1))); + for (const file of list) { + try { + const info = await decodeAudioInfo(file); + const uploaded = await uploadAudioFile(file); + const track = Math.max(0, Math.min(Math.max(1, Number(state.audioTrackCount || 4)) - 1, segments().length % Math.max(1, Number(state.audioTrackCount || 4)))); + const seg = { + id: newId("aud"), + type: "audio", + start: cursor, + length: info.durationFrames, + track, + trimStart: 0, + audioDurationFrames: info.durationFrames, + audioFile: uploaded.path, + audioUploadType: uploaded.type, + fileName: file.name, + name: file.name, + mime: file.type, + size: file.size, + waveformPeaks: info.peaks, + waveformReal: true, + purpose: "dialogue_or_music", + gain: 1, + pan: 0, + fadeInFrames: 0, + fadeOutFrames: 0, + pitchSemitones: 0, + timeStretch: 1, + hpfHz: 0, + lpfHz: 22000, + eqLowDb: 0, + eqMidDb: 0, + eqHighDb: 0, + compressor: 0, + noiseGateDb: -60, + ducking: 0, + reverbSend: 0, + delaySend: 0, + stereoWidth: 1, + transient: 0, + denoise: 0, + reverse: false, + lock: false, + normalizeAudio: false, + mute: false, + solo: false, + linkedVisualId: firstVisual ? firstVisual.id : "", + }; + audioBuffers.set(seg.id, info.buffer); + audioUrls.set(seg.id, URL.createObjectURL(file)); + segments().push(seg); + selectedId = seg.id; + if (!firstVisual) cursor += info.durationFrames; + addEdit(`Imported ${file.name} (${framesToSeconds(info.durationFrames).toFixed(2)}s).`); + } catch (err) { + addEdit(`Import failed: ${file.name}`); + console.warn("[IAMCCS AudioBoardArranger] import failed", err); + } + } + writeState("import"); + draw(); + }; + fileInput.onchange = async (event) => { + await importFiles(event.target.files || []); + fileInput.value = ""; + }; + const setPlayhead = (frame, redraw = false) => { + transport.playhead = Math.max(0, Math.min(totalFrames(), Math.round(Number(frame || 0)))); + if (redraw) draw(); + else { + const dom = transport.dom || {}; + (dom.playheads || root.querySelectorAll(".iamccs-playhead")).forEach((el) => { el.style.left = `${frameToX(transport.playhead)}px`; }); + const readout = dom.time || root.querySelector(".iamccs-transport-time"); + if (readout) readout.textContent = `${fmtTime(transport.playhead)} / ${fmtTime(totalFrames())}`; + } + }; + const stopPlayback = (redraw = true) => { + for (const item of transport.sources) { + try { item.source.stop(); } catch {} + } + transport.sources = []; + transport.playing = false; + transport.analysers = new Map(); + transport.masterAnalyser = null; + transport.lastMeterSnapshot = { tracks: {}, master: { peak: 0, rms: 0 }, playing: false, playhead: transport.playhead }; + transport.efxFrame = 0; + transport.lastMeterAt = 0; + transport.pendingLoopRestart = false; + if (transport.raf) cancelAnimationFrame(transport.raf); + transport.raf = 0; + const dom = transport.dom || {}; + (dom.masterBars || root.querySelectorAll(".iamccs-master-meter i")).forEach((el) => { el.style.width = "0%"; }); + if (dom.trackBars) dom.trackBars.forEach((items) => items.forEach((el) => { el.style.width = "0%"; })); + else root.querySelectorAll(".iamccs-track-meter i").forEach((el) => { el.style.width = "0%"; }); + (dom.masterReadouts || root.querySelectorAll(".iamccs-master-readout")).forEach((el) => { el.textContent = "PK 000 RMS 000"; }); + if (redraw) draw(); + }; + const meterFromAnalyser = (analyser) => { + if (!analyser) return { peak: 0, rms: 0 }; + if (!analyser._iamccsMeterData || analyser._iamccsMeterData.length !== analyser.fftSize) analyser._iamccsMeterData = new Uint8Array(analyser.fftSize); + const data = analyser._iamccsMeterData; + analyser.getByteTimeDomainData(data); + let peak = 0; + let sum = 0; + for (const value of data) { + const v = (value - 128) / 128; + const a = Math.abs(v); + peak = Math.max(peak, a); + sum += v * v; + } + return { peak: Math.min(1, peak), rms: Math.min(1, Math.sqrt(sum / Math.max(1, data.length))) }; + }; + const activeAnalyser = () => { + const target = selectedMixer(); + if (target.type === "track") return transport.analysers.get(target.track) || transport.masterAnalyser; + return transport.masterAnalyser; + }; + const paintRealtimeAnalyser = (canvas, kind, analyser) => { + if (!canvas || !analyser) return false; + const rect = canvas.getBoundingClientRect(); + const w = Math.max(180, Math.round(rect.width || 260)); + const h = Math.max(70, Math.round(rect.height || 90)); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) return false; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = "#050d0f"; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = "rgba(255,255,255,.08)"; + ctx.lineWidth = 1; + for (let x = 0; x <= w; x += Math.max(24, w / 8)) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + if (kind === "eq") { + const fx = canvas._iamccsFx?.type === "eq" ? canvas._iamccsFx : null; + const freq = new Uint8Array(analyser.frequencyBinCount); + analyser.getByteFrequencyData(freq); + const bars = 88; + for (let i = 0; i < bars; i += 1) { + const t = i / Math.max(1, bars - 1); + const idx = Math.min(freq.length - 1, Math.round(Math.pow(t, 1.85) * (freq.length - 1))); + const v = Math.max(0, Math.min(1, Number(freq[idx] || 0) / 255)); + const x = (i / bars) * w; + const bw = Math.max(2, w / bars - 1); + const bh = Math.max(1, v * h * .45); + ctx.fillStyle = v > .72 ? "rgba(240,184,87,.34)" : "rgba(85,199,185,.24)"; + ctx.fillRect(x, h - bh, bw, bh); + } + normalizeEffect(fx); + const low = Number(fx?.params?.low ?? 0); + const midDb = Number(fx?.params?.mid ?? 0); + const high = Number(fx?.params?.high ?? 0); + const hpf = fx?.params?.lowCut ? Number(fx.params.lowCutFreq || 80) : 0; + const lpf = fx?.params?.highCut ? Number(fx.params.highCutFreq || 12000) : 22000; + const lowCutLevel = Math.max(-48, Math.min(0, Number(fx?.params?.lowCutLevel ?? -30))); + const highCutLevel = Math.max(-48, Math.min(0, Number(fx?.params?.highCutLevel ?? -30))); + const center = h * .5; + const freqToX = (hz) => { + const t = (Math.log10(Math.max(20, Math.min(22000, Number(hz || 20)))) - Math.log10(20)) / (Math.log10(22000) - Math.log10(20)); + return Math.max(0, Math.min(w, t * w)); + }; + const yFromDb = (db) => center - (Math.max(-24, Math.min(24, Number(db || 0))) / 48) * h; + const yCutFromDb = (db) => center - (Math.max(-48, Math.min(0, Number(db || 0))) / 72) * h; + const drawSmooth = (points, stroke, width = 2.2) => { + ctx.strokeStyle = stroke; + ctx.lineWidth = width; + ctx.beginPath(); + points.forEach((p, i) => { + if (i === 0) ctx.moveTo(p[0], p[1]); + else { + const p0 = points[Math.max(0, i - 2)]; + const p1 = points[i - 1]; + const p2 = p; + const p3 = points[Math.min(points.length - 1, i + 1)]; + ctx.bezierCurveTo( + p1[0] + (p2[0] - p0[0]) / 6, + p1[1] + (p2[1] - p0[1]) / 6, + p2[0] - (p3[0] - p1[0]) / 6, + p2[1] - (p3[1] - p1[1]) / 6, + p2[0], + p2[1] + ); + } + }); + ctx.stroke(); + }; + const lowX = freqToX(140); + const midX = freqToX(1200); + const highX = freqToX(6200); + const lowCutX = freqToX(hpf || 20); + const highCutX = freqToX(lpf || 22000); + if (hpf > 20) { + ctx.fillStyle = "rgba(216,91,66,.12)"; + ctx.fillRect(0, 0, lowCutX, h); + } + if (lpf < 22000) { + ctx.fillStyle = "rgba(216,91,66,.10)"; + ctx.fillRect(highCutX, 0, w - highCutX, h); + } + drawSmooth([ + [0, hpf > 20 ? yCutFromDb(lowCutLevel) : center], + ...(hpf > 20 ? [[lowCutX, center]] : []), + [lowX, yFromDb(low)], + [midX, yFromDb(midDb)], + [highX, yFromDb(high)], + ...(lpf < 22000 ? [[highCutX, center]] : []), + [w, lpf < 22000 ? yCutFromDb(highCutLevel) : center], + ].sort((a, b) => a[0] - b[0]), "#f5d08e", 2.4); + ctx.fillStyle = "#ffe2a8"; + ctx.font = "900 9px ui-monospace, Consolas, monospace"; + ctx.fillText(`LOW ${low.toFixed(1)} MID ${midDb.toFixed(1)} HIGH ${high.toFixed(1)}`, 8, 14); + if (hpf > 20) ctx.fillText(`LC ${Math.round(hpf)}Hz ${Math.round(lowCutLevel)}dB`, 8, h - 9); + if (lpf < 22000) ctx.fillText(`HC ${(lpf / 1000).toFixed(1)}k ${Math.round(highCutLevel)}dB`, Math.max(8, w - 116), h - 9); + return true; + } + if (kind === "wave") { + const data = new Uint8Array(analyser.fftSize); + analyser.getByteTimeDomainData(data); + ctx.strokeStyle = "#f5d08e"; + ctx.lineWidth = 2; + ctx.beginPath(); + for (let i = 0; i < data.length; i += 1) { + const x = (i / Math.max(1, data.length - 1)) * w; + const y = ((data[i] || 128) / 255) * h; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + return true; + } + if (kind === "bus") { + const fx = canvas._iamccsFx || {}; + const meter = meterFromAnalyser(analyser); + const wave = new Uint8Array(analyser.fftSize); + analyser.getByteTimeDomainData(wave); + ctx.strokeStyle = "rgba(255,226,168,.34)"; + ctx.lineWidth = 1.4; + ctx.beginPath(); + wave.forEach((value, i) => { + const x = (i / Math.max(1, wave.length - 1)) * w; + const y = h * .26 + ((value - 128) / 128) * h * .16; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + const peakX = Math.round(meter.peak * w); + const rmsX = Math.round(meter.rms * w); + const grad = ctx.createLinearGradient(0, 0, w, 0); + grad.addColorStop(0, "#55c7b9"); + grad.addColorStop(.65, "#d6df76"); + grad.addColorStop(.84, "#e8aa4e"); + grad.addColorStop(1, "#d75b42"); + ctx.fillStyle = grad; + ctx.fillRect(0, h * .34, peakX, h * .25); + ctx.fillStyle = "rgba(255,255,255,.42)"; + ctx.fillRect(0, h * .68, rmsX, h * .12); + const threshold = Math.max(-80, Math.min(0, Number(fx.params?.threshold ?? fx.params?.ceiling ?? -18))); + const ratio = Math.max(1, Math.min(30, Number(fx.params?.ratio ?? (fx.type === "limiter" ? 20 : 4)))); + const knee = fx.type === "limiter" ? 4 : 12; + ctx.strokeStyle = "rgba(143,208,204,.86)"; + ctx.lineWidth = 1.8; + ctx.beginPath(); + for (let x = 0; x <= w; x += 1) { + const inDb = -60 + (x / Math.max(1, w)) * 66; + const over = inDb - threshold; + const kneeMix = Math.max(0, Math.min(1, (over + knee / 2) / Math.max(.001, knee))); + const compressed = over > 0 ? threshold + over / ratio : inDb; + const soft = inDb * (1 - kneeMix) + compressed * kneeMix; + const outDb = over < -knee / 2 ? inDb : soft; + const y = h - ((outDb + 60) / 66) * h; + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + const inputPeakDb = meter.peak > .0001 ? 20 * Math.log10(meter.peak) : -80; + const reduction = Math.max(0, inputPeakDb - threshold) * (1 - 1 / ratio); + ctx.fillStyle = "rgba(216,91,66,.82)"; + ctx.fillRect(w - 18, h - Math.min(h, reduction / 24 * h), 10, Math.min(h, reduction / 24 * h)); + ctx.fillStyle = "#f5d08e"; + ctx.font = "900 10px ui-monospace, Consolas, monospace"; + ctx.fillText(`PK ${Math.round(meter.peak * 100)} RMS ${Math.round(meter.rms * 100)} GR ${reduction.toFixed(1)}dB`, 8, 15); + return true; + } + return false; + }; + const updateInlineEfxRealtime = () => { + if (!transport.playing) return; + transport.efxFrame = (transport.efxFrame || 0) + 1; + if (transport.efxFrame % 3 !== 0) return; + const analyser = activeAnalyser(); + if (!analyser) return; + root.querySelectorAll(".iamccs-inline-efx-canvas[data-kind='bus'], .iamccs-inline-efx-canvas[data-kind='eq']").forEach((canvas) => { + const rect = canvas.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0 || rect.right < 0 || rect.left > window.innerWidth) return; + const kind = canvas.dataset.kind || ""; + paintRealtimeAnalyser(canvas, kind, analyser); + }); + }; + const updateMeters = () => { + if (!transport.playing) return; + const elapsed = Math.max(0, (audioContext?.currentTime || 0) - transport.startedAt); + const nextFrame = transport.startFrame + secondsToFrames(elapsed); + const range = loopRange(); + if (state.loopEnabled && range && nextFrame >= range.end && !transport.pendingLoopRestart) { + transport.pendingLoopRestart = true; + stopPlayback(false); + setPlayhead(range.start, false); + playPlayback(); + return; + } + setPlayhead(nextFrame, false); + const now = performance.now ? performance.now() : Date.now(); + if (now - Number(transport.lastMeterAt || 0) > 42) { + transport.lastMeterAt = now; + const dom = transport.dom || {}; + const meterSnapshot = { tracks: {}, master: { peak: 0, rms: 0 }, playing: true, playhead: transport.playhead }; + for (const [track, analyser] of transport.analysers.entries()) { + const meter = meterFromAnalyser(analyser); + meterSnapshot.tracks[track] = meter; + const bars = dom.trackBars?.get(track) || []; + bars.forEach((el) => { el.style.width = `${Math.round(meter.peak * 100)}%`; }); + } + const master = meterFromAnalyser(transport.masterAnalyser); + meterSnapshot.master = master; + transport.lastMeterSnapshot = meterSnapshot; + (dom.masterBars || []).forEach((el) => { el.style.width = `${Math.round(master.peak * 100)}%`; }); + (dom.masterReadouts || []).forEach((el) => { el.textContent = `PK ${String(Math.round(master.peak * 100)).padStart(3, "0")} RMS ${String(Math.round(master.rms * 100)).padStart(3, "0")}`; }); + updateInlineEfxRealtime(); + } + if (transport.playhead >= totalFrames()) { + stopPlayback(true); + setPlayhead(0, false); + return; + } + transport.raf = requestAnimationFrame(updateMeters); + }; + const playPlayback = async () => { + stopPlayback(false); + const ctx = await ensureAudioContext(); + const soloed = segments().some((seg) => seg.solo); + const trackSoloed = Array.from({ length: Math.max(1, Number(state.audioTrackCount || 4)) }, (_, index) => trackSettings(index)).some((item) => item.solo); + const range = loopRange(); + if (state.loopEnabled && range && (transport.playhead < range.start || transport.playhead >= range.end)) { + setPlayhead(range.start, false); + } + const masterGain = ctx.createGain(); + masterGain.gain.value = Math.max(0, Math.min(2, Number(state.masterAudioGain ?? 1) || 1)); + const masterAnalyser = ctx.createAnalyser(); + masterAnalyser.fftSize = 256; + let masterLast = masterGain; + state.masterBus.effectChain = Array.isArray(state.masterBus.effectChain) ? state.masterBus.effectChain : JSON.parse(JSON.stringify(DEFAULT_MASTER_CHAIN)); + const masterChain = state.masterBus.effectChain.map(normalizeEffect); + const masterEqFx = masterChain.find((fx) => fx.enabled !== false && fx.type === "eq"); + const masterCompFx = masterChain.find((fx) => fx.enabled !== false && fx.type === "compressor"); + const masterLimFx = masterChain.find((fx) => fx.enabled !== false && fx.type === "limiter"); + const addMasterBiquad = (type, freq, gain = 0, q = .707) => { + const filter = ctx.createBiquadFilter(); + filter.type = type; + filter.frequency.value = Math.max(10, Math.min(22000, Number(freq || 0))); + filter.Q.value = Math.max(.1, Math.min(18, Number(q || .707))); + if ("gain" in filter) filter.gain.value = Math.max(-36, Math.min(36, Number(gain || 0))); + masterLast.connect(filter); + masterLast = filter; + }; + if (masterEqFx) { + if (masterEqFx.params?.lowCut) addMasterBiquad("highpass", masterEqFx.params.lowCutFreq || 80); + if (Number(masterEqFx.params?.low || 0)) addMasterBiquad("lowshelf", 140, masterEqFx.params.low, .7); + if (Number(masterEqFx.params?.mid || 0)) addMasterBiquad("peaking", 1200, masterEqFx.params.mid, Math.max(.2, Number(masterEqFx.params?.q || 1.2))); + if (Number(masterEqFx.params?.high || 0)) addMasterBiquad("highshelf", 6200, masterEqFx.params.high, .7); + if (masterEqFx.params?.highCut) addMasterBiquad("lowpass", masterEqFx.params.highCutFreq || 12000); + } + const masterCompAmount = masterCompFx ? Math.max(.01, Math.min(1, Math.abs(Number(masterCompFx.params?.threshold ?? -18)) / 60)) : Math.max(0, Math.min(1, Number(state.masterBus?.compressor || 0))); + if ((masterLimFx || state.masterBus?.limiter || masterCompAmount > 0) && ctx.createDynamicsCompressor) { + const comp = ctx.createDynamicsCompressor(); + comp.threshold.value = masterLimFx ? Math.max(-24, Math.min(0, Number(masterLimFx.params?.ceiling ?? -1) - 5)) : masterCompFx ? Math.max(-60, Math.min(0, Number(masterCompFx.params?.threshold ?? -18))) : -20 - masterCompAmount * 18; + comp.knee.value = masterLimFx ? 3 : 18; + comp.ratio.value = masterLimFx ? 18 : masterCompFx ? Math.max(1, Math.min(20, Number(masterCompFx.params?.ratio ?? 4))) : 1 + masterCompAmount * 7; + comp.attack.value = masterCompFx ? Math.max(.001, Number(masterCompFx.params?.attack ?? 6) / 1000) : .003; + comp.release.value = masterLimFx ? Math.max(.02, Number(masterLimFx.params?.release ?? 120) / 1000) : masterCompFx ? Math.max(.02, Number(masterCompFx.params?.release ?? 180) / 1000) : .12; + masterLast.connect(comp); + masterLast = comp; + } + masterLast.connect(masterAnalyser); + masterAnalyser.connect(ctx.destination); + const trackNodes = new Map(); + const getTrack = (track) => { + if (trackNodes.has(track)) return trackNodes.get(track); + const trackState = trackSettings(track); + const gain = ctx.createGain(); + gain.gain.value = Math.max(0, Math.min(2, Number(trackState.volume ?? 1))); + let last = gain; + if (ctx.createStereoPanner) { + const trackPan = ctx.createStereoPanner(); + trackPan.pan.value = Math.max(-1, Math.min(1, Number(trackState.pan || 0))); + last.connect(trackPan); + last = trackPan; + } + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + last.connect(analyser); + analyser.connect(masterGain); + trackNodes.set(track, gain); + transport.analysers.set(track, analyser); + return gain; + }; + const startFrame = transport.playhead; + transport.startFrame = startFrame; + transport.startedAt = ctx.currentTime; + transport.playing = true; + transport.pendingLoopRestart = false; + transport.lastMeterAt = 0; + for (const seg of segments()) { + const trackIndex = Math.max(0, Number(seg.track || 0)); + const trackState = trackSettings(trackIndex); + if (trackState.mute || (trackSoloed && !trackState.solo) || trackState.lock) continue; + if (seg.mute || (soloed && !seg.solo)) continue; + const segStart = Math.round(Number(seg.start || 0)); + const segLen = Math.max(1, Math.round(Number(seg.length || 1))); + const segEnd = segStart + segLen; + if (segEnd <= startFrame) continue; + try { + const buffer = await getBuffer(seg); + if (!buffer) continue; + const relStart = Math.max(0, startFrame - segStart); + const when = Math.max(0, framesToSeconds(segStart - startFrame)); + const offset = Math.max(0, framesToSeconds(Number(seg.trimStart || 0) + relStart)); + const dur = Math.max(0.02, Math.min(framesToSeconds(segLen - relStart), buffer.duration - offset)); + if (dur <= 0) continue; + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.playbackRate.value = Math.max(.25, Math.min(4, Number(seg.timeStretch || 1) || 1)) * Math.pow(2, Number(seg.pitchSemitones || 0) / 12); + const clipGain = ctx.createGain(); + clipGain.gain.value = effectiveGain(seg); + const nodes = []; + const addBiquad = (type, freq, gain = 0, q = .707) => { + const node = ctx.createBiquadFilter(); + node.type = type; + node.frequency.value = Math.max(10, Math.min(22000, Number(freq || 0))); + node.Q.value = Math.max(.1, Math.min(18, Number(q || .707))); + if ("gain" in node) node.gain.value = Math.max(-36, Math.min(36, Number(gain || 0))); + nodes.push(node); + return node; + }; + if (Number(seg.hpfHz || 0) > 10) addBiquad("highpass", seg.hpfHz, 0, .7); + if (Number(seg.lpfHz || 22000) < 21950) addBiquad("lowpass", seg.lpfHz, 0, .7); + if (Number(seg.eqLowDb || 0)) addBiquad("lowshelf", 140, seg.eqLowDb, .7); + if (Number(seg.eqMidDb || 0)) addBiquad("peaking", 1200, seg.eqMidDb, 1.1); + if (Number(seg.eqHighDb || 0)) addBiquad("highshelf", 6200, seg.eqHighDb, .7); + const trackEq = trackState.effectChain.find((fx) => fx.enabled !== false && fx.type === "eq"); + if (trackEq) { + if (trackEq.params?.lowCut) addBiquad("highpass", trackEq.params.lowCutFreq || 80, 0, .7); + if (Number(trackEq.params?.low || 0)) addBiquad("lowshelf", 140, trackEq.params.low, .7); + if (Number(trackEq.params?.mid || 0)) addBiquad("peaking", 1200, trackEq.params.mid, Math.max(.2, Number(trackEq.params?.q || 1.2))); + if (Number(trackEq.params?.high || 0)) addBiquad("highshelf", 6200, trackEq.params.high, .7); + if (trackEq.params?.highCut) addBiquad("lowpass", trackEq.params.highCutFreq || 12000, 0, .7); + } + if (Number(seg.compressor || 0) > 0 && ctx.createDynamicsCompressor) { + const comp = ctx.createDynamicsCompressor(); + const amount = Math.max(0, Math.min(1, Number(seg.compressor || 0))); + comp.threshold.value = -18 - amount * 24; + comp.knee.value = 18; + comp.ratio.value = 1 + amount * 11; + comp.attack.value = .006; + comp.release.value = .18; + nodes.push(comp); + } + const trackComp = trackState.effectChain.find((fx) => fx.enabled !== false && fx.type === "compressor"); + if (trackComp && ctx.createDynamicsCompressor) { + const comp = ctx.createDynamicsCompressor(); + comp.threshold.value = Math.max(-60, Math.min(0, Number(trackComp.params?.threshold ?? -18))); + comp.knee.value = 12; + comp.ratio.value = Math.max(1, Math.min(20, Number(trackComp.params?.ratio ?? 4))); + comp.attack.value = Math.max(.001, Number(trackComp.params?.attack ?? 6) / 1000); + comp.release.value = Math.max(.02, Number(trackComp.params?.release ?? 180) / 1000); + nodes.push(comp); + } + source.connect(clipGain); + let last = clipGain; + for (const node of nodes) { + last.connect(node); + last = node; + } + if (ctx.createStereoPanner) { + const pan = ctx.createStereoPanner(); + pan.pan.value = Math.max(-1, Math.min(1, Number(seg.pan || 0))); + last.connect(pan); + last = pan; + } + if (Number(seg.delaySend || 0) > 0) { + const delay = ctx.createDelay(1.5); + const feedback = ctx.createGain(); + const send = ctx.createGain(); + delay.delayTime.value = .18; + feedback.gain.value = Math.min(.55, Number(seg.delaySend || 0) * .45); + send.gain.value = Math.min(.7, Number(seg.delaySend || 0)); + last.connect(send); + send.connect(delay); + delay.connect(feedback); + feedback.connect(delay); + delay.connect(getTrack(trackIndex)); + } + last.connect(getTrack(trackIndex)); + source.start(ctx.currentTime + when, offset, dur); + transport.sources.push({ source, seg }); + } catch (err) { + console.warn("[IAMCCS AudioBoardArranger] playback skipped clip", seg, err); + } + } + transport.masterAnalyser = masterAnalyser; + transport.helper = "Playing timeline. Meters are live from WebAudio."; + draw(); + updateMeters(); + }; + const exposeTransportApi = () => { + node._iamccsAudioBoardTransport = { + play: () => playPlayback(), + stop: (redraw = true) => stopPlayback(redraw), + toggle: () => transport.playing ? stopPlayback(true) : playPlayback(), + rewind: () => { + stopPlayback(false); + setPlayhead(0, true); + }, + setPlayhead: (frame, redraw = false) => setPlayhead(frame, redraw), + snapshot: () => ({ + playing: Boolean(transport.playing), + playhead: Math.max(0, Math.round(Number(transport.playhead || 0))), + totalFrames: totalFrames(), + fps: fps(), + loopEnabled: Boolean(state.loopEnabled), + helper: String(transport.helper || ""), + meters: transport.lastMeterSnapshot || { tracks: {}, master: { peak: 0, rms: 0 }, playing: false, playhead: transport.playhead }, + }), + }; + }; + exposeTransportApi(); + const drawWave = (clip, seg) => { + ensureSegmentWaveform(seg); + const allPeaks = Array.isArray(seg.waveformPeaks) ? seg.waveformPeaks : []; + const durationFrames = Math.max(1, Number(seg.audioDurationFrames || seg.length || 1)); + const trimStart = Math.max(0, Number(seg.trimStart || 0)); + const trimEnd = Math.max(trimStart + 1, trimStart + Math.max(1, Number(seg.length || 1))); + const startIndex = allPeaks.length ? Math.max(0, Math.min(allPeaks.length - 1, Math.floor((trimStart / durationFrames) * allPeaks.length))) : 0; + const endIndex = allPeaks.length ? Math.max(startIndex + 1, Math.min(allPeaks.length, Math.ceil((trimEnd / durationFrames) * allPeaks.length))) : 0; + const peaks = allPeaks.slice(startIndex, endIndex); + const canvas = document.createElement("canvas"); + canvas.classList.add("iamccs-clip-wave-svg", "iamccs-clip-wave-canvas"); + canvas.dataset.sourceStart = String(trimStart); + canvas.dataset.sourceEnd = String(trimEnd); + const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); + const cssW = Math.max(64, Math.round(Number(seg.length || 1) * pxPerFrame())); + const cssH = Math.max(46, Math.round(view().trackHeight - 28)); + const w = Math.max(96, Math.min(4096, Math.round(cssW * dpr))); + const h = Math.max(50, Math.min(260, Math.round(cssH * dpr))); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) { + clip.appendChild(canvas); + return; + } + const bg = ctx.createLinearGradient(0, 0, 0, h); + bg.addColorStop(0, "#376a9b"); + bg.addColorStop(.52, "#315f8f"); + bg.addColorStop(1, "#23496f"); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = "rgba(255,255,255,.12)"; + ctx.lineWidth = 1; + for (let x = 0; x <= w; x += Math.max(36, Math.round(w / 18))) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + ctx.strokeStyle = "rgba(255,255,255,.28)"; + ctx.beginPath(); + ctx.moveTo(0, h * .5); + ctx.lineTo(w, h * .5); + ctx.stroke(); + const normPeak = (raw) => { + if (raw && typeof raw === "object") { + return { + min: Math.max(-1, Math.min(1, Number(raw.min) || 0)), + max: Math.max(-1, Math.min(1, Number(raw.max) || 0)), + rms: Math.max(0, Math.min(1, Math.abs(Number(raw.rms) || 0))), + }; + } + const p = Math.max(0, Math.min(1, Math.abs(Number(raw) || 0))); + return { min: -p, max: p, rms: p * .66 }; + }; + const peakValue = (raw) => { + const p = normPeak(raw); + return Math.max(Math.abs(p.min), Math.abs(p.max), p.rms); + }; + if (!peaks.length) { + ctx.fillStyle = "rgba(235,248,255,.76)"; + ctx.font = `900 ${Math.max(10, Math.round(12 * dpr))}px ui-monospace, Consolas, monospace`; + ctx.textAlign = "center"; + ctx.fillText(waveformLoading.has(seg.id) ? "decoding real waveform..." : "no waveform peaks", w * .5, h * .53); + clip.appendChild(canvas); + return; + } + const visualMax = Math.max(.05, ...peaks.map(peakValue)); + const scale = Math.min(1.65, .94 / visualMax); + const columnPeak = (x) => { + const from = Math.floor((x / Math.max(1, w)) * peaks.length); + const to = Math.max(from + 1, Math.floor(((x + 1) / Math.max(1, w)) * peaks.length)); + let min = 0; + let max = 0; + let rms = 0; + let n = 0; + for (let i = from; i < Math.min(peaks.length, to); i += 1) { + const p = normPeak(peaks[i]); + min = Math.min(min, p.min); + max = Math.max(max, p.max); + rms += p.rms; + n += 1; + } + if (!n) { + const p = normPeak(peaks[Math.min(peaks.length - 1, Math.max(0, from))]); + return p; + } + return { min, max, rms: rms / n }; + }; + const center = h * .5; + const amp = h * .46; + const top = []; + const bottom = []; + const rmsTop = []; + const rmsBottom = []; + for (let x = 0; x < w; x += 1) { + const p = columnPeak(x); + top.push([x, center - Math.max(1, p.max * scale * amp)]); + bottom.unshift([x, center + Math.max(1, Math.abs(p.min) * scale * amp)]); + rmsTop.push([x, center - Math.max(.5, p.rms * scale * amp * .62)]); + rmsBottom.unshift([x, center + Math.max(.5, p.rms * scale * amp * .62)]); + } + const body = ctx.createLinearGradient(0, 0, 0, h); + body.addColorStop(0, "rgba(236,249,255,.96)"); + body.addColorStop(.48, "rgba(178,221,245,.82)"); + body.addColorStop(.52, "rgba(172,215,241,.80)"); + body.addColorStop(1, "rgba(236,249,255,.94)"); + ctx.fillStyle = "rgba(255,255,255,.16)"; + ctx.beginPath(); + rmsTop.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); + rmsBottom.forEach(([x, y]) => ctx.lineTo(x, y)); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = body; + ctx.beginPath(); + top.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); + bottom.forEach(([x, y]) => ctx.lineTo(x, y)); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,.84)"; + ctx.lineWidth = 1; + ctx.beginPath(); + top.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); + bottom.slice().reverse().forEach(([x, y]) => ctx.lineTo(x, y)); + ctx.stroke(); + ctx.strokeStyle = "rgba(255,255,255,.32)"; + const detailStep = w > 2200 ? 2 : 1; + for (let x = 0; x < w; x += detailStep) { + const p = columnPeak(x); + const y1 = center - Math.max(1, p.max * scale * amp); + const y2 = center + Math.max(1, Math.abs(p.min) * scale * amp); + ctx.beginPath(); + ctx.moveTo(x + .5, y1); + ctx.lineTo(x + .5, y2); + ctx.stroke(); + } + clip.appendChild(canvas); + }; + const updateClipStyle = (clip, seg) => { + clip.style.left = `${frameToX(seg.start)}px`; + clip.style.width = `${Math.max(18, Math.round(Number(seg.length || 1) * pxPerFrame()))}px`; + const time = clip.querySelector(".iamccs-audio-clip-time"); + if (time) time.textContent = `${fmtTime(seg.start)} - ${fmtTime(Number(seg.start || 0) + Number(seg.length || 1))}`; + const trim = clip.querySelector(".iamccs-audio-clip-trim"); + const srcEnd = Number(seg.trimStart || 0) + Number(seg.length || 1); + if (trim) trim.textContent = `src +${fmtTime(Number(seg.trimStart || 0))} -> +${fmtTime(srcEnd)}`; + const marker = clip.querySelector(".iamccs-clip-source-marker"); + if (marker) marker.textContent = `SRC ${fmtTime(srcEnd)}`; + const waveKey = `${Math.round(Number(seg.trimStart || 0))}:${Math.round(Number(seg.length || 1))}:${Array.isArray(seg.waveformPeaks) ? seg.waveformPeaks.length : 0}:${seg.waveformReal ? 1 : 0}`; + if (clip.dataset.waveKey !== waveKey) { + clip.dataset.waveKey = waveKey; + clip.querySelectorAll(".iamccs-clip-wave-svg").forEach((el) => el.remove()); + drawWave(clip, seg); + } + }; + const attachClipPointer = (clip, seg) => { + const beginDrag = (event, forcedMode = "") => { + if (seg.lock || trackSettings(seg.track).lock) return; + event.stopPropagation(); + event.preventDefault(); + root.focus(); + selectedId = seg.id; + state.selectedMixer = { type: "track", track: Math.max(0, Number(seg.track || 0)) }; + const rect = clip.getBoundingClientRect(); + const tool = view().tool; + const pickedMode = forcedMode || "move"; + if (pickedMode === "cut" || (!forcedMode && tool === "cut")) { + const laneX = event.clientX - rect.left + Number(seg.start || 0) * pxPerFrame(); + const frame = Math.max(Number(seg.start || 0), Math.min(Number(seg.start || 0) + Number(seg.length || 1), Math.round(laneX / pxPerFrame()))); + setPlayhead(frame, false); + splitSelectedAtPlayhead(); + return; + } + const mode = pickedMode; + const startX = event.clientX; + const startY = event.clientY; + const start = Number(seg.start || 0); + const length = Number(seg.length || 1); + const trimStart = Number(seg.trimStart || 0); + const track = Number(seg.track || 0); + try { clip.setPointerCapture?.(event.pointerId); } catch {} + clip.classList.toggle("is-trimming", mode === "trim_left" || mode === "trim_right" || mode === "trim"); + clip.classList.toggle("is-moving", mode === "move"); + const marker = clip.querySelector(".iamccs-clip-source-marker"); + if (marker) { + marker.style.display = mode === "trim_left" || mode === "trim_right" || mode === "trim" ? "block" : "none"; + marker.style.left = mode === "trim_left" ? "0" : "auto"; + marker.style.right = mode === "trim_left" ? "auto" : "0"; + } + transport.helper = `${mode.replace("_", " ")} ${String(seg.name || seg.fileName || "clip")}`; + const move = (moveEvent) => { + const delta = Math.round((moveEvent.clientX - startX) / pxPerFrame()); + const trackDelta = Math.round((moveEvent.clientY - startY) / Math.max(1, view().trackHeight)); + if (mode === "trim_left") { + const applied = Math.max(-trimStart, Math.min(length - 1, delta)); + seg.start = Math.max(0, start + applied); + seg.trimStart = Math.max(0, trimStart + applied); + seg.length = Math.max(1, length - applied); + } else if (mode === "trim_right" || mode === "trim") { + seg.length = Math.max(1, Math.min(Number(seg.audioDurationFrames || 999999) - Number(seg.trimStart || 0), length + delta)); + } else { + seg.start = Math.max(0, start + delta); + seg.track = Math.max(0, Math.min(Math.max(1, Number(state.audioTrackCount || 4)) - 1, track + trackDelta)); + } + transport.helper = `${mode.replace("_", " ")} | ${fmtTime(seg.start)} - ${fmtTime(Number(seg.start || 0) + Number(seg.length || 1))} | source +${fmtTime(seg.trimStart || 0)}`; + updateClipStyle(clip, seg); + scheduleSilentStateWrite(`live_${mode}`); + scheduleShotboardSync(`live_${mode}`); + moveEvent.preventDefault(); + moveEvent.stopPropagation(); + }; + const up = (upEvent) => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + if (liveStateWriteTimer) { + window.clearTimeout(liveStateWriteTimer); + liveStateWriteTimer = 0; + } + try { clip.releasePointerCapture?.(upEvent.pointerId); } catch {} + clip.classList.remove("is-trimming", "is-moving"); + const marker = clip.querySelector(".iamccs-clip-source-marker"); + if (marker) marker.style.display = "none"; + if (shotboardSyncTimer) { + window.clearTimeout(shotboardSyncTimer); + shotboardSyncTimer = 0; + } + addEdit(`${mode.replace("_", " ")} applied to clip.`); + writeState(mode); + draw(); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up, { once: true }); + }; + const edgeMode = (event) => { + const rect = clip.getBoundingClientRect(); + const edgeWidth = Math.min(40, Math.max(24, rect.width * .28)); + if (event.target?.closest?.(".iamccs-clip-handle.left")) return "trim_left"; + if (event.target?.closest?.(".iamccs-clip-handle.right")) return "trim_right"; + if (event.clientX - rect.left <= edgeWidth) return "trim_left"; + if (rect.right - event.clientX <= edgeWidth) return "trim_right"; + return ""; + }; + clip.onpointerdown = (event) => { + if (event.button != null && event.button !== 0) return; + const edge = edgeMode(event); + if (edge) return beginDrag(event, edge); + const tool = view().tool; + if (tool === "cut") return beginDrag(event, "cut"); + if (tool === "trim") return beginDrag(event, "trim_right"); + beginDrag(event, "move"); + }; + clip.ondblclick = (event) => { + event.stopPropagation(); + setPlayhead(Number(seg.start || 0), false); + selectedId = seg.id; + draw(); + }; + }; + const selectedClip = () => segments().find((seg) => seg.id === selectedId) || segments()[0] || null; + const splitSelectedAtPlayhead = () => { + const seg = selectedClip(); + if (!seg) return; + const rel = transport.playhead - Number(seg.start || 0); + if (rel <= 0 || rel >= Number(seg.length || 1)) { + addEdit("Cut ignored: playhead is outside selected clip."); + draw(); + return; + } + const right = { ...seg, id: newId("aud"), start: Number(seg.start || 0) + rel, length: Number(seg.length || 1) - rel, trimStart: Number(seg.trimStart || 0) + rel, name: `${String(seg.name || "clip")} B` }; + seg.length = rel; + segments().push(right); + selectedId = right.id; + addEdit("Split selected clip at playhead."); + writeState("cut"); + draw(); + }; + const trimStartToPlayhead = () => { + const seg = selectedClip(); + if (!seg) return; + const rel = transport.playhead - Number(seg.start || 0); + if (rel <= 0 || rel >= Number(seg.length || 1)) return; + seg.start = transport.playhead; + seg.trimStart = Number(seg.trimStart || 0) + rel; + seg.length = Number(seg.length || 1) - rel; + addEdit("Trimmed clip start to playhead."); + writeState("trim_start"); + draw(); + }; + const trimEndToPlayhead = () => { + const seg = selectedClip(); + if (!seg) return; + const rel = transport.playhead - Number(seg.start || 0); + if (rel <= 0 || rel >= Number(seg.length || 1)) return; + seg.length = rel; + addEdit("Trimmed clip end to playhead."); + writeState("trim_end"); + draw(); + }; + const nudgeSelected = (frames) => { + const seg = selectedClip(); + if (!seg || seg.lock) return; + seg.start = Math.max(0, Number(seg.start || 0) + frames); + addEdit(`Nudged clip ${frames}f.`); + writeState("nudge"); + draw(); + }; + const makeDraggableNumber = (input, options = {}) => { + input.title = `${input.title || ""} Drag horizontally to change value.`.trim(); + let startX = 0; + let startValue = 0; + let dragging = false; + let changed = false; + input.addEventListener("pointerdown", (event) => { + if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey) return; + startX = event.clientX; + startValue = Number(input.value || 0); + dragging = false; + changed = false; + const step = Number(options.step ?? input.step ?? 1) || 1; + input.setPointerCapture?.(event.pointerId); + const move = (moveEvent) => { + const dx = moveEvent.clientX - startX; + if (Math.abs(dx) < 3 && !dragging) return; + dragging = true; + changed = true; + const multiplier = moveEvent.shiftKey ? 10 : moveEvent.altKey ? .1 : 1; + let next = startValue + Math.round(dx / 6) * step * multiplier; + if (options.min != null) next = Math.max(Number(options.min), next); + if (options.max != null) next = Math.min(Number(options.max), next); + input.value = String(Number(next.toFixed(4))); + input.dispatchEvent(new Event("input", { bubbles: true })); + moveEvent.preventDefault(); + }; + const up = (upEvent) => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + try { input.releasePointerCapture?.(upEvent.pointerId); } catch {} + if (changed) input.dispatchEvent(new Event("change", { bubbles: true })); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up, { once: true }); + event.preventDefault(); + }); + input.addEventListener("click", (event) => { + if (dragging) event.preventDefault(); + }); + return input; + }; + root.addEventListener("keydown", (event) => { + const tag = String(event.target?.tagName || "").toLowerCase(); + if (tag === "input" || tag === "select" || tag === "textarea") return; + const selected = selectedClip(); + const step = event.shiftKey ? 12 : 1; + if (event.code === "Space") { + transport.playing ? stopPlayback(true) : playPlayback(); + event.preventDefault(); + return; + } + if (event.key === "+" || event.key === "=") { + view().timeZoom = Math.min(8, view().timeZoom * 1.2); + addEdit("Shortcut zoom in."); + writeState("shortcut_zoom", false); + draw(); + event.preventDefault(); + return; + } + if (event.key === "-" || event.key === "_") { + view().timeZoom = Math.max(.35, view().timeZoom / 1.2); + addEdit("Shortcut zoom out."); + writeState("shortcut_zoom", false); + draw(); + event.preventDefault(); + return; + } + if (event.key === "c" || event.key === "C") { + splitSelectedAtPlayhead(); + event.preventDefault(); + return; + } + if (!selected) return; + if (event.key === "Delete" || event.key === "Backspace") { + state.audioSegments = segments().filter((seg) => seg.id !== selected.id); + selectedId = ""; + addEdit("Shortcut deleted selected clip."); + writeState("shortcut_delete"); + draw(); + event.preventDefault(); + return; + } + if (event.key === "m" || event.key === "M") selected.mute = !selected.mute; + else if (event.key === "s" || event.key === "S") selected.solo = !selected.solo; + else if (event.key === "n" || event.key === "N") selected.normalizeAudio = !selected.normalizeAudio; + else if (event.key === "l" || event.key === "L") selected.lock = !selected.lock; + else if (event.key === "ArrowLeft" || event.key === "ArrowRight") nudgeSelected((event.key === "ArrowLeft" ? -1 : 1) * step); + else if (event.key === "[") trimStartToPlayhead(); + else if (event.key === "]") trimEndToPlayhead(); + else return; + addEdit(`Shortcut edited selected clip.`); + writeState("shortcut_edit"); + draw(); + event.preventDefault(); + }); + const addButton = (parent, label, handler, klass = "") => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = label; + btn.className = klass; + btn.onclick = handler; + parent.appendChild(btn); + return btn; + }; + const multiGenerationClips = () => segments().filter((seg) => Boolean(seg.multiGenerationClip) || String(seg.timelineId || "").startsWith("T")); + const multiGenerationMapText = () => { + const clips = multiGenerationClips() + .slice() + .sort((a, b) => (Number(a.multiTakeIndex || 0) - Number(b.multiTakeIndex || 0)) || (Number(a.track || 0) - Number(b.track || 0))); + if (!clips.length) return "MULTI inactive. Split source audio to create T1-A1, T2-A2, T3-A3 indexed lanes."; + return clips + .map((seg) => { + const localStart = Number(seg.start || 0); + const globalStart = Number(seg.sourceGlobalStart ?? seg.globalStart ?? localStart); + const suffix = Math.abs(globalStart - localStart) > 0.5 ? ` src ${fmtTime(globalStart)}` : ""; + return `${String(seg.timelineId || `T${String(seg.multiTakeIndex || 1).padStart(2, "0")}`).replace(/^T0/, "T")} - A${Number(seg.track || 0) + 1} ${fmtTime(localStart)}+${fmtTime(Number(seg.length || 1))}${suffix}`; + }) + .join(" | "); + }; + const clearMultiGenerationClips = () => { + const beforeSegments = segments(); + const source = state.multiGeneration?.sourceSegment && typeof state.multiGeneration.sourceSegment === "object" + ? JSON.parse(JSON.stringify(state.multiGeneration.sourceSegment)) + : null; + const sourceId = String(state.multiGeneration?.sourceSegmentId || source?.id || ""); + const removed = beforeSegments.filter((seg) => Boolean(seg.multiGenerationClip) || String(seg.timelineId || "").startsWith("T")).length; + state.audioSegments = beforeSegments.filter((seg) => !(Boolean(seg.multiGenerationClip) || String(seg.timelineId || "").startsWith("T"))); + if (source && !state.audioSegments.some((seg) => String(seg.id || "") === sourceId)) { + state.audioSegments.push(source); + } + state.multiGeneration = { enabled: false, removed, clearedAt: new Date().toISOString() }; + addEdit(`Cleared ${removed} MULTI take clip(s).${source ? " Source restored." : ""}`); + writeState("multi_clear"); + draw(); + }; + const applyMultiGenerationSplit = (options = {}) => { + const chunkSeconds = Math.max(1, Number(options.chunkSeconds || 20)); + const chunkFrames = Math.max(1, secondsToFrames(chunkSeconds)); + const sourceTrack = Math.max(0, Number(options.sourceTrack || 0)); + const destinationStartTrack = Math.max(0, Number(options.destinationStartTrack || 0)); + const takeCountRaw = String(options.takeCount || "auto"); + const splitStartMode = String(options.splitStartMode || state.multiGeneration?.splitStartMode || "all_zero") === "global_source" + ? "global_source" + : "all_zero"; + const localizeStarts = splitStartMode !== "global_source"; + const sourceCandidates = segments() + .filter((seg) => !seg.multiGenerationClip && !String(seg.timelineId || "").startsWith("T")) + .filter((seg) => Number(seg.track || 0) === sourceTrack) + .filter((seg) => hasMedia(seg)) + .sort((a, b) => Number(b.length || b.audioDurationFrames || 0) - Number(a.length || a.audioDurationFrames || 0)); + const source = sourceCandidates[0]; + if (!source) { + addEdit(`MULTI split failed: no source audio on A${sourceTrack + 1}.`); + draw(); + return; + } + const sourceStart = Math.max(0, Math.round(Number(source.start || 0))); + const sourceLength = Math.max(1, Math.round(Number(source.length || source.audioDurationFrames || 1))); + const sourceTrim = Math.max(0, Math.round(Number(source.trimStart || 0))); + const autoTakes = Math.max(1, Math.ceil(sourceLength / chunkFrames)); + const takeCount = takeCountRaw === "auto" + ? autoTakes + : Math.max(1, Math.min(autoTakes, Math.round(Number(takeCountRaw) || autoTakes))); + const sourceId = String(source.id || ""); + const baseSegments = segments().filter((seg) => { + if (Boolean(seg.multiGenerationClip) || String(seg.timelineId || "").startsWith("T")) return false; + return String(seg.id || "") !== sourceId; + }); + const created = []; + for (let index = 0; index < takeCount; index += 1) { + const offset = index * chunkFrames; + if (offset >= sourceLength) break; + const length = Math.max(1, Math.min(chunkFrames, sourceLength - offset)); + const takeIndex = index + 1; + const timelineId = `T${String(takeIndex).padStart(2, "0")}`; + const clone = { + ...JSON.parse(JSON.stringify(source)), + id: newId(`multi_${timelineId.toLowerCase()}`), + name: `${String(source.name || source.fileName || source.audioFile || "audio").slice(0, 42)} ${timelineId}`, + track: destinationStartTrack + index, + start: localizeStarts ? 0 : sourceStart + offset, + length, + trimStart: sourceTrim + offset, + audioDurationFrames: Math.max(sourceTrim + offset + length, Number(source.audioDurationFrames || sourceLength)), + mute: false, + solo: false, + multiGenerationClip: true, + multiTakeIndex: takeIndex, + timelineId, + sourceSegmentId: sourceId, + sourceTrack, + sourceGlobalStart: sourceStart + offset, + sourceGlobalEnd: sourceStart + offset + length, + globalStart: sourceStart + offset, + globalEnd: sourceStart + offset + length, + localStart: 0, + splitStartMode, + chunkFrames, + chunkSeconds, + }; + created.push(clone); + } + state.audioSegments = baseSegments.concat(created); + state.audioTrackCount = Math.max(Number(state.audioTrackCount || 1), destinationStartTrack + created.length); + state.audioBusMode = "only_first"; + state.onlyFirstTrack = true; + state.multiGeneration = { + enabled: true, + template: `${chunkSeconds}s`, + chunkSeconds, + chunkFrames, + sourceTrack, + sourceSegment: JSON.parse(JSON.stringify(source)), + sourceSegmentId: sourceId, + destinationStartTrack, + splitStartMode, + takeCount: created.length, + timelineIds: created.map((seg) => seg.timelineId), + laneIndex: created.map((seg) => ({ + timeline_id: seg.timelineId, + take_index: seg.multiTakeIndex, + track_index: seg.track, + track_name: `A${Number(seg.track || 0) + 1}`, + audio_chunk_id: seg.id, + source_segment_id: sourceId, + start: seg.start, + local_start: seg.start, + source_global_start: seg.sourceGlobalStart, + source_global_end: seg.sourceGlobalEnd, + length: seg.length, + })), + updatedAt: new Date().toISOString(), + }; + addEdit(`MULTI split created ${created.length} take lane(s): ${multiGenerationMapText()}.`); + writeState("multi_split"); + draw(); + }; + const makeMultiSelect = (labelText, values, value, onChange) => { + const wrap = document.createElement("label"); + wrap.textContent = labelText; + const select = document.createElement("select"); + for (const [optionValue, optionLabel] of values) { + const option = document.createElement("option"); + option.value = String(optionValue); + option.textContent = optionLabel; + select.appendChild(option); + } + select.value = String(value); + select.onchange = () => onChange(select.value); + wrap.appendChild(select); + return { wrap, select }; + }; + const appendMultiGenerationStrip = (parent) => { + const multi = state.multiGeneration && typeof state.multiGeneration === "object" ? state.multiGeneration : {}; + const strip = document.createElement("div"); + strip.className = "iamccs-audio-board-multi"; + const title = document.createElement("div"); + title.className = "iamccs-audio-board-multi-title"; + title.innerHTML = `MULTIGENERATIONaudio chunk lanes`; + let chunkSeconds = Number(multi.chunkSeconds || 20); + let takeCount = "auto"; + let sourceTrack = Number(multi.sourceTrack || 0); + let destinationStartTrack = Number(multi.destinationStartTrack || 0); + let splitStartMode = String(multi.splitStartMode || "all_zero") === "global_source" ? "global_source" : "all_zero"; + const chunk = makeMultiSelect("Template", [["10", "10 sec"], ["15", "15 sec"], ["20", "20 sec"], ["25", "25 sec"], ["custom", "custom"]], [10, 15, 20, 25].includes(chunkSeconds) ? String(chunkSeconds) : "custom", (value) => { + if (value !== "custom") customInput.value = value; + chunkSeconds = Math.max(1, Number(customInput.value || value || 20)); + }); + const customLabel = document.createElement("label"); + customLabel.textContent = "Seconds"; + const customInput = document.createElement("input"); + customInput.type = "number"; + customInput.min = "1"; + customInput.max = "300"; + customInput.step = "0.25"; + customInput.value = String(chunkSeconds); + customInput.onchange = () => { chunkSeconds = Math.max(1, Number(customInput.value || 20)); }; + customLabel.appendChild(customInput); + const takes = makeMultiSelect("Takes", [["auto", "auto"], ["2", "2 takes"], ["3", "3 takes"], ["4", "4 takes"], ["5", "5 takes"]], takeCount, (value) => { takeCount = value; }); + const source = makeMultiSelect("Source", Array.from({ length: Math.max(5, Number(state.audioTrackCount || 5)) }, (_, i) => [String(i), `A${i + 1}`]), sourceTrack, (value) => { sourceTrack = Number(value || 0); }); + const dest = makeMultiSelect("T1 lane", Array.from({ length: Math.max(5, Number(state.audioTrackCount || 5)) }, (_, i) => [String(i), `A${i + 1}`]), destinationStartTrack, (value) => { destinationStartTrack = Number(value || 0); }); + const startMode = makeMultiSelect("Start", [["all_zero", "all T @ 0"], ["global_source", "keep source time"]], splitStartMode, (value) => { splitStartMode = String(value || "all_zero"); }); + const split = document.createElement("button"); + split.type = "button"; + split.className = "is-primary"; + split.textContent = "Split To T Lanes"; + split.onclick = () => applyMultiGenerationSplit({ chunkSeconds, sourceTrack, destinationStartTrack, takeCount, splitStartMode }); + const clear = document.createElement("button"); + clear.type = "button"; + clear.className = "danger"; + clear.textContent = "Clear MULTI"; + clear.onclick = clearMultiGenerationClips; + const map = document.createElement("div"); + map.className = "iamccs-audio-board-multi-map"; + map.textContent = multiGenerationMapText(); + strip.append(title, chunk.wrap, customLabel, takes.wrap, source.wrap, dest.wrap, startMode.wrap, split, clear, map); + parent.appendChild(strip); + }; + const paintInlineCanvas = (canvas, kind, seg, staticPeak = 0) => { + const rect = canvas.getBoundingClientRect(); + const w = Math.max(180, Math.round(rect.width || 260)); + const h = Math.max(70, Math.round(rect.height || 90)); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = "#061012"; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = "rgba(255,255,255,.08)"; + ctx.lineWidth = 1; + for (let x = 0; x <= w; x += Math.max(24, w / 8)) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + for (let y = 0; y <= h; y += Math.max(18, h / 4)) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + if (kind === "wave") { + const peaks = Array.isArray(seg?.waveformPeaks) ? seg.waveformPeaks : []; + const mid = h * .5; + const amp = h * .42; + ctx.strokeStyle = "rgba(244,212,158,.92)"; + ctx.fillStyle = "rgba(85,199,185,.34)"; + ctx.beginPath(); + if (!peaks.length) { + ctx.moveTo(0, mid); + ctx.lineTo(w, mid); + } else { + for (let i = 0; i < peaks.length; i += 1) { + const item = peaks[i] || {}; + const x = (i / Math.max(1, peaks.length - 1)) * w; + const max = Math.max(0, Math.abs(Number(item.max ?? item) || 0)); + const min = Math.max(0, Math.abs(Number(item.min ?? item) || 0)); + const top = mid - Math.max(max, min) * amp; + if (i === 0) ctx.moveTo(x, top); + else ctx.lineTo(x, top); + } + for (let i = peaks.length - 1; i >= 0; i -= 1) { + const item = peaks[i] || {}; + const x = (i / Math.max(1, peaks.length - 1)) * w; + const max = Math.max(0, Math.abs(Number(item.max ?? item) || 0)); + const min = Math.max(0, Math.abs(Number(item.min ?? item) || 0)); + ctx.lineTo(x, mid + Math.max(max, min) * amp); + } + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.strokeStyle = "rgba(255,255,255,.18)"; + ctx.beginPath(); + ctx.moveTo(0, mid); + ctx.lineTo(w, mid); + ctx.stroke(); + return; + } + if (kind === "eq") { + const eqFx = canvas._iamccsFx?.type === "eq" ? canvas._iamccsFx : chainForTarget().find((fx) => fx.enabled !== false && fx.type === "eq"); + normalizeEffect(eqFx); + const low = Number(eqFx?.params?.low ?? seg?.eqLowDb ?? 0); + const midDb = Number(eqFx?.params?.mid ?? seg?.eqMidDb ?? 0); + const high = Number(eqFx?.params?.high ?? seg?.eqHighDb ?? 0); + const hpf = eqFx?.params?.lowCut ? Number(eqFx.params.lowCutFreq || 80) : Number(seg?.hpfHz || 0); + const lpf = eqFx?.params?.highCut ? Number(eqFx.params.highCutFreq || 12000) : Number(seg?.lpfHz || 22000); + const lowCutLevel = Math.max(-48, Math.min(0, Number(eqFx?.params?.lowCutLevel ?? -30))); + const highCutLevel = Math.max(-48, Math.min(0, Number(eqFx?.params?.highCutLevel ?? -30))); + const center = h * .5; + const clampDb = (db) => Math.max(-24, Math.min(24, Number(db || 0))); + const yFromDb = (db) => center - (clampDb(db) / 48) * h; + const yCutFromDb = (db) => center - (Math.max(-48, Math.min(0, Number(db || 0))) / 72) * h; + const freqToX = (hz) => { + const t = (Math.log10(Math.max(20, Math.min(22000, Number(hz || 20)))) - Math.log10(20)) / (Math.log10(22000) - Math.log10(20)); + return Math.max(0, Math.min(w, t * w)); + }; + const drawSmooth = (points, stroke, width = 2.2) => { + ctx.strokeStyle = stroke; + ctx.lineWidth = width; + ctx.beginPath(); + points.forEach((p, i) => { + if (i === 0) ctx.moveTo(p[0], p[1]); + else { + const p0 = points[Math.max(0, i - 2)]; + const p1 = points[i - 1]; + const p2 = p; + const p3 = points[Math.min(points.length - 1, i + 1)]; + const cp1x = p1[0] + (p2[0] - p0[0]) / 6; + const cp1y = p1[1] + (p2[1] - p0[1]) / 6; + const cp2x = p2[0] - (p3[0] - p1[0]) / 6; + const cp2y = p2[1] - (p3[1] - p1[1]) / 6; + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2[0], p2[1]); + } + }); + ctx.stroke(); + }; + const lowX = freqToX(140); + const midX = freqToX(1200); + const highX = freqToX(6200); + const lowCutX = freqToX(hpf || 20); + const highCutX = freqToX(lpf || 22000); + if (hpf > 20) { + ctx.fillStyle = "rgba(214,91,66,.11)"; + ctx.fillRect(0, 0, Math.max(0, lowCutX), h); + } + if (lpf < 22000) { + ctx.fillStyle = "rgba(214,91,66,.09)"; + ctx.fillRect(highCutX, 0, Math.max(0, w - highCutX), h); + } + const mainPoints = [ + [0, hpf > 20 ? yCutFromDb(lowCutLevel) : center], + ...(hpf > 20 ? [[lowCutX, center]] : []), + [lowX, yFromDb(low)], + [midX, yFromDb(midDb)], + [highX, yFromDb(high)], + ...(lpf < 22000 ? [[highCutX, center]] : []), + [w, lpf < 22000 ? yCutFromDb(highCutLevel) : center], + ].sort((a, b) => a[0] - b[0]); + ctx.fillStyle = "rgba(85,199,185,.08)"; + ctx.beginPath(); + mainPoints.forEach((p, i) => { + if (i === 0) ctx.moveTo(p[0], p[1]); + else ctx.lineTo(p[0], p[1]); + }); + ctx.lineTo(w, center); + ctx.lineTo(0, center); + ctx.closePath(); + ctx.fill(); + drawSmooth(mainPoints, "#f5d08e", 2.4); + if (hpf > 20) { + ctx.fillStyle = "#ffac72"; + ctx.beginPath(); + ctx.arc(lowCutX, center, 5, 0, Math.PI * 2); + ctx.fill(); + } + if (lpf < 22000) { + ctx.fillStyle = "#ffac72"; + ctx.beginPath(); + ctx.arc(highCutX, center, 5, 0, Math.PI * 2); + ctx.fill(); + } + const points = [ + [lowX / Math.max(1, w), low, "#d6a75e", "L"], + [midX / Math.max(1, w), midDb, "#55c7b9", "M"], + [highX / Math.max(1, w), high, "#d08963", "H"], + ]; + ctx.font = "900 10px ui-monospace, Consolas, monospace"; + for (const [tx, db, color, label] of points) { + const x = tx * w; + const y = yFromDb(db); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(x, y, 6, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = "rgba(0,0,0,.72)"; + ctx.stroke(); + ctx.fillStyle = "#fff2cf"; + ctx.fillText(label, x + 8, y - 8); + } + ctx.fillStyle = "#ffc67f"; + ctx.font = "900 9px ui-monospace, Consolas, monospace"; + if (hpf > 20) ctx.fillText(`LC ${Math.round(hpf)}Hz ${Math.round(lowCutLevel)}dB`, 8, h - 9); + if (lpf < 22000) ctx.fillText(`HC ${(lpf / 1000).toFixed(1)}k ${Math.round(highCutLevel)}dB`, Math.max(8, w - 116), h - 9); + return; + } + const currentFx = canvas._iamccsFx || null; + const activeFx = currentFx || chainForTarget().find((fx) => fx.enabled !== false && (fx.type === "compressor" || fx.type === "limiter")); + const isLimiter = activeFx?.type === "limiter"; + const compFx = activeFx?.type === "compressor" ? activeFx : chainForTarget().find((fx) => fx.enabled !== false && fx.type === "compressor"); + const limFx = activeFx?.type === "limiter" ? activeFx : chainForTarget().find((fx) => fx.enabled !== false && fx.type === "limiter"); + const threshold = Math.max(-60, Math.min(0, Number(compFx?.params?.threshold ?? -18))); + const ratio = Math.max(1, Math.min(20, Number(compFx?.params?.ratio ?? 4))); + const ceiling = Math.max(-12, Math.min(0, Number(limFx?.params?.ceiling ?? -1))); + const inputDb = Math.max(-12, Math.min(12, Number(limFx?.params?.input ?? 0))); + const outputDb = Math.max(-12, Math.min(6, Number(limFx?.params?.output ?? 0))); + const dbToX = (db, minDb, maxDb) => ((Math.max(minDb, Math.min(maxDb, db)) - minDb) / Math.max(.001, maxDb - minDb)) * w; + const compY = (db) => h - ((Math.max(-60, Math.min(0, db)) + 60) / 60) * h; + const limY = (db) => h - ((Math.max(-12, Math.min(6, db)) + 12) / 18) * h; + ctx.strokeStyle = "rgba(255,226,168,.18)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, h - 18); + ctx.lineTo(w, h - 18); + ctx.moveTo(28, 0); + ctx.lineTo(28, h); + ctx.stroke(); + ctx.fillStyle = "rgba(255,226,168,.76)"; + ctx.font = "900 9px ui-monospace, Consolas, monospace"; + if (isLimiter) { + ctx.fillText("IN -12dB", 34, h - 7); + ctx.fillText("0dB", w - 35, h - 7); + ctx.fillText("OUT", 6, 13); + ctx.strokeStyle = "rgba(143,208,204,.86)"; + ctx.lineWidth = 2.2; + ctx.beginPath(); + for (let x = 0; x <= w; x += 1) { + const inDb = -12 + (x / Math.max(1, w)) * 12 + inputDb; + const limited = Math.min(ceiling, inDb); + const outDb = Math.max(-12, Math.min(6, limited + outputDb)); + const y = limY(outDb); + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + const px = dbToX(ceiling, -12, 0); + const py = limY(outputDb); + ctx.strokeStyle = "rgba(245,208,142,.38)"; + ctx.beginPath(); + ctx.moveTo(px, 0); + ctx.lineTo(px, h); + ctx.moveTo(0, py); + ctx.lineTo(w, py); + ctx.stroke(); + ctx.fillStyle = "#f5d08e"; + ctx.beginPath(); + ctx.arc(px, py, 9, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = "rgba(0,0,0,.75)"; + ctx.stroke(); + ctx.fillStyle = "#fff2cf"; + ctx.fillText(`CEIL ${ceiling.toFixed(1)} OUT ${outputDb.toFixed(1)}`, Math.min(w - 142, px + 12), Math.max(14, py - 10)); + } else { + ctx.fillText("-60dB", 34, h - 7); + ctx.fillText("0dB", w - 35, h - 7); + ctx.fillText("OUT", 6, 13); + ctx.strokeStyle = "rgba(255,255,255,.18)"; + ctx.beginPath(); + ctx.moveTo(28, h - 18); + ctx.lineTo(w, 8); + ctx.stroke(); + ctx.strokeStyle = "rgba(143,208,204,.86)"; + ctx.lineWidth = 2.4; + ctx.beginPath(); + for (let x = 28; x <= w; x += 1) { + const inDb = -60 + ((x - 28) / Math.max(1, w - 28)) * 60; + const over = Math.max(0, inDb - threshold); + const outDb = inDb <= threshold ? inDb : threshold + over / ratio; + const y = compY(outDb); + if (x === 28) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + const px = 28 + ((threshold + 60) / 60) * Math.max(1, w - 28); + const py = h - ((ratio - 1) / 19) * (h - 24) - 12; + ctx.strokeStyle = "rgba(245,208,142,.38)"; + ctx.beginPath(); + ctx.moveTo(px, 0); + ctx.lineTo(px, h); + ctx.moveTo(0, py); + ctx.lineTo(w, py); + ctx.stroke(); + ctx.fillStyle = "#d6a75e"; + ctx.beginPath(); + ctx.arc(px, py, 9, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = "rgba(0,0,0,.75)"; + ctx.stroke(); + ctx.fillStyle = "#fff2cf"; + ctx.fillText(`THR ${threshold.toFixed(0)}dB R ${ratio.toFixed(1)}:1`, Math.min(w - 150, px + 12), Math.max(14, py - 10)); + } + const peak = Math.max(0, Math.min(1, staticPeak)); + ctx.fillStyle = "rgba(85,199,185,.86)"; + ctx.fillRect(8, h - Math.max(2, peak * h), 8, Math.max(2, peak * h)); + }; + const appendInlineEfxPreview = (parent, selected, staticPeak) => { + const efx = document.createElement("div"); + efx.className = "iamccs-inline-efx"; + const target = selectedMixer(); + const targetName = target.type === "track" ? `A${target.track + 1}` : "MASTER OUT"; + const chain = chainForTarget(); + const deviceColors = { + eq: ["#6a5532", "#29251c", "#0b0d0c"], + compressor: ["#5a3f2c", "#2d241b", "#0b0d0c"], + limiter: ["#673631", "#2c1e1b", "#0b0d0c"], + gate: ["#394d3f", "#1d2922", "#0b0d0c"], + reverb: ["#35445f", "#1b2232", "#090b12"], + delay: ["#5b4a2d", "#2a2418", "#0d0b08"], + saturator: ["#6b4026", "#322116", "#0d0907"], + utility: ["#3e514d", "#1c2927", "#071010"], + stereo: ["#423c60", "#211f32", "#0a0912"], + deesser: ["#4b5a35", "#242d1c", "#090d08"], + transient: ["#614c34", "#2c251a", "#0c0a08"], + tape: ["#57412b", "#2b2118", "#0d0906"], + chorus: ["#34565d", "#1a2b30", "#071012"], + }; + efx.innerHTML = `
DEVICE RACK / AUDIO CONTROL EFX${targetName}
`; + const grid = document.createElement("div"); + grid.className = "iamccs-inline-efx-grid"; + const chainEl = document.createElement("div"); + chainEl.className = "iamccs-device-chain"; + const previewForFx = (type) => { + if (type === "eq") return ["eq", "EQ 3-Point Editor"]; + if (type === "compressor" || type === "limiter" || type === "gate" || type === "deesser" || type === "transient") return ["bus", `${String(type || "Dynamics").toUpperCase()} Editor`]; + return ["placeholder", `${String(type || "FX").toUpperCase()} Preview`]; + }; + if (!chain.length) { + const module = document.createElement("div"); + module.className = "iamccs-device-module"; + const empty = document.createElement("div"); + empty.className = "iamccs-audio-device"; + empty.innerHTML = `
NO DEVICE
Use the ${target.type === "track" ? "track" : "master"} FX dropdown to insert a device.
`; + const ph = document.createElement("div"); + ph.className = "iamccs-inline-efx-panel"; + ph.innerHTML = `
Insert an effect to show its control preview here.
`; + module.append(empty, ph); + chainEl.appendChild(module); + } + chain.forEach((fx, index) => { + normalizeEffect(fx); + const module = document.createElement("div"); + module.className = "iamccs-device-module"; + const device = document.createElement("div"); + device.className = `iamccs-audio-device iamccs-device-${String(fx.type || "device").replace(/[^a-z0-9_-]/gi, "")}`; + const palette = deviceColors[fx.type] || ["#3a3328", "#171714", "#0b0d0c"]; + device.style.setProperty("--device-hi", palette[0]); + device.style.setProperty("--device-mid", palette[1]); + device.style.setProperty("--device-lo", palette[2]); + const name = EFFECT_CHOICES.find(([value]) => value === fx.type)?.[1] || String(fx.type || "FX"); + const power = document.createElement("button"); + power.type = "button"; + power.className = `iamccs-device-power${fx.enabled === false ? " is-off" : ""}`; + power.title = "Enable / bypass"; + power.onpointerdown = (event) => event.stopPropagation(); + power.onclick = (event) => { + event.stopPropagation(); + fx.enabled = fx.enabled === false; + addEdit(`${targetName} ${name} ${fx.enabled ? "enabled" : "bypassed"}.`); + writeState("toggle_effect"); + draw(); + }; + const head = document.createElement("div"); + head.className = "iamccs-device-head"; + const deviceTitle = document.createElement("span"); + deviceTitle.textContent = `${index + 1}. ${name}`; + const headActions = document.createElement("div"); + headActions.className = "iamccs-device-head-actions"; + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "iamccs-device-remove"; + remove.title = "Remove device"; + remove.textContent = "x"; + remove.onpointerdown = (event) => event.stopPropagation(); + remove.onclick = (event) => { + event.stopPropagation(); + removeEffectFromChain(fx, target); + }; + const lamp = document.createElement("span"); + lamp.className = `iamccs-device-lamp${fx.enabled === false ? " is-off" : ""}`; + lamp.title = fx.enabled === false ? "Device bypassed" : "Device active"; + headActions.append(lamp, power, remove); + head.append(deviceTitle, headActions); + const knobs = document.createElement("div"); + knobs.className = "iamccs-device-knobs"; + effectParamSpecs(fx.type).forEach(([key, knobName, min, max, step, fallback, unit]) => { + if (fx.params[key] == null) fx.params[key] = fallback; + const knob = document.createElement("div"); + knob.className = "iamccs-device-knob"; + const dial = document.createElement("i"); + const value = Math.max(Number(min), Math.min(Number(max), Number(fx.params[key] ?? fallback))); + const amount = (value - Number(min)) / Math.max(.0001, Number(max) - Number(min)); + dial.style.setProperty("--knob-angle", `${-135 + amount * 270}deg`); + const text = document.createElement("span"); + text.textContent = knobName; + const valueText = document.createElement("em"); + valueText.textContent = `${Number(value.toFixed(2))}${unit || ""}`; + const range = document.createElement("input"); + range.type = "range"; + range.min = String(min); + range.max = String(max); + range.step = String(step); + range.value = String(value); + const applyParam = (nextValue, commit = false) => { + const next = Math.max(Number(min), Math.min(Number(max), Number(nextValue ?? range.value ?? fallback))); + range.value = String(next); + fx.params[key] = next; + const t = (next - Number(min)) / Math.max(.0001, Number(max) - Number(min)); + dial.style.setProperty("--knob-angle", `${-135 + t * 270}deg`); + valueText.textContent = `${Number(next.toFixed(2))}${unit || ""}`; + if (fx.type === "compressor" && target.type === "master") state.masterBus.compressor = Math.max(.01, Math.min(1, (Math.abs(Number(fx.params.threshold || -18)) / 60) + .15)); + if (fx.type === "limiter" && target.type === "master") { + state.masterBus.limiter = true; + state.masterBus.ceilingDb = Number(fx.params.ceiling ?? state.masterBus.ceilingDb ?? -1); + } + module.querySelectorAll(".iamccs-inline-efx-canvas").forEach((canvas) => paintInlineCanvas(canvas, canvas.dataset.kind || "", selected, staticPeak)); + if (commit) { + addEdit(`${targetName} ${name}: ${knobName} ${valueText.textContent}.`); + writeState(`fx_${fx.type}_${key}`); + } else { + scheduleSilentStateWrite(`fx_${fx.type}_${key}`); + scheduleShotboardSync(`fx_${fx.type}_${key}`); + } + }; + range.oninput = (event) => { + event.stopPropagation(); + applyParam(Number(range.value || fallback), false); + }; + range.onchange = () => { + if (shotboardSyncTimer) { + window.clearTimeout(shotboardSyncTimer); + shotboardSyncTimer = 0; + } + applyParam(Number(range.value || fallback), true); + }; + dial.onpointerdown = (event) => { + event.stopPropagation(); + event.preventDefault(); + const startX = event.clientX; + const startValue = Number(fx.params[key] ?? fallback); + try { dial.setPointerCapture?.(event.pointerId); } catch {} + const move = (moveEvent) => { + const mult = moveEvent.shiftKey ? 10 : moveEvent.altKey ? .1 : 1; + let next = startValue + Math.round((moveEvent.clientX - startX) / 5) * Number(step) * mult; + next = Math.max(Number(min), Math.min(Number(max), next)); + range.value = String(Number(next.toFixed(4))); + applyParam(next, false); + moveEvent.preventDefault(); + }; + const up = (upEvent) => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + try { dial.releasePointerCapture?.(upEvent.pointerId); } catch {} + if (shotboardSyncTimer) { + window.clearTimeout(shotboardSyncTimer); + shotboardSyncTimer = 0; + } + applyParam(Number(range.value || fallback), true); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up, { once: true }); + }; + knob.append(dial, text, valueText, range); + knobs.appendChild(knob); + }); + const eqSwitches = document.createElement("div"); + eqSwitches.className = "iamccs-eq-switches"; + if (fx.type === "eq") { + [["LOW CUT", "lowCut"], ["HIGH CUT", "highCut"]].forEach(([labelText, key]) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = labelText; + btn.className = fx.params[key] ? "is-active" : ""; + btn.onclick = (event) => { + event.stopPropagation(); + fx.params[key] = !fx.params[key]; + addEdit(`${targetName} EQ ${labelText} ${fx.params[key] ? "on" : "off"}.`); + writeState(`eq_${key}`); + draw(); + }; + eqSwitches.appendChild(btn); + }); + } + device.append(head); + if (fx.type === "eq") device.appendChild(eqSwitches); + device.appendChild(knobs); + const [kind, labelText] = previewForFx(fx.type); + const panel = document.createElement("div"); + panel.className = "iamccs-inline-efx-panel"; + const previewLabel = document.createElement("label"); + previewLabel.textContent = labelText; + let canvas = null; + if (kind === "placeholder") { + const ph = document.createElement("div"); + ph.className = "iamccs-efx-placeholder"; + ph.textContent = `${labelText}: realtime preview placeholder`; + panel.append(previewLabel, ph); + } else { + canvas = document.createElement("canvas"); + canvas.className = "iamccs-inline-efx-canvas"; + canvas.dataset.kind = kind; + canvas._iamccsFx = fx; + panel.append(previewLabel, canvas); + } + if (kind === "eq") { + canvas.onpointerdown = (event) => { + const eqFx = chainForTarget().find((fx) => fx.type === "eq"); + if (!eqFx) return; + normalizeEffect(eqFx); + const rect = canvas.getBoundingClientRect(); + const update = (ev, commit = false) => { + const x = Math.max(0, Math.min(1, (ev.clientX - rect.left) / Math.max(1, rect.width))); + const y = Math.max(0, Math.min(1, (ev.clientY - rect.top) / Math.max(1, rect.height))); + const points = [ + { key: "low", x: .27 }, + { key: "mid", x: .58 }, + { key: "high", x: .82 }, + ]; + const nearest = points.reduce((best, item) => Math.abs(item.x - x) < Math.abs(best.x - x) ? item : best, points[0]); + const key = nearest.key; + eqFx.params[key] = Number(((.5 - y) * 48).toFixed(2)); + paintInlineCanvas(canvas, "eq", selected, staticPeak); + if (commit) writeState(`eq_point_${key}`); + else scheduleSilentStateWrite(`eq_point_${key}`); + if (!commit) scheduleShotboardSync(`eq_point_${key}`); + }; + update(event, false); + const move = (ev) => { update(ev, false); ev.preventDefault(); }; + const up = (ev) => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + update(ev, true); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up, { once: true }); + }; + } + if (kind === "bus") { + canvas.onpointerdown = (event) => { + const activeFx = canvas._iamccsFx || chainForTarget().find((fx) => fx.type === "compressor" || fx.type === "limiter"); + if (!activeFx) return; + normalizeEffect(activeFx); + const rect = canvas.getBoundingClientRect(); + const update = (ev, commit = false) => { + const x = Math.max(0, Math.min(1, (ev.clientX - rect.left) / Math.max(1, rect.width))); + const y = Math.max(0, Math.min(1, (ev.clientY - rect.top) / Math.max(1, rect.height))); + if (activeFx.type === "limiter") { + activeFx.params.ceiling = Number((-12 + x * 12).toFixed(1)); + activeFx.params.output = Number((-12 + (1 - y) * 18).toFixed(1)); + } else { + activeFx.params.threshold = Number((-60 + x * 60).toFixed(1)); + activeFx.params.ratio = Number((1 + (1 - y) * 19).toFixed(1)); + } + paintInlineCanvas(canvas, "bus", selected, staticPeak); + if (commit) writeState(`${activeFx.type}_point`); + else scheduleSilentStateWrite(`${activeFx.type}_point`); + if (!commit) scheduleShotboardSync(`${activeFx.type}_point`); + }; + update(event, false); + const move = (ev) => { update(ev, false); ev.preventDefault(); }; + const up = (ev) => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + update(ev, true); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up, { once: true }); + }; + } + module.append(device, panel); + chainEl.appendChild(module); + if (canvas) requestAnimationFrame(() => paintInlineCanvas(canvas, kind, selected, staticPeak)); + }); + grid.appendChild(chainEl); + efx.appendChild(grid); + parent.appendChild(efx); + }; + const draw = () => { + root.querySelectorAll(".iamccs-audio-board-dynamic").forEach((el) => el.remove()); + const dynamic = document.createElement("div"); + dynamic.className = "iamccs-audio-board-dynamic"; + const head = document.createElement("div"); + head.className = "iamccs-audio-board-head"; + const left = document.createElement("div"); + left.innerHTML = `
IAMCCS AudioBoard Arranger
${segments().length} clips / ${Math.max(1, Number(state.audioTrackCount || 4))} tracks / ${fmtTime(totalFrames())} / sync V3: ${linkedShotboardNodes().length}
`; + const tools = document.createElement("div"); + tools.className = "iamccs-audio-board-tools"; + addButton(tools, root._iamccsAudioFullscreenState ? "Close Editor" : "Open Editor", () => toggleAudioFullscreen(), root._iamccsAudioFullscreenState ? "is-active" : ""); + addButton(tools, "MULTI", () => { state.showMultiGeneration = !state.showMultiGeneration; addEdit(`Multigeneration panel ${state.showMultiGeneration ? "shown" : "hidden"}.`); writeState("toggle_multi", false); draw(); }, state.showMultiGeneration ? "is-active" : ""); + addButton(tools, "Import Audio", () => fileInput.click()); + addButton(tools, "Add Track", () => { state.audioTrackCount = Math.max(1, Number(state.audioTrackCount || 4)) + 1; addEdit("Added audio track."); writeState("add_track"); draw(); }); + addButton(tools, "ONLY FIRST", () => { + state.audioBusMode = state.audioBusMode === "only_first" ? "all_tracks" : "only_first"; + state.onlyFirstTrack = state.audioBusMode === "only_first"; + addEdit(state.onlyFirstTrack ? "Shotboard V3 sync uses only track A1." : "Shotboard V3 sync uses all tracks."); + writeState("bus_mode"); + draw(); + }, state.audioBusMode === "only_first" ? "is-active" : ""); + for (const tool of ["cursor", "move", "trim", "cut"]) { + addButton(tools, tool.toUpperCase(), () => { view().tool = tool; addEdit(`Tool: ${tool}.`); writeState("tool", false); draw(); }, view().tool === tool ? "is-active" : ""); + } + addButton(tools, "Zoom +", () => { view().timeZoom = Math.min(8, view().timeZoom * 1.25); addEdit("Zoomed timeline in."); writeState("zoom", false); draw(); }); + addButton(tools, "Zoom -", () => { view().timeZoom = Math.max(.35, view().timeZoom / 1.25); addEdit("Zoomed timeline out."); writeState("zoom", false); draw(); }); + addButton(tools, "Tall +", () => { view().trackHeight = Math.min(200, view().trackHeight + 10); addEdit("Increased track height."); writeState("track_height", false); draw(); }); + addButton(tools, "Tall -", () => { view().trackHeight = Math.max(144, view().trackHeight - 10); addEdit("Reduced track height."); writeState("track_height", false); draw(); }); + addButton(tools, "Event Monitor", () => { state.showEventMonitor = !state.showEventMonitor; addEdit(`Event monitor ${state.showEventMonitor ? "shown" : "hidden"}.`); writeState("event_monitor", false); draw(); }, state.showEventMonitor ? "is-active" : ""); + addButton(tools, "Sync Now", () => { writeState("manual_sync"); draw(); }); + addButton(tools, "Pull V3", () => pullFromShotboard()); + addButton(tools, "Clear", () => { stopPlayback(false); state.audioSegments = []; selectedId = ""; addEdit("Cleared arranger clips."); writeState("clear"); draw(); }, "danger"); + head.append(left, tools); + dynamic.appendChild(head); + if (state.showMultiGeneration || (state.multiGeneration && state.multiGeneration.enabled)) appendMultiGenerationStrip(dynamic); + + const transportBar = document.createElement("div"); + transportBar.className = "iamccs-audio-board-transport"; + const transportLeft = document.createElement("div"); + transportLeft.className = "iamccs-transport-left"; + const transportCenter = document.createElement("div"); + transportCenter.className = "iamccs-transport-center"; + const transportRight = document.createElement("div"); + transportRight.className = "iamccs-transport-right"; + addButton(transportCenter, "|<", () => { stopPlayback(false); setPlayhead(0, true); addEdit("Rewind to start."); }); + addButton(transportCenter, "<<", () => { setPlayhead(transport.playhead - secondsToFrames(1), true); addEdit("Back 1 second."); }); + addButton(transportCenter, transport.playing ? "Stop" : "Play", () => transport.playing ? stopPlayback(true) : playPlayback(), `${transport.playing ? "is-active " : ""}is-play`); + addButton(transportCenter, ">>", () => { setPlayhead(transport.playhead + secondsToFrames(1), true); addEdit("Forward 1 second."); }); + addButton(transportCenter, ">|", () => { setPlayhead(totalFrames(), true); addEdit("Go to end."); }); + addButton(transportCenter, "I", () => setLoopIn(), "is-marker"); + addButton(transportCenter, "O", () => setLoopOut(), "is-marker"); + addButton(transportCenter, "Loop", () => toggleLoop(), `${state.loopEnabled ? "is-active " : ""}is-loop`); + addButton(transportLeft, "Cut", () => splitSelectedAtPlayhead()); + addButton(transportLeft, "Trim In", () => trimStartToPlayhead()); + addButton(transportLeft, "Trim Out", () => trimEndToPlayhead()); + addButton(transportLeft, "Nudge -", () => nudgeSelected(-1)); + addButton(transportLeft, "Nudge +", () => nudgeSelected(1)); + const time = document.createElement("div"); + time.className = "iamccs-transport-time"; + time.textContent = `${fmtTime(transport.playhead)} / ${fmtTime(totalFrames())}`; + const monitor = document.createElement("div"); + monitor.className = "iamccs-helper-monitor"; + monitor.textContent = transport.helper || "Ready."; + transportRight.append(time, monitor); + transportBar.append(transportLeft, transportCenter, transportRight); + dynamic.appendChild(transportBar); + + const master = document.createElement("div"); + master.className = "iamccs-audio-board-master"; + master.onclick = () => { + state.selectedMixer = { type: "master", track: 0 }; + transport.helper = "Selected MASTER OUT device chain."; + writeState("select_master", false); + draw(); + }; + master.innerHTML = `
MASTER OUT
`; + const masterControls = document.createElement("div"); + masterControls.className = "iamccs-master-controls"; + masterControls.onclick = (event) => event.stopPropagation(); + const limiterToggle = document.createElement("button"); + limiterToggle.type = "button"; + limiterToggle.className = `iamccs-master-toggle${state.masterBus?.limiter ? " is-active" : ""}`; + limiterToggle.textContent = "LIMITER"; + limiterToggle.onclick = () => { + state.masterBus.limiter = !state.masterBus.limiter; + addEdit(`Master limiter ${state.masterBus.limiter ? "on" : "off"}.`); + writeState("master_limiter"); + draw(); + }; + const compToggle = document.createElement("button"); + compToggle.type = "button"; + compToggle.className = `iamccs-master-toggle${Number(state.masterBus?.compressor || 0) > 0 ? " is-active" : ""}`; + compToggle.textContent = "COMP"; + compToggle.onclick = () => { + state.masterBus.compressor = Number(state.masterBus?.compressor || 0) > 0 ? 0 : .45; + addEdit(`Master compressor ${Number(state.masterBus.compressor || 0) > 0 ? "on" : "off"}.`); + writeState("master_compressor"); + draw(); + }; + masterControls.append(limiterToggle, compToggle); + const masterFxSelect = document.createElement("select"); + masterFxSelect.className = "iamccs-master-fx-select"; + masterFxSelect.innerHTML = ``; + for (const [value, label] of EFFECT_CHOICES) { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + masterFxSelect.appendChild(option); + } + masterFxSelect.onchange = (event) => { + event.stopPropagation(); + if (!masterFxSelect.value) return; + addEffectToChain(masterFxSelect.value, { type: "master", track: 0 }); + masterFxSelect.value = ""; + }; + masterFxSelect.onpointerdown = (event) => event.stopPropagation(); + masterFxSelect.onclick = (event) => event.stopPropagation(); + masterControls.appendChild(masterFxSelect); + const masterFields = [ + ["Gain", "masterAudioGain", 0, 2, .05], + ["Ceil dB", "masterBus.ceilingDb", -12, 0, .5], + ["Comp", "masterBus.compressor", 0, 1, .05], + ["Width", "masterBus.width", 0, 2, .05], + ["Rev", "masterBus.reverbSend", 0, 1, .05], + ["Delay", "masterBus.delaySend", 0, 1, .05], + ]; + for (const [label, key, min, max, step] of masterFields) { + const wrap = document.createElement("label"); + wrap.style.cssText = "display:grid;gap:2px;color:#91a5ac;font-size:8px;font-weight:900;text-transform:uppercase;"; + wrap.textContent = label; + const input = document.createElement("input"); + input.type = "number"; + input.min = String(min); + input.max = String(max); + input.step = String(step); + input.style.cssText = "width:54px;height:22px;background:#10171a;color:#fff;border:1px solid rgba(255,255,255,.16);border-radius:4px;"; + input.value = key.startsWith("masterBus.") ? String(state.masterBus?.[key.split(".")[1]] ?? 0) : String(state[key] ?? 1); + input.onchange = () => { + if (key.startsWith("masterBus.")) state.masterBus[key.split(".")[1]] = Number(input.value || 0); + else state[key] = Number(input.value || 1); + addEdit(`Changed master ${label}.`); + writeState(`master_${label}`); + draw(); + }; + makeDraggableNumber(input, { step, min, max }); + wrap.appendChild(input); + masterControls.appendChild(wrap); + } + const norm = document.createElement("label"); + norm.style.cssText = "display:flex;align-items:center;gap:5px;color:#91a5ac;font-size:9px;font-weight:900;text-transform:uppercase;"; + const normInput = document.createElement("input"); + normInput.type = "checkbox"; + normInput.checked = Boolean(state.masterAudioNormalize); + normInput.onchange = () => { state.masterAudioNormalize = Boolean(normInput.checked); addEdit("Toggled master normalize."); writeState("master_normalize"); draw(); }; + norm.append(normInput, "Normalize"); + masterControls.appendChild(norm); + master.appendChild(masterControls); + const masterMonitor = document.createElement("span"); + masterMonitor.className = "iamccs-master-crt iamccs-master-readout"; + masterMonitor.textContent = "PK 000 RMS 000"; + master.appendChild(masterMonitor); + dynamic.appendChild(master); + + const timeline = document.createElement("div"); + timeline.className = "iamccs-audio-board-timeline"; + timeline.style.setProperty("--iamccs-track-height", `${view().trackHeight}px`); + timeline.style.setProperty("--iamccs-px-per-frame", `${pxPerFrame()}px`); + const ruler = document.createElement("div"); + ruler.className = "iamccs-audio-board-ruler"; + ruler.style.minWidth = `${contentWidth()}px`; + const activeLoopRange = loopRange(); + const addLoopRange = (parent, klass) => { + if (!activeLoopRange) return; + const region = document.createElement("div"); + region.className = klass; + region.style.left = `${frameToX(activeLoopRange.start)}px`; + region.style.width = `${Math.max(2, frameToX(activeLoopRange.end) - frameToX(activeLoopRange.start))}px`; + parent.appendChild(region); + }; + const scrubToEvent = (event, redraw = false) => { + const rect = ruler.getBoundingClientRect(); + const scaleX = Math.max(.001, ruler.offsetWidth / Math.max(1, rect.width)); + const contentX = Math.max(0, (event.clientX - rect.left) * scaleX); + const frame = xToFrame(contentX); + setPlayhead(frame, redraw); + transport.helper = `Scrub ${fmtTime(transport.playhead)} / ${fmtTime(totalFrames())}`; + }; + ruler.onpointerdown = (event) => { + event.preventDefault(); + scrubToEvent(event, false); + ruler.setPointerCapture?.(event.pointerId); + const move = (moveEvent) => { + scrubToEvent(moveEvent, false); + moveEvent.preventDefault(); + }; + const up = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + draw(); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up, { once: true }); + }; + addLoopRange(ruler, "iamccs-loop-range-ruler"); + for (let s = 0; s <= framesToSeconds(totalFrames()) + .01; s += 1) { + const x = frameToX(secondsToFrames(s)); + const tick = document.createElement("div"); + tick.style.cssText = `position:absolute;left:${x}px;top:0;bottom:0;width:1px;background:rgba(244,212,158,.25);`; + const rulerTickLabel = document.createElement("span"); + rulerTickLabel.textContent = `${s}s`; + rulerTickLabel.style.cssText = `position:absolute;left:${x + 4}px;top:5px;color:#9fb1b8;font-size:9px;font-weight:850;`; + ruler.append(tick, rulerTickLabel); + } + const addLoopMarker = (frame, label, out = false) => { + const marker = document.createElement("div"); + marker.className = `iamccs-loop-marker${out ? " out" : ""}`; + marker.style.left = `${frameToX(frame)}px`; + marker.textContent = label; + ruler.appendChild(marker); + }; + addLoopMarker(Math.max(0, Number(state.loopInFrame || 0)), "IN"); + if (Number(state.loopOutFrame || 0) > Number(state.loopInFrame || 0)) addLoopMarker(Number(state.loopOutFrame || 0), "OUT", true); + const rulerHead = document.createElement("div"); + rulerHead.style.cssText = `position:sticky;left:0;z-index:9;width:${LABEL_W}px;height:28px;background:#11181c;border-right:1px solid rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;color:#ffe2a8;font-size:9px;font-weight:900;`; + rulerHead.textContent = "TIME"; + ruler.appendChild(rulerHead); + timeline.appendChild(ruler); + const trackCount = Math.max(1, Number(state.audioTrackCount || 4)); + for (let track = 0; track < trackCount; track += 1) { + const lane = document.createElement("div"); + lane.className = "iamccs-audio-board-track"; + lane.style.minWidth = `${contentWidth()}px`; + addLoopRange(lane, "iamccs-loop-range"); + const trackSegs = segments().filter((seg) => Number(seg.track || 0) === track); + const trackState = trackSettings(track); + trackState.volume = Math.max(0, Math.min(2, Number(trackState.volume ?? 1))); + trackState.pan = Math.max(-1, Math.min(1, Number(trackState.pan || 0))); + trackState.color = normalizeTrackColor(trackState.color, track); + lane.style.setProperty("--iamccs-track-color", trackState.color); + const trackPeak = trackState.mute ? 0 : Math.min(1, trackSegs.reduce((sum, seg) => sum + peakFor(seg), 0) * trackState.volume); + const trackLabel = document.createElement("div"); + trackLabel.className = `iamccs-audio-board-track-label${selectedMixer().type === "track" && selectedMixer().track === track ? " is-selected" : ""}`; + trackLabel.style.borderLeft = `4px solid ${trackState.color}`; + trackLabel.onclick = () => { + state.selectedMixer = { type: "track", track }; + transport.helper = `Selected A${track + 1} device chain.`; + writeState("select_track", false); + draw(); + }; + const top = document.createElement("div"); + top.className = "iamccs-track-strip-top"; + top.innerHTML = `
A${track + 1}${trackSegs.length} clips
`; + const controls = document.createElement("div"); + controls.className = "iamccs-track-strip-controls"; + const addTrackToggle = (labelText, key) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `iamccs-track-mini${trackState[key] ? " is-active" : ""}`; + btn.textContent = labelText; + btn.onclick = (event) => { + event.stopPropagation(); + state.selectedMixer = { type: "track", track }; + trackState[key] = !trackState[key]; + addEdit(`A${track + 1} ${labelText} ${trackState[key] ? "on" : "off"}.`); + writeState(`track_${key}`); + draw(); + }; + controls.appendChild(btn); + }; + addTrackToggle("M", "mute"); + addTrackToggle("S", "solo"); + addTrackToggle("N", "normalize"); + addTrackToggle("RV", "reverb"); + addTrackToggle("L", "lock"); + const colorButton = document.createElement("button"); + colorButton.type = "button"; + colorButton.className = "iamccs-track-color-chip"; + colorButton.textContent = "C"; + colorButton.title = `A${track + 1} lane color`; + colorButton.style.setProperty("--iamccs-track-color", trackState.color); + colorButton.onclick = (event) => { + event.stopPropagation(); + state.selectedMixer = { type: "track", track }; + trackState.color = nextTrackColor(trackState.color, track); + addEdit(`A${track + 1} color changed.`); + writeState("track_color"); + draw(); + }; + controls.appendChild(colorButton); + const knobRow = document.createElement("div"); + knobRow.className = "iamccs-track-knob-row"; + const makeTrackKnob = (labelText, key, min, max, step, fallback, formatter) => { + const wrap = document.createElement("div"); + wrap.className = "iamccs-track-knob-wrap"; + const knob = document.createElement("button"); + knob.type = "button"; + knob.className = "iamccs-track-knob"; + const readout = document.createElement("span"); + readout.className = "iamccs-track-knob-readout"; + const sync = () => { + const value = Math.max(min, Math.min(max, Number(trackState[key] ?? fallback))); + trackState[key] = value; + const t = (value - min) / Math.max(.0001, max - min); + knob.style.setProperty("--iamccs-knob-angle", `${-135 + t * 270}deg`); + knob.style.setProperty("--iamccs-knob-fill", `${Math.round(t * 78)}%`); + readout.textContent = formatter(value); + knob.title = `A${track + 1} ${labelText}: ${formatter(value)}`; + }; + sync(); + knob.onpointerdown = (event) => { + event.preventDefault(); + event.stopPropagation(); + state.selectedMixer = { type: "track", track }; + const startX = event.clientX; + const startY = event.clientY; + const startValue = Number(trackState[key] ?? fallback); + try { knob.setPointerCapture?.(event.pointerId); } catch {} + const move = (moveEvent) => { + const delta = ((moveEvent.clientX - startX) + (startY - moveEvent.clientY)) * Number(step || .01); + trackState[key] = Math.max(min, Math.min(max, startValue + delta)); + sync(); + transport.helper = `A${track + 1} ${labelText}: ${formatter(trackState[key])}`; + scheduleSilentStateWrite(`track_${key}_live`); + scheduleShotboardSync(`track_${key}_live`); + moveEvent.preventDefault(); + }; + const up = (upEvent) => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + try { knob.releasePointerCapture?.(upEvent.pointerId); } catch {} + addEdit(`A${track + 1} ${labelText} ${formatter(trackState[key])}.`); + writeState(`track_${key}`, false); + draw(); + }; + window.addEventListener("pointermove", move, { passive: false }); + window.addEventListener("pointerup", up, { passive: false, once: true }); + }; + const labelEl = document.createElement("span"); + labelEl.className = "iamccs-track-knob-label"; + labelEl.textContent = labelText; + wrap.append(labelEl, knob, readout); + return wrap; + }; + knobRow.append( + makeTrackKnob("Volume", "volume", 0, 2, .006, 1, (v) => `${Math.round(v * 100)}%`), + makeTrackKnob("Pan", "pan", -1, 1, .006, 0, (v) => Math.abs(v) < .02 ? "C" : `${v < 0 ? "L" : "R"}${Math.round(Math.abs(v) * 100)}`) + ); + const bottom = document.createElement("div"); + bottom.className = "iamccs-track-strip-bottom"; + const fxSelect = document.createElement("select"); + fxSelect.className = "iamccs-track-fx-select"; + fxSelect.innerHTML = ``; + for (const [value, labelText] of EFFECT_CHOICES) { + const option = document.createElement("option"); + option.value = value; + option.textContent = labelText; + fxSelect.appendChild(option); + } + fxSelect.onchange = (event) => { + event.stopPropagation(); + if (!fxSelect.value) return; + state.selectedMixer = { type: "track", track }; + addEffectToChain(fxSelect.value, { type: "track", track }); + fxSelect.value = ""; + }; + fxSelect.onpointerdown = (event) => event.stopPropagation(); + fxSelect.onclick = (event) => event.stopPropagation(); + bottom.appendChild(fxSelect); + trackLabel.append(top, controls, knobRow, bottom); + lane.appendChild(trackLabel); + for (const seg of trackSegs) { + const clip = document.createElement("div"); + clip.className = `iamccs-audio-clip${seg.id === selectedId ? " is-selected" : ""}${seg.mute ? " is-muted" : ""}`; + clip.style.borderColor = trackState.color; + clip.title = "Drag to move. Drag edges to trim. Double-click to move playhead."; + const leftHandle = document.createElement("div"); + leftHandle.className = "iamccs-clip-handle left"; + const rightHandle = document.createElement("div"); + rightHandle.className = "iamccs-clip-handle right"; + const name = document.createElement("div"); + name.className = "iamccs-audio-clip-name"; + name.textContent = String(seg.name || seg.fileName || "audio"); + const timeLabel = document.createElement("div"); + timeLabel.className = "iamccs-audio-clip-time"; + timeLabel.textContent = `${fmtTime(seg.start)} - ${fmtTime(Number(seg.start || 0) + Number(seg.length || 1))}`; + const trimLabel = document.createElement("div"); + trimLabel.className = "iamccs-audio-clip-trim"; + trimLabel.textContent = `src +${fmtTime(seg.trimStart || 0)}`; + const sourceMarker = document.createElement("div"); + sourceMarker.className = "iamccs-clip-source-marker"; + clip.append(name, timeLabel, trimLabel, sourceMarker); + updateClipStyle(clip, seg); + clip.append(leftHandle, rightHandle); + attachClipPointer(clip, seg); + lane.appendChild(clip); + } + const playhead = document.createElement("div"); + playhead.className = "iamccs-playhead"; + playhead.style.left = `${frameToX(transport.playhead)}px`; + lane.appendChild(playhead); + timeline.appendChild(lane); + } + dynamic.appendChild(timeline); + + const selected = selectedClip(); + if (selected && !selectedId) selectedId = selected.id; + const clipActions = document.createElement("div"); + clipActions.className = "iamccs-clip-action-bar"; + clipActions.innerHTML = `CLIP EDIT`; + addButton(clipActions, "Clip Values", () => { + state.showClipValues = !state.showClipValues; + addEdit(`Clip values ${state.showClipValues ? "shown" : "hidden"}.`); + writeState("clip_values", false); + draw(); + }, state.showClipValues ? "is-active" : "is-values"); + addButton(clipActions, "Track Up", () => selected && setSelectedExternal("track", Math.max(0, Number(selected.track || 0) - 1))); + addButton(clipActions, "Track Down", () => selected && setSelectedExternal("track", Math.min(Math.max(1, Number(state.audioTrackCount || 4)) - 1, Number(selected.track || 0) + 1))); + addButton(clipActions, "Trim In", () => trimStartToPlayhead()); + addButton(clipActions, "Trim Out", () => trimEndToPlayhead()); + addButton(clipActions, "Split", () => splitSelectedAtPlayhead()); + addButton(clipActions, "Delete Clip", () => { + if (!selected) return; + state.audioSegments = segments().filter((seg) => seg.id !== selected.id); + selectedId = ""; + addEdit("Deleted clip."); + writeState("delete"); + draw(); + }, "danger"); + dynamic.appendChild(clipActions); + const editor = document.createElement("div"); + editor.className = "iamccs-audio-board-editor"; + if (!state.showClipValues) editor.style.display = "none"; + const setSelected = (key, value) => { + if (!selected) return; + selected[key] = value; + addEdit(`Edited ${String(selected.name || selected.fileName || "clip")}: ${key}`); + writeState(`edit_${key}`); + draw(); + }; + const setSelectedExternal = (key, value) => { + if (!selected) return; + selected[key] = value; + addEdit(`Edited ${String(selected.name || selected.fileName || "clip")}: ${key}`); + writeState(`edit_${key}`); + draw(); + }; + const numberControl = (label, key, value, step = "1", transform = (v) => Number(v || 0), min = null, max = null) => { + const wrap = document.createElement("label"); + wrap.textContent = label; + const input = document.createElement("input"); + input.type = "number"; + input.step = step; + if (min != null) input.min = String(min); + if (max != null) input.max = String(max); + input.value = String(value); + input.onchange = () => setSelected(key, transform(input.value)); + makeDraggableNumber(input, { step, min, max }); + wrap.appendChild(input); + return wrap; + }; + if (selected) { + editor.append( + numberControl("Start", "start", framesToSeconds(selected.start).toFixed(2), "0.01", secondsToFrames), + numberControl("End", "length", framesToSeconds(Number(selected.start || 0) + Number(selected.length || 1)).toFixed(2), "0.01", (v) => Math.max(1, secondsToFrames(v) - Number(selected.start || 0))), + numberControl("Len", "length", framesToSeconds(selected.length).toFixed(2), "0.01", (v) => Math.max(1, secondsToFrames(v))), + numberControl("Trim In", "trimStart", framesToSeconds(selected.trimStart || 0).toFixed(2), "0.01", secondsToFrames), + numberControl("Gain", "gain", Number(selected.gain ?? 1).toFixed(2), "0.05", (v) => Math.max(0, Math.min(4, Number(v || 0))), 0, 4), + numberControl("Pan", "pan", Number(selected.pan ?? 0).toFixed(2), "0.05", (v) => Math.max(-1, Math.min(1, Number(v || 0))), -1, 1), + numberControl("Fade In", "fadeInFrames", Number(selected.fadeInFrames || 0), "1"), + numberControl("Fade Out", "fadeOutFrames", Number(selected.fadeOutFrames || 0), "1"), + numberControl("Pitch", "pitchSemitones", Number(selected.pitchSemitones || 0), "0.5", Number, -24, 24), + numberControl("Stretch", "timeStretch", Number(selected.timeStretch || 1), "0.05", (v) => Math.max(.25, Math.min(4, Number(v || 1))), .25, 4), + numberControl("HPF", "hpfHz", Number(selected.hpfHz || 0), "10", Number, 0, 20000), + numberControl("LPF", "lpfHz", Number(selected.lpfHz || 22000), "10", Number, 20, 22000), + numberControl("EQ Low", "eqLowDb", Number(selected.eqLowDb || 0), "0.5", Number, -24, 24), + numberControl("EQ Mid", "eqMidDb", Number(selected.eqMidDb || 0), "0.5", Number, -24, 24), + numberControl("EQ High", "eqHighDb", Number(selected.eqHighDb || 0), "0.5", Number, -24, 24), + numberControl("Comp", "compressor", Number(selected.compressor || 0), "0.05", Number, 0, 1), + numberControl("Gate dB", "noiseGateDb", Number(selected.noiseGateDb ?? -60), "1", Number, -80, 0), + numberControl("Duck", "ducking", Number(selected.ducking || 0), "0.05", Number, 0, 1), + numberControl("Rev", "reverbSend", Number(selected.reverbSend || 0), "0.05", Number, 0, 1), + numberControl("Delay", "delaySend", Number(selected.delaySend || 0), "0.05", Number, 0, 1), + numberControl("Width", "stereoWidth", Number(selected.stereoWidth || 1), "0.05", Number, 0, 2), + numberControl("Transient", "transient", Number(selected.transient || 0), "0.05", Number, -1, 1), + numberControl("Denoise", "denoise", Number(selected.denoise || 0), "0.05", Number, 0, 1), + ); + const purpose = document.createElement("label"); + purpose.textContent = "Purpose"; + const purposeSelect = document.createElement("select"); + ["dialogue", "music", "ambience", "foley", "whoosh", "guide_audio", "dialogue_or_music"].forEach((item) => { + const option = document.createElement("option"); + option.value = item; + option.textContent = item; + option.selected = String(selected.purpose || "") === item; + purposeSelect.appendChild(option); + }); + purposeSelect.onchange = () => setSelected("purpose", purposeSelect.value); + purpose.appendChild(purposeSelect); + editor.appendChild(purpose); + const link = document.createElement("label"); + link.textContent = "Panel"; + const linkSelect = document.createElement("select"); + const empty = document.createElement("option"); + empty.value = ""; + empty.textContent = "timeline"; + linkSelect.appendChild(empty); + for (const item of visualOptions()) { + const option = document.createElement("option"); + option.value = item.id; + option.textContent = item.label; + option.selected = String(selected.linkedVisualId || "") === item.id; + linkSelect.appendChild(option); + } + linkSelect.onchange = () => { + const picked = visualOptions().find((item) => item.id === linkSelect.value); + selected.linkedVisualId = linkSelect.value; + if (picked) selected.start = Math.max(0, Math.round(Number(picked.start || 0))); + addEdit(picked ? `Linked clip to panel ${picked.label} and aligned start.` : "Unlinked clip from panel."); + writeState("link_panel"); + draw(); + }; + link.appendChild(linkSelect); + editor.appendChild(link); + [["Reverse", "reverse"]].forEach(([labelText, key]) => { + const wrap = document.createElement("label"); + wrap.textContent = labelText; + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = Boolean(selected[key]); + input.onchange = () => setSelected(key, Boolean(input.checked)); + wrap.appendChild(input); + editor.appendChild(wrap); + }); + } else { + const empty = document.createElement("div"); + empty.style.gridColumn = "1 / -1"; + empty.textContent = "Import audio to create editable DAW-style Shotboard audio lanes. Default timeline is 26 seconds."; + editor.appendChild(empty); + } + dynamic.appendChild(editor); + + const staticPeak = Math.min(1, segments().filter((seg) => !seg.mute).reduce((sum, seg) => sum + peakFor(seg), 0) * Math.max(0, Math.min(2, Number(state.masterAudioGain ?? 1) || 1))); + const lower = document.createElement("div"); + lower.className = "iamccs-audio-board-lower"; + if (!state.showEventMonitor) lower.classList.add("no-monitor"); + if (state.showEventMonitor) { + const consoleBox = document.createElement("div"); + consoleBox.className = "iamccs-event-console"; + const edits = (state.status?.edits || []).slice(0, 8); + consoleBox.innerHTML = ` +
+ EVENT MONITOR + ${fmtTime(transport.playhead)} / ${fmtTime(totalFrames())} +
+
V3 ${linkedShotboardNodes().length} | CUSTOM AUDIO ${segments().some(hasMedia) ? "YES" : "NO"} | PEAK ${Math.round(staticPeak * 100)}% +HELPER ${transport.helper || "Ready."} +SHORTCUTS Space play/stop | C cut | M/S/N/L | [ ] trim | arrows nudge | +/- zoom + +${edits.length ? edits.join("\n") : "No edits yet."}
`; + lower.appendChild(consoleBox); + } + appendInlineEfxPreview(lower, selected, staticPeak); + dynamic.appendChild(lower); + root.appendChild(dynamic); + collectTransportDom(); + }; + let bootError = null; + try { + writeState("init", false); + draw(); + } catch (err) { + bootError = err; + console.error("[IAMCCS AudioBoardArranger] draw failed before DOM mount", { + nodeId: node?.id, + error: err?.message || String(err), + stack: err?.stack || "", + widgets: (node?.widgets || []).map((w) => ({ name: w?.name, type: w?.type, hidden: w?.hidden, valueLength: String(w?.value || "").length })), + }); + root.innerHTML = ""; + const errorPanel = document.createElement("div"); + errorPanel.className = "iamccs-audio-board-dynamic"; + errorPanel.style.cssText = [ + "min-height:420px", + "padding:14px", + "border:1px solid rgba(255,120,120,.45)", + "border-radius:8px", + "background:linear-gradient(180deg,#1a1112,#0b0f11)", + "color:#f3d7d7", + "font:12px/1.35 ui-monospace, SFMono-Regular, Consolas, monospace", + "white-space:pre-wrap", + "overflow:auto", + ].join(";"); + const title = document.createElement("div"); + title.style.cssText = "font:800 14px/1.2 Inter,system-ui,sans-serif;color:#ffd9a1;margin-bottom:10px"; + title.textContent = "IAMCCS AudioBoardArranger render failed"; + const body = document.createElement("div"); + body.textContent = `${err?.message || String(err)}\n\n${err?.stack || ""}`; + errorPanel.append(title, body); + root.appendChild(errorPanel); + } + if (typeof node.addDOMWidget !== "function") { + console.error("[IAMCCS AudioBoardArranger] addDOMWidget is not available", { + nodeId: node?.id, + type: node?.type, + comfyClass: node?.comfyClass, + widgets: (node?.widgets || []).map((w) => w?.name), + }); + return; + } + const domWidget = node.addDOMWidget("AudioBoard Arranger", "iamccs_audio_board_arranger", root, { serialize: false }); + hideWidget(dataWidget); + const applyFixedAudioBoardSize = () => { + const fixed = [AUDIO_BOARD_FIXED_WIDTH, AUDIO_BOARD_FIXED_HEIGHT]; + node._iamccsAudioBoardFixedSize = fixed; + node.resizable = false; + node.resizeable = false; + node.flags = { ...(node.flags || {}), resizable: false }; + node.min_size = fixed.slice(); + const current = Array.isArray(node.size) ? node.size : [0, 0]; + if (Number(current[0] || 0) !== fixed[0] || Number(current[1] || 0) !== fixed[1]) { + if (typeof node.setSize === "function") node.setSize(fixed.slice()); + else node.size = fixed.slice(); + } + }; + if (!node._iamccsAudioBoardResizeLocked) { + const previousOnResize = node.onResize; + node.onResize = function (size) { + const fixed = this._iamccsAudioBoardFixedSize || [AUDIO_BOARD_FIXED_WIDTH, AUDIO_BOARD_FIXED_HEIGHT]; + if (Array.isArray(size)) { + size[0] = fixed[0]; + size[1] = fixed[1]; + } + const result = typeof previousOnResize === "function" ? previousOnResize.apply(this, arguments) : undefined; + this.resizable = false; + this.resizeable = false; + this.flags = { ...(this.flags || {}), resizable: false }; + this.min_size = fixed.slice(); + if (Number(this.size?.[0] || 0) !== fixed[0] || Number(this.size?.[1] || 0) !== fixed[1]) { + if (typeof this.setSize === "function") this.setSize(fixed.slice()); + else this.size = fixed.slice(); + } + return result; + }; + node._iamccsAudioBoardResizeLocked = true; + } + if (domWidget) { + domWidget.computeSize = () => root._iamccsAudioFullscreenState + ? [AUDIO_BOARD_FIXED_WIDTH, 24] + : [AUDIO_BOARD_FIXED_WIDTH, AUDIO_BOARD_FIXED_HEIGHT - 70]; + } + applyFixedAudioBoardSize(); + node._iamccsAudioBoardReady = true; + console.info("[IAMCCS AudioBoardArranger] render complete", { + nodeId: node?.id, + bootError: bootError?.message || "", + domWidget: Boolean(domWidget), + widgetCount: node?.widgets?.length, + rootChildren: root.childElementCount, + }); +} + +function iamccsAudioDialogueType(node) { + const raw = [ + node?.comfyClass, + node?.type, + node?.title, + node?.constructor?.comfyClass, + node?.constructor?.type, + node?.constructor?.nodeData?.name, + ].map((item) => String(item || "")).filter(Boolean); + const widgets = (node?.widgets || []).map((w) => String(w?.name || "")); + if (raw.includes("IAMCCS_AudioBoardArranger") || raw.includes("IAMCCS AudioBoard Arranger") || widgets.includes("arranger_data")) return "IAMCCS_AudioBoardArranger"; + if (raw.includes("IAMCCS_AudioBoardMixer") || raw.includes("IAMCCS AudioBoard Mixer") || widgets.includes("mixer_data")) return "IAMCCS_AudioBoardMixer"; + if (raw.includes("IAMCCS_ControlAudEfx") || raw.includes("IAMCCS ControlAudEfx") || widgets.includes("control_data")) return "IAMCCS_ControlAudEfx"; + if (raw.includes("IAMCCS_CineEmotionButtons") || widgets.includes("selected_emotions")) return "IAMCCS_CineEmotionButtons"; + if (raw.includes("IAMCCS_BoardMaker_DialogueFoley") || widgets.includes("board_json")) return "IAMCCS_BoardMaker_DialogueFoley"; + if (raw.includes("IAMCCS_CineSpeech1PromptCompiler") || widgets.includes("speech1_prompt")) return "IAMCCS_CineSpeech1PromptCompiler"; + return ""; +} + +function renderIamccsAudioDialogueNode(node, source = "unknown") { + const type = iamccsAudioDialogueType(node); + if (!type) return; + if (![ + "IAMCCS_CineEmotionButtons", + "IAMCCS_BoardMaker_DialogueFoley", + "IAMCCS_AudioBoardArranger", + "IAMCCS_ControlAudEfx", + "IAMCCS_AudioBoardMixer", + "IAMCCS_CineSpeech1PromptCompiler", + ].includes(type)) return; + console.info("[IAMCCS AudioDialogueUI] render hook", { + source, + type, + id: node?.id, + rawType: node?.type, + comfyClass: node?.comfyClass, + title: node?.title, + ready: node?._iamccsAudioBoardReady, + widgets: (node?.widgets || []).map((w) => w?.name), + }); + try { + if (type === "IAMCCS_CineEmotionButtons") renderEmotionPanel(node); + if (type === "IAMCCS_BoardMaker_DialogueFoley") renderDialogueFoleyBoardMaker(node); + if (type === "IAMCCS_CineSpeech1PromptCompiler") renderSpeech1Compiler(node); + if (type === "IAMCCS_AudioBoardArranger") renderAudioBoardArranger(node); + if (type === "IAMCCS_ControlAudEfx") renderControlAudEfx(node); + if (type === "IAMCCS_AudioBoardMixer") renderAudioBoardMixer(node); + } catch (err) { + if (type === "IAMCCS_AudioBoardArranger") node._iamccsAudioBoardReady = false; + console.error("[IAMCCS AudioDialogueUI] render hook failed", { + source, + type, + id: node?.id, + error: err?.message || String(err), + stack: err?.stack || "", + widgets: (node?.widgets || []).map((w) => ({ name: w?.name, type: w?.type, hidden: w?.hidden, valueLength: String(w?.value || "").length })), + }); + } +} + +function ensureControlAudEfxStyles() { + if (document.getElementById("iamccs-control-aud-efx-style")) return; + const style = document.createElement("style"); + style.id = "iamccs-control-aud-efx-style"; + style.textContent = ` + .iamccs-audefx { + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-height: 460px; + padding: 10px; + overflow: hidden; + color: #dce7ea; + background: linear-gradient(180deg, #14191c, #0d1114); + border: 1px solid rgba(150,174,184,.22); + border-radius: 8px; + font: 11px/1.25 Inter, ui-sans-serif, system-ui, sans-serif; + } + .iamccs-audefx * { box-sizing: border-box; letter-spacing: 0; } + .iamccs-audefx-head, + .iamccs-audefx-tools, + .iamccs-audefx-strip { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; + } + .iamccs-audefx-head { justify-content: space-between; margin-bottom: 8px; } + .iamccs-audefx-title { color: #fff; font-weight: 950; font-size: 13px; } + .iamccs-audefx-sub { color: #8fa5ac; font-weight: 750; font-size: 10px; } + .iamccs-audefx button { + min-height: 25px; + padding: 0 8px; + color: #ecffff; + background: linear-gradient(180deg, #284a4e, #1a3034); + border: 1px solid rgba(143,208,204,.35); + border-radius: 5px; + font-size: 10px; + font-weight: 850; + cursor: pointer; + } + .iamccs-audefx button.is-active { + color: #101315; + background: linear-gradient(180deg, #f2d79a, #c79e59); + border-color: #ffe6ae; + } + .iamccs-audefx-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; + width: 100%; + max-width: 100%; + overflow: hidden; + } + .iamccs-audefx-panel { + min-width: 0; + padding: 7px; + background: rgba(0,0,0,.22); + border: 1px solid rgba(255,255,255,.10); + border-radius: 7px; + } + .iamccs-audefx-label { + margin-bottom: 5px; + color: #ffe2a8; + font-size: 9px; + font-weight: 950; + text-transform: uppercase; + } + .iamccs-audefx-canvas { + width: 100%; + max-width: 100%; + height: 108px; + display: block; + background: #080c0f; + border: 1px solid rgba(255,255,255,.08); + border-radius: 5px; + } + .iamccs-audefx-strip { + margin: 8px 0; + padding: 7px; + background: rgba(0,0,0,.18); + border: 1px solid rgba(255,255,255,.10); + border-radius: 7px; + } + .iamccs-audefx-meter { + position: relative; + width: 190px; + height: 12px; + overflow: hidden; + background: #070b0d; + border: 1px solid rgba(255,255,255,.16); + border-radius: 999px; + } + .iamccs-audefx-meter i { + display: block; + width: 0%; + height: 100%; + background: linear-gradient(90deg, #55c7b9 0%, #cfe37c 58%, #f0b857 78%, #dc5c42 100%); + transition: width .045s linear; + } + .iamccs-audefx-readout { + color: #a9bec4; + font: 900 10px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + } + .iamccs-audefx-list { + max-height: 86px; + overflow: auto; + white-space: pre-wrap; + color: #9fb1b8; + font: 10px/1.35 ui-monospace, SFMono-Regular, Consolas, monospace; + } + `; +} + +function renderControlAudEfx(node) { + if (node._iamccsControlAudEfxReady) return; + node._iamccsControlAudEfxReady = true; + ensureControlAudEfxStyles(); + const dataWidget = findWidget(node, "control_data"); + hideWidget(dataWidget); + const root = document.createElement("div"); + root.className = "iamccs-audefx"; + root.tabIndex = 0; + let audioContext = null; + let analyser = null; + let source = null; + let raf = 0; + let playing = false; + + const parseControl = () => { + try { return JSON.parse(String(dataWidget?.value || "{}")); } catch { return {}; } + }; + const writeControl = (patch = {}) => { + const data = { schema: "iamccs.control_aud_efx", schema_version: 1, ...parseControl(), ...patch }; + if (dataWidget) { + dataWidget.value = JSON.stringify(data, null, 2); + dataWidget.callback?.(dataWidget.value); + } + node.setDirtyCanvas?.(true, true); + app.graph?.setDirtyCanvas?.(true, true); + }; + const upstreamArranger = () => { + for (const input of node.inputs || []) { + const link = input.link != null ? app.graph?.links?.[input.link] : null; + const origin = link ? app.graph?.getNodeById?.(link.origin_id) : null; + const type = String(origin?.comfyClass || origin?.type || ""); + if (origin && type === "IAMCCS_AudioBoardArranger") return origin; + if (origin) { + for (const other of app.graph?._nodes || []) { + const otherType = String(other?.comfyClass || other?.type || ""); + if (otherType !== "IAMCCS_AudioBoardArranger") continue; + const linkedToOrigin = (other.outputs || []).some((output) => (output.links || []).some((linkId) => app.graph?.links?.[linkId]?.target_id === origin.id)); + if (linkedToOrigin) return other; + } + } + } + return null; + }; + const arrangerState = () => { + const fallbackFromTimeline = () => { + for (const input of node.inputs || []) { + const link = input.link != null ? app.graph?.links?.[input.link] : null; + const origin = link ? app.graph?.getNodeById?.(link.origin_id) : null; + const timelineWidget = origin ? findWidget(origin, "timeline_data") : null; + try { + const timeline = JSON.parse(String(timelineWidget?.value || "{}")); + if (Array.isArray(timeline.audioSegments)) { + return { + audioSegments: timeline.audioSegments, + audioTrackCount: timeline.audioTrackCount || 4, + masterAudioGain: timeline.masterAudioGain ?? 1, + masterAudioNormalize: Boolean(timeline.masterAudioNormalize), + masterBus: timeline.masterBus || {}, + frame_rate: timeline.frame_rate || 24, + selectedClipId: timeline.selectedClipId || "", + }; + } + } catch {} + } + return {}; + }; + const arranger = upstreamArranger(); + const widget = arranger ? findWidget(arranger, "arranger_data") : null; + try { + const data = JSON.parse(String(widget?.value || "{}")); + if (data && typeof data === "object" && Array.isArray(data.audioSegments)) return data; + return fallbackFromTimeline(); + } catch { + return fallbackFromTimeline(); + } + }; + const clips = (state) => Array.isArray(state.audioSegments) ? state.audioSegments : []; + const selectedClip = (state) => { + const data = parseControl(); + const selectedId = String(data.selectedClipId || state.selectedClipId || ""); + return clips(state).find((clip) => clip.id === selectedId) || clips(state)[0] || null; + }; + const audioViewUrl = (seg) => { + const file = String(seg?.audioFile || "").trim(); + if (!file) return ""; + const parts = file.split(/[\\/]+/).filter(Boolean); + const filename = parts.pop() || file; + const subfolder = parts.join("/"); + return `/view?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(seg.audioUploadType || "input")}&subfolder=${encodeURIComponent(subfolder)}`; + }; + const drawGrid = (ctx, w, h) => { + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = "#080c0f"; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = "rgba(255,255,255,.07)"; + ctx.lineWidth = 1; + for (let x = 0; x <= w; x += w / 8) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + for (let y = 0; y <= h; y += h / 4) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + }; + const normalizedPeak = (raw) => { + if (raw && typeof raw === "object") return { min: Number(raw.min || 0), max: Number(raw.max || 0), rms: Number(raw.rms || 0) }; + const p = Math.abs(Number(raw || 0)); + return { min: -p, max: p, rms: p * .65 }; + }; + const drawStaticWave = (canvas, clip) => { + const ctx = canvas.getContext("2d"); + const dpr = window.devicePixelRatio || 1; + const w = Math.max(1, Math.round(canvas.clientWidth * dpr)); + const h = Math.max(1, Math.round(canvas.clientHeight * dpr)); + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + drawGrid(ctx, w, h); + const peaks = Array.isArray(clip?.waveformPeaks) ? clip.waveformPeaks : []; + if (!peaks.length) return; + ctx.beginPath(); + const top = []; + const bottom = []; + for (let x = 0; x < w; x += 1) { + const raw = normalizedPeak(peaks[Math.min(peaks.length - 1, Math.floor((x / Math.max(1, w - 1)) * peaks.length))]); + top.push([x, h * .5 - raw.max * h * .46]); + bottom.unshift([x, h * .5 - raw.min * h * .46]); + } + ctx.fillStyle = "rgba(91,190,181,.50)"; + ctx.strokeStyle = "rgba(244,212,158,.95)"; + ctx.lineWidth = Math.max(1, dpr); + ctx.beginPath(); + top.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); + bottom.forEach(([x, y]) => ctx.lineTo(x, y)); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.strokeStyle = "rgba(255,255,255,.22)"; + ctx.beginPath(); + ctx.moveTo(0, h * .5); + ctx.lineTo(w, h * .5); + ctx.stroke(); + }; + const drawEq = (canvas, clip, masterBus = {}) => { + const ctx = canvas.getContext("2d"); + const dpr = window.devicePixelRatio || 1; + const w = Math.max(1, Math.round(canvas.clientWidth * dpr)); + const h = Math.max(1, Math.round(canvas.clientHeight * dpr)); + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + drawGrid(ctx, w, h); + const low = Number(clip?.eqLowDb || 0); + const mid = Number(clip?.eqMidDb || 0); + const high = Number(clip?.eqHighDb || 0); + const hpf = Number(clip?.hpfHz || 0); + const lpf = Number(clip?.lpfHz || 22000); + const gainAt = (freq) => { + const log = (Math.log10(freq) - Math.log10(20)) / (Math.log10(22000) - Math.log10(20)); + let db = low * Math.max(0, 1 - Math.abs(log - .22) / .32); + db += mid * Math.max(0, 1 - Math.abs(log - .55) / .22); + db += high * Math.max(0, 1 - Math.abs(log - .82) / .30); + if (hpf > 20 && freq < hpf) db -= Math.min(36, 24 * (1 - freq / hpf)); + if (lpf < 21950 && freq > lpf) db -= Math.min(36, 24 * (freq / lpf - 1)); + db -= Number(masterBus?.compressor || 0) * 2; + return Math.max(-24, Math.min(24, db)); + }; + ctx.strokeStyle = "#f4d49e"; + ctx.lineWidth = Math.max(2, 2 * dpr); + ctx.beginPath(); + for (let x = 0; x < w; x += 1) { + const t = x / Math.max(1, w - 1); + const freq = 20 * Math.pow(22000 / 20, t); + const y = h * .5 - (gainAt(freq) / 24) * h * .42; + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.fillStyle = "#8fa5ac"; + ctx.font = `${10 * dpr}px ui-monospace`; + ["20", "100", "1k", "10k"].forEach((label, i) => ctx.fillText(label, 6 + i * (w / 4), h - 7 * dpr)); + }; + const ensureAudioContext = async () => { + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + if (!AudioContextClass) throw new Error("WebAudio unavailable"); + if (!audioContext) audioContext = new AudioContextClass(); + if (audioContext.state === "suspended") await audioContext.resume(); + return audioContext; + }; + const stop = () => { + try { source?.stop(); } catch {} + source = null; + playing = false; + if (raf) cancelAnimationFrame(raf); + raf = 0; + draw(); + }; + const play = async () => { + const state = arrangerState(); + const clip = selectedClip(state); + const url = audioViewUrl(clip); + if (!clip || !url) return; + stop(); + const ctx = await ensureAudioContext(); + const resp = await fetch(url); + const decoded = await ctx.decodeAudioData((await resp.arrayBuffer()).slice(0)); + const src = ctx.createBufferSource(); + src.buffer = decoded; + src.playbackRate.value = Math.max(.25, Math.min(4, Number(clip.timeStretch || 1))) * Math.pow(2, Number(clip.pitchSemitones || 0) / 12); + const gain = ctx.createGain(); + gain.gain.value = Math.max(0, Math.min(4, Number(clip.gain || 1))); + let last = gain; + const addFilter = (type, freq, db = 0, q = .707) => { + const f = ctx.createBiquadFilter(); + f.type = type; + f.frequency.value = Math.max(10, Math.min(22000, Number(freq || 0))); + f.Q.value = q; + f.gain.value = Math.max(-36, Math.min(36, Number(db || 0))); + last.connect(f); + last = f; + }; + if (Number(clip.hpfHz || 0) > 10) addFilter("highpass", clip.hpfHz); + if (Number(clip.lpfHz || 22000) < 21950) addFilter("lowpass", clip.lpfHz); + if (Number(clip.eqLowDb || 0)) addFilter("lowshelf", 140, clip.eqLowDb); + if (Number(clip.eqMidDb || 0)) addFilter("peaking", 1200, clip.eqMidDb, 1.1); + if (Number(clip.eqHighDb || 0)) addFilter("highshelf", 6200, clip.eqHighDb); + if (ctx.createStereoPanner) { + const pan = ctx.createStereoPanner(); + pan.pan.value = Math.max(-1, Math.min(1, Number(clip.pan || 0))); + last.connect(pan); + last = pan; + } + analyser = ctx.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = .72; + last.connect(analyser); + analyser.connect(ctx.destination); + src.connect(gain); + const offset = Math.max(0, Number(clip.trimStart || 0) / Math.max(1, Number(state.frame_rate || 24))); + src.start(0, offset, Math.max(.05, Math.min(decoded.duration - offset, Number(clip.length || 1) / Math.max(1, Number(state.frame_rate || 24))))); + source = src; + playing = true; + src.onended = () => stop(); + drawRealtime(); + }; + const drawRealtime = () => { + const spectrum = root.querySelector(".iamccs-audefx-spectrum"); + const scope = root.querySelector(".iamccs-audefx-scope"); + const meter = root.querySelector(".iamccs-audefx-meter i"); + const readout = root.querySelector(".iamccs-audefx-readout"); + if (!analyser || !playing) return; + const freq = new Uint8Array(analyser.frequencyBinCount); + const wave = new Uint8Array(analyser.fftSize); + analyser.getByteFrequencyData(freq); + analyser.getByteTimeDomainData(wave); + const drawBars = (canvas) => { + if (!canvas) return; + const ctx = canvas.getContext("2d"); + const dpr = window.devicePixelRatio || 1; + const w = Math.max(1, Math.round(canvas.clientWidth * dpr)); + const h = Math.max(1, Math.round(canvas.clientHeight * dpr)); + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + drawGrid(ctx, w, h); + const bars = 96; + for (let i = 0; i < bars; i += 1) { + const v = freq[Math.floor((i / bars) * freq.length)] / 255; + ctx.fillStyle = v > .85 ? "#dc5c42" : v > .58 ? "#f0b857" : "#55c7b9"; + ctx.fillRect((i / bars) * w, h - v * h, Math.max(1, w / bars - 1), v * h); + } + }; + const drawScope = (canvas) => { + if (!canvas) return; + const ctx = canvas.getContext("2d"); + const dpr = window.devicePixelRatio || 1; + const w = Math.max(1, Math.round(canvas.clientWidth * dpr)); + const h = Math.max(1, Math.round(canvas.clientHeight * dpr)); + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + drawGrid(ctx, w, h); + ctx.strokeStyle = "#f4d49e"; + ctx.lineWidth = Math.max(1.5, 1.5 * dpr); + ctx.beginPath(); + wave.forEach((value, i) => { + const x = (i / Math.max(1, wave.length - 1)) * w; + const y = (value / 255) * h; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + }; + drawBars(spectrum); + drawScope(scope); + let peak = 0; + let sum = 0; + for (const value of wave) { + const v = (value - 128) / 128; + peak = Math.max(peak, Math.abs(v)); + sum += v * v; + } + const rms = Math.sqrt(sum / Math.max(1, wave.length)); + if (meter) meter.style.width = `${Math.round(peak * 100)}%`; + if (readout) readout.textContent = `PK ${Math.round(peak * 100)} RMS ${Math.round(rms * 100)}`; + raf = requestAnimationFrame(drawRealtime); + }; + const draw = () => { + root.querySelectorAll(".iamccs-audefx-dynamic").forEach((el) => el.remove()); + const state = arrangerState(); + const clip = selectedClip(state); + const control = parseControl(); + const dynamic = document.createElement("div"); + dynamic.className = "iamccs-audefx-dynamic"; + const head = document.createElement("div"); + head.className = "iamccs-audefx-head"; + const title = document.createElement("div"); + title.innerHTML = `
IAMCCS ControlAudEfx
${clip ? String(clip.name || clip.fileName || clip.id) : "connect Arranger cine_linx output"} / clips ${clips(state).length}
`; + const tools = document.createElement("div"); + tools.className = "iamccs-audefx-tools"; + const playBtn = document.createElement("button"); + playBtn.textContent = playing ? "Stop" : "Play FX"; + playBtn.className = playing ? "is-active" : ""; + playBtn.onclick = () => playing ? stop() : play(); + const refresh = document.createElement("button"); + refresh.textContent = "Refresh"; + refresh.onclick = () => draw(); + tools.append(playBtn, refresh); + head.append(title, tools); + dynamic.appendChild(head); + const strip = document.createElement("div"); + strip.className = "iamccs-audefx-strip"; + strip.innerHTML = `REALTIMEPK 0 RMS 0${clip ? `HPF ${clip.hpfHz || 0} / LPF ${clip.lpfHz || 22000} / EQ ${clip.eqLowDb || 0}, ${clip.eqMidDb || 0}, ${clip.eqHighDb || 0}` : "no clip"}`; + dynamic.appendChild(strip); + const grid = document.createElement("div"); + grid.className = "iamccs-audefx-grid"; + const panels = [ + ["Waveform", "iamccs-audefx-wave"], + ["EQ Response", "iamccs-audefx-eq"], + ["Spectrum", "iamccs-audefx-spectrum"], + ["Oscilloscope", "iamccs-audefx-scope"], + ]; + const canvases = {}; + for (const [label, klass] of panels) { + const panel = document.createElement("div"); + panel.className = "iamccs-audefx-panel"; + const lab = document.createElement("div"); + lab.className = "iamccs-audefx-label"; + lab.textContent = label; + const canvas = document.createElement("canvas"); + canvas.className = `iamccs-audefx-canvas ${klass}`; + canvases[klass] = canvas; + panel.append(lab, canvas); + grid.appendChild(panel); + } + dynamic.appendChild(grid); + const list = document.createElement("div"); + list.className = "iamccs-audefx-list"; + list.textContent = clip ? JSON.stringify({ + id: clip.id, + linkedVisualId: clip.linkedVisualId || "", + gain: clip.gain, + pan: clip.pan, + hpfHz: clip.hpfHz, + lpfHz: clip.lpfHz, + eqLowDb: clip.eqLowDb, + eqMidDb: clip.eqMidDb, + eqHighDb: clip.eqHighDb, + compressor: clip.compressor, + delaySend: clip.delaySend, + reverbSend: clip.reverbSend, + masterBus: state.masterBus || {}, + }, null, 2) : "No AudioBoardArranger connected."; + dynamic.appendChild(list); + root.appendChild(dynamic); + if (clip) { + requestAnimationFrame(() => { + drawStaticWave(canvases["iamccs-audefx-wave"], clip); + drawEq(canvases["iamccs-audefx-eq"], clip, state.masterBus || {}); + drawGrid(canvases["iamccs-audefx-spectrum"].getContext("2d"), canvases["iamccs-audefx-spectrum"].clientWidth, canvases["iamccs-audefx-spectrum"].clientHeight); + drawGrid(canvases["iamccs-audefx-scope"].getContext("2d"), canvases["iamccs-audefx-scope"].clientWidth, canvases["iamccs-audefx-scope"].clientHeight); + }); + } + }; + draw(); + const domWidget = node.addDOMWidget("ControlAudEfx", "iamccs_control_aud_efx", root, { serialize: false }); + domWidget.computeSize = (width) => [width, 500]; +} + + +function ensureAudioBoardMixerStyles() { + if (document.getElementById("iamccs-audio-board-mixer-style")) return; + const style = document.createElement("style"); + style.id = "iamccs-audio-board-mixer-style"; + style.textContent = ` + .iamccs-audio-mixer { + box-sizing: border-box; + width: 100%; + min-height: 560px; + padding: 10px; + overflow: hidden; + color: #dce7ea; + background: linear-gradient(180deg, #222322, #111314 42%, #0b0d0e); + border: 1px solid rgba(170,184,184,.24); + border-radius: 8px; + font: 11px/1.25 Inter, ui-sans-serif, system-ui, sans-serif; + } + .iamccs-audio-mixer * { box-sizing: border-box; letter-spacing: 0; } + .iamccs-mixer-head, + .iamccs-mixer-transport, + .iamccs-mixer-strips { + display: flex; + align-items: center; + gap: 8px; + } + .iamccs-mixer-head { justify-content: space-between; margin-bottom: 8px; } + .iamccs-mixer-title { color: #f1f3f2; font-weight: 950; font-size: 13px; } + .iamccs-mixer-sub { color: #a7adae; font-weight: 750; font-size: 10px; } + .iamccs-audio-mixer button { + min-height: 24px; + padding: 0 8px; + color: #eaf4f2; + background: linear-gradient(180deg, #45494a, #272a2b); + border: 1px solid rgba(210,218,216,.24); + border-radius: 5px; + font: 850 10px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + cursor: pointer; + } + .iamccs-audio-mixer button.is-active { + color: #1b1712; + border-color: #e8c58d; + background: linear-gradient(180deg, #f0d49b, #b8784b); + } + .iamccs-mixer-transport { + padding: 8px; + margin-bottom: 8px; + background: #303031; + border: 1px solid rgba(255,255,255,.09); + border-radius: 6px; + } + .iamccs-mixer-time { + min-width: 190px; + color: #b9bec4; + font: 950 23px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + } + .iamccs-mixer-state { margin-left: auto; color: #9fa1a5; font: 850 16px/1 ui-monospace, SFMono-Regular, Consolas, monospace; } + .iamccs-mixer-strips { + align-items: stretch; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 8px; + scrollbar-color: #596164 #1b1d1e; + } + .iamccs-mixer-strip { + flex: 0 0 158px; + min-height: 430px; + display: grid; + grid-template-rows: 34px 42px 58px 1fr 32px 34px; + gap: 7px; + padding: 8px; + color: #d6dbdc; + background: linear-gradient(180deg, #8e9896 0 24%, #2c2e2f 24% 100%); + border: 1px solid rgba(255,255,255,.12); + border-radius: 2px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.10), 0 8px 18px rgba(0,0,0,.18); + } + .iamccs-mixer-strip.is-master { + flex-basis: 210px; + background: linear-gradient(180deg, #2b2c2d, #17191a); + } + .iamccs-mixer-fx { + display:flex; + align-items:center; + justify-content:space-between; + height:28px; + padding:0 7px; + color:#bec6c8; + background:linear-gradient(90deg, #454848 0 73%, #87918f 73% 100%); + border:1px solid rgba(255,255,255,.13); + border-radius:2px; + font:900 10px/1 ui-monospace,SFMono-Regular,Consolas,monospace; + } + .iamccs-mixer-strip.is-master .iamccs-mixer-fx { background:linear-gradient(90deg, #353637 0 74%, #6f7977 74% 100%); } + .iamccs-mixer-pan { + display:grid; + place-items:center; + gap:2px; + color:#313437; + font:800 9px/1 ui-monospace,SFMono-Regular,Consolas,monospace; + } + .iamccs-mixer-knob { + width:34px; + height:34px; + min-height:34px; + padding:0; + border-radius:50%; + border:1px solid rgba(0,0,0,.35); + background: + conic-gradient(from 225deg, rgba(255,255,255,.12) 0 18%, #cfd6d4 var(--knob-fill, 38%), rgba(0,0,0,.28) var(--knob-fill, 38%) 78%, transparent 78% 100%), + radial-gradient(circle at 35% 30%, #f2f1ed, #aaa9a4 54%, #3c3e3f 100%); + box-shadow: inset 0 1px 2px rgba(255,255,255,.7), inset 0 -4px 7px rgba(0,0,0,.36); + position:relative; + cursor:ew-resize; + } + .iamccs-mixer-knob::after { + content:""; + position:absolute; + left:50%; + top:5px; + width:2px; + height:11px; + transform-origin:50% 12px; + transform:translateX(-50%) rotate(var(--knob-angle, 0deg)); + background:#313437; + border-radius:999px; + } + .iamccs-mixer-actions { + display:grid; + grid-template-columns: 1fr 1fr; + gap:5px; + } + .iamccs-mixer-actions button { + min-height:28px; + color:#a85b58; + background:linear-gradient(180deg,#333536,#202223); + border-color:rgba(255,255,255,.10); + font-size:11px; + } + .iamccs-mixer-actions button.solo { color:#cbd45b; } + .iamccs-mixer-route { + display:grid; + place-items:center; + width:36px; + height:28px; + margin:0 auto; + border-radius:3px; + background:linear-gradient(135deg,#29b4a7 0 34%,#374349 34% 52%,#c6b15e 52% 100%); + box-shadow:inset 0 0 0 1px rgba(0,0,0,.35); + } + .iamccs-mixer-body { + display:grid; + grid-template-columns: 36px 1fr 32px; + gap:7px; + align-items:end; + min-height:230px; + } + .iamccs-mixer-scale { + align-self:stretch; + display:flex; + flex-direction:column; + justify-content:space-between; + color:#85898c; + font:900 10px/1 ui-monospace,SFMono-Regular,Consolas,monospace; + } + .iamccs-mixer-fader { + align-self:stretch; + display:grid; + place-items:center; + position:relative; + } + .iamccs-mixer-fader input[type="range"] { + writing-mode: bt-lr; + appearance: slider-vertical; + width:32px; + height:218px; + accent-color:#c7cbca; + cursor:ns-resize; + } + .iamccs-mixer-meter { + align-self:stretch; + position:relative; + width:18px; + overflow:hidden; + background:#101413; + border:1px solid rgba(0,0,0,.58); + box-shadow:inset 0 0 0 1px rgba(255,255,255,.06); + } + .iamccs-mixer-meter i { + position:absolute; + left:0; + right:0; + bottom:0; + height:0%; + background:linear-gradient(180deg,#e25145 0 10%,#f0c755 10% 28%,#39f077 28% 100%); + transition:height .08s linear; + } + .iamccs-mixer-label { + display:flex; + align-items:center; + justify-content:center; + min-height:30px; + color:#2b2e31; + background:#9aa3a1; + border-top:1px solid rgba(255,255,255,.18); + font:950 16px/1 ui-monospace,SFMono-Regular,Consolas,monospace; + } + .iamccs-mixer-strip.is-master .iamccs-mixer-label { + color:#f0f2ef; + background:#2b2e31; + } + `; + document.head.appendChild(style); +} + +function renderAudioBoardMixer(node) { + if (node._iamccsAudioBoardMixerReady) return; + node._iamccsAudioBoardMixerReady = true; + ensureAudioBoardMixerStyles(); + const dataWidget = findWidget(node, "mixer_data"); + hideWidget(dataWidget); + const root = document.createElement("div"); + root.className = "iamccs-audio-mixer"; + let mixerMeterRaf = 0; + + const parseLocal = () => { + try { return JSON.parse(String(dataWidget?.value || "{}")); } catch { return {}; } + }; + const writeLocal = (patch = {}) => { + const data = { schema: "iamccs.audio_board_mixer", schema_version: 1, ...parseLocal(), ...patch }; + if (dataWidget) { + dataWidget.value = JSON.stringify(data, null, 2); + dataWidget.callback?.(dataWidget.value); + } + node.setDirtyCanvas?.(true, true); + app.graph?.setDirtyCanvas?.(true, true); + }; + const upstreamArranger = () => { + for (const input of node.inputs || []) { + const link = input.link != null ? app.graph?.links?.[input.link] : null; + const origin = link ? app.graph?.getNodeById?.(link.origin_id) : null; + const type = String(origin?.comfyClass || origin?.type || ""); + if (origin && type === "IAMCCS_AudioBoardArranger") return origin; + if (origin) { + for (const other of app.graph?._nodes || []) { + const otherType = String(other?.comfyClass || other?.type || ""); + if (otherType !== "IAMCCS_AudioBoardArranger") continue; + const linkedToOrigin = (other.outputs || []).some((output) => (output.links || []).some((linkId) => app.graph?.links?.[linkId]?.target_id === origin.id)); + if (linkedToOrigin) return other; + } + } + } + return null; + }; + const readArranger = () => { + const arranger = upstreamArranger(); + const widget = arranger ? findWidget(arranger, "arranger_data") : null; + try { + const data = JSON.parse(String(widget?.value || "{}")); + if (data && typeof data === "object") return { arranger, widget, data }; + } catch {} + return { arranger: null, widget: null, data: {} }; + }; + const arrangerTransport = () => upstreamArranger()?._iamccsAudioBoardTransport || null; + const fmtMixerTime = (frames, fpsValue = 24) => { + const safeFps = Math.max(1, Number(fpsValue || 24) || 24); + const total = Math.max(0, Number(frames || 0) / safeFps); + const minutes = Math.floor(total / 60); + const seconds = Math.floor(total % 60); + const millis = Math.floor((total - Math.floor(total)) * 1000); + return `${minutes}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`; + }; + const ensureTrackSettings = (data) => { + data.trackSettings = Array.isArray(data.trackSettings) ? data.trackSettings : []; + const count = Math.max(1, Number(data.audioTrackCount || 1)); + for (let i = 0; i < count; i += 1) { + data.trackSettings[i] = { + name: `A${i + 1}`, + mute: false, + solo: false, + volume: 1, + pan: 0, + effectChain: [], + ...(data.trackSettings[i] && typeof data.trackSettings[i] === "object" ? data.trackSettings[i] : {}), + }; + } + return data.trackSettings; + }; + const syncArranger = (data, reason) => { + const { arranger, widget } = readArranger(); + if (widget) { + widget.value = JSON.stringify(data, null, 2); + widget.callback?.(widget.value); + arranger?.setDirtyCanvas?.(true, true); + } + writeLocal({ mirrorTrackSettings: data.trackSettings || [], masterBus: data.masterBus || {}, last_sync_reason: reason }); + }; + const peakForTrack = (data, track) => { + const segs = Array.isArray(data.audioSegments) ? data.audioSegments.filter((seg) => Number(seg.track || 0) === track && !seg.mute) : []; + if (!segs.length) return 0; + const raw = segs.reduce((sum, seg) => sum + Math.max(.08, Math.min(1, Number(seg.peak || seg.audioPeak || .38))), 0) / Math.max(1, segs.length); + const settings = ensureTrackSettings(data)[track] || {}; + return Math.max(0, Math.min(1, raw * Number(settings.volume ?? 1))); + }; + const setKnob = (button, value, min, max) => { + const t = (Math.max(min, Math.min(max, Number(value || 0))) - min) / Math.max(.0001, max - min); + button.style.setProperty("--knob-angle", `${-135 + t * 270}deg`); + button.style.setProperty("--knob-fill", `${Math.round(t * 78)}%`); + }; + const attachDrag = (button, getter, setter, min, max, scale) => { + button.onpointerdown = (event) => { + event.preventDefault(); + const startX = event.clientX; + const startY = event.clientY; + const start = Number(getter() || 0); + try { button.setPointerCapture?.(event.pointerId); } catch {} + const move = (moveEvent) => { + const value = Math.max(min, Math.min(max, start + ((moveEvent.clientX - startX) + (startY - moveEvent.clientY)) * scale)); + setter(value, false); + moveEvent.preventDefault(); + }; + const up = (upEvent) => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + try { button.releasePointerCapture?.(upEvent.pointerId); } catch {} + setter(Number(getter() || 0), true); + }; + window.addEventListener("pointermove", move, { passive: false }); + window.addEventListener("pointerup", up, { passive: false, once: true }); + }; + }; + function updateMixerLiveMeters() { + const api = arrangerTransport(); + const snap = api?.snapshot?.(); + const meters = snap?.meters || {}; + const playing = Boolean(snap?.playing); + const timeEl = root.querySelector(".iamccs-mixer-time"); + if (timeEl && snap) timeEl.textContent = `${fmtMixerTime(Math.max(0, Number(snap.playhead || 0)), snap.fps)} / ${fmtMixerTime(Math.max(1, Number(snap.totalFrames || 1)), snap.fps)}`; + const stateEl = root.querySelector(".iamccs-mixer-state"); + if (stateEl) stateEl.textContent = api ? (playing ? "[playing]" : "[stopped]") : "[no arranger]"; + const playBtn = root.querySelector("[data-mixer-transport='play']"); + if (playBtn) { + playBtn.classList.toggle("is-active", playing); + playBtn.textContent = playing ? "Pause" : "Play"; + } + root.querySelectorAll(".iamccs-mixer-meter i").forEach((fill) => { + const key = fill.dataset.mixerMeter || ""; + let peak = Number(fill.dataset.staticPeak || 0); + if (playing && key === "master") peak = Number(meters.master?.peak || 0); + else if (playing && key !== "") peak = Number(meters.tracks?.[key]?.peak || 0); + fill.style.height = `${Math.max(0, Math.min(100, Math.round(peak * 100)))}%`; + }); + mixerMeterRaf = requestAnimationFrame(updateMixerLiveMeters); + } + const draw = () => { + const { data } = readArranger(); + const local = parseLocal(); + if (!Array.isArray(data.audioSegments) && Array.isArray(local.audioSegments)) Object.assign(data, local); + data.audioTrackCount = Math.max(1, Number(data.audioTrackCount || (Array.isArray(data.trackSettings) ? data.trackSettings.length : 4) || 4)); + data.masterBus = data.masterBus && typeof data.masterBus === "object" ? data.masterBus : {}; + const settings = ensureTrackSettings(data); + root.innerHTML = ""; + const head = document.createElement("div"); + head.className = "iamccs-mixer-head"; + const title = document.createElement("div"); + title.innerHTML = `
IAMCCS AudioBoard Mixer
${data.audioSegments?.length || 0} clips / ${data.audioTrackCount} channel strips / sync ${upstreamArranger() ? "Arranger" : "cine_linx"}
`; + const refresh = document.createElement("button"); + refresh.textContent = "Sync From Arranger"; + refresh.onclick = () => draw(); + head.append(title, refresh); + if (mixerMeterRaf) { + cancelAnimationFrame(mixerMeterRaf); + mixerMeterRaf = 0; + } + const transport = document.createElement("div"); + transport.className = "iamccs-mixer-transport"; + const makeTransportButton = (label, action, attr = "") => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + if (attr) button.dataset.mixerTransport = attr; + button.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + action(); + setTimeout(updateMixerLiveMeters, 0); + }; + return button; + }; + const rewind = makeTransportButton("|<", () => arrangerTransport()?.rewind?.(), "rewind"); + const play = makeTransportButton("Play", () => arrangerTransport()?.toggle?.(), "play"); + const loop = makeTransportButton("Loop", () => { + data.loopEnabled = !data.loopEnabled; + syncArranger(data, "mixer_loop_toggle"); + draw(); + }, "loop"); + loop.classList.toggle("is-active", Boolean(data.loopEnabled)); + const stop = makeTransportButton("Stop", () => arrangerTransport()?.stop?.(true), "stop"); + const time = document.createElement("span"); + time.className = "iamccs-mixer-time"; + time.textContent = `${fmtMixerTime(0, data.frame_rate)} / ${fmtMixerTime(Math.max(1, Math.round(Number(data.duration_seconds || 0) * Number(data.frame_rate || 24))), data.frame_rate)}`; + const stateBadge = document.createElement("span"); + stateBadge.className = "iamccs-mixer-state"; + stateBadge.textContent = upstreamArranger() ? "[Mixer Sync]" : "[no arranger]"; + transport.append(rewind, play, loop, stop, time, stateBadge); + const strips = document.createElement("div"); + strips.className = "iamccs-mixer-strips"; + const makeStrip = (index, isMaster = false) => { + const strip = document.createElement("div"); + strip.className = `iamccs-mixer-strip${isMaster ? " is-master" : ""}`; + const st = isMaster ? data.masterBus : settings[index]; + const label = isMaster ? "MASTER" : String(st.name || `A${index + 1}`); + const fx = document.createElement("div"); + fx.className = "iamccs-mixer-fx"; + fx.innerHTML = `FX`; + const pan = document.createElement("div"); + pan.className = "iamccs-mixer-pan"; + const panKnob = document.createElement("button"); + panKnob.className = "iamccs-mixer-knob"; + panKnob.type = "button"; + setKnob(panKnob, isMaster ? 0 : Number(st.pan || 0), -1, 1); + const panText = document.createElement("span"); + const syncPanText = () => { const v = isMaster ? 0 : Number(st.pan || 0); panText.textContent = isMaster ? "stereo" : (Math.abs(v) < .02 ? "C" : `${v < 0 ? "L" : "R"}${Math.round(Math.abs(v) * 100)}`); setKnob(panKnob, v, -1, 1); }; + syncPanText(); + if (!isMaster) attachDrag(panKnob, () => st.pan || 0, (value, commit) => { st.pan = value; syncPanText(); if (commit) syncArranger(data, `mixer_pan_${index}`); }, -1, 1, .006); + pan.append(panKnob, panText); + const actions = document.createElement("div"); + actions.className = "iamccs-mixer-actions"; + if (isMaster) { + actions.innerHTML = ``; + } else { + const mute = document.createElement("button"); + mute.textContent = "M"; + mute.className = st.mute ? "is-active" : ""; + mute.onclick = () => { st.mute = !st.mute; syncArranger(data, `mixer_mute_${index}`); draw(); }; + const solo = document.createElement("button"); + solo.textContent = "S"; + solo.className = `solo${st.solo ? " is-active" : ""}`; + solo.onclick = () => { st.solo = !st.solo; syncArranger(data, `mixer_solo_${index}`); draw(); }; + actions.append(mute, solo); + } + const body = document.createElement("div"); + body.className = "iamccs-mixer-body"; + const scale = document.createElement("div"); + scale.className = "iamccs-mixer-scale"; + ["0", "-6", "-18", "-30", "-42", "-54"].forEach((item) => { + const tick = document.createElement("span"); + tick.textContent = item; + scale.appendChild(tick); + }); + const fader = document.createElement("div"); + fader.className = "iamccs-mixer-fader"; + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = "0"; + slider.max = "2"; + slider.step = "0.01"; + slider.value = String(isMaster ? Number(data.masterAudioGain ?? 1) : Number(st.volume ?? 1)); + slider.oninput = () => { + if (isMaster) data.masterAudioGain = Number(slider.value || 1); + else st.volume = Number(slider.value || 1); + meterFill.style.height = `${Math.round((isMaster ? .45 : peakForTrack(data, index)) * 100)}%`; + }; + slider.onchange = () => syncArranger(data, isMaster ? "mixer_master_gain" : `mixer_volume_${index}`); + fader.appendChild(slider); + const meter = document.createElement("div"); + meter.className = "iamccs-mixer-meter"; + const meterFill = document.createElement("i"); + const staticPeak = isMaster ? Math.min(1, Array.from({ length: data.audioTrackCount }, (_, i) => peakForTrack(data, i)).reduce((a, b) => a + b, 0) / Math.max(1, data.audioTrackCount)) : peakForTrack(data, index); + meterFill.dataset.mixerMeter = isMaster ? "master" : String(index); + meterFill.dataset.staticPeak = String(staticPeak); + meterFill.style.height = `${Math.round(staticPeak * 100)}%`; + meter.appendChild(meterFill); + body.append(scale, fader, meter); + const route = document.createElement("div"); + route.className = "iamccs-mixer-route"; + route.title = "Route"; + const name = document.createElement("div"); + name.className = "iamccs-mixer-label"; + name.textContent = label.replace(/^A/, ""); + strip.append(fx, pan, actions, body, route, name); + return strip; + }; + strips.appendChild(makeStrip(0, true)); + for (let i = 0; i < data.audioTrackCount; i += 1) strips.appendChild(makeStrip(i, false)); + root.append(head, transport, strips); + updateMixerLiveMeters(); + writeLocal({ audioSegments: data.audioSegments || [], audioTrackCount: data.audioTrackCount, mirrorTrackSettings: data.trackSettings || [], masterBus: data.masterBus || {}, masterAudioGain: data.masterAudioGain ?? 1 }); + }; + draw(); + const domWidget = node.addDOMWidget("AudioBoardMixer", "iamccs_audio_board_mixer", root, { serialize: false }); + domWidget.computeSize = (width) => [width, 620]; +} + +app.registerExtension({ + name: "IAMCCS.CineAudioDialogueUI", + setup() { + [500, 1500, 3500].forEach((delay) => { + setTimeout(() => { + const nodes = Array.isArray(app?.graph?._nodes) ? app.graph._nodes : []; + console.info("[IAMCCS AudioDialogueUI] graph scan", { + delay, + nodes: nodes.length, + iamccsAudioCandidates: nodes + .filter((node) => iamccsAudioDialogueType(node)) + .map((node) => ({ id: node?.id, type: node?.type, detected: iamccsAudioDialogueType(node), widgets: (node?.widgets || []).map((w) => w?.name) })), + }); + nodes.forEach((node) => renderIamccsAudioDialogueNode(node, `extension.graphScan+${delay}`)); + }, delay); + }); + }, + nodeCreated(node) { + [0, 120, 600].forEach((delay) => setTimeout(() => renderIamccsAudioDialogueNode(node, `extension.nodeCreated+${delay}`), delay)); + }, + loadedGraphNode(node) { + [0, 120, 600].forEach((delay) => setTimeout(() => renderIamccsAudioDialogueNode(node, `extension.loadedGraphNode+${delay}`), delay)); + }, + async beforeRegisterNodeDef(nodeType, nodeData) { + if ( + nodeData.name !== "IAMCCS_CineEmotionButtons" && + nodeData.name !== "IAMCCS_BoardMaker_DialogueFoley" && + nodeData.name !== "IAMCCS_AudioBoardArranger" && + nodeData.name !== "IAMCCS_ControlAudEfx" && + nodeData.name !== "IAMCCS_AudioBoardMixer" && + nodeData.name !== "IAMCCS_CineSpeech1PromptCompiler" + ) return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + onNodeCreated?.apply(this, arguments); + console.info("[IAMCCS AudioDialogueUI] node created", { name: nodeData.name, id: this?.id, type: this?.type }); + renderIamccsAudioDialogueNode(this, "prototype.onNodeCreated"); + }; + }, +}); diff --git a/web/iamccs_cine_timeline_ui.js b/web/iamccs_cine_timeline_ui.js index 17ec3a0..542fbbc 100644 --- a/web/iamccs_cine_timeline_ui.js +++ b/web/iamccs_cine_timeline_ui.js @@ -1,10 +1,12 @@ ο»Ώimport { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; -const CINE_VERSION = "2026-05-22-v3-node-all-values-truth-1"; +console.info("[IAMCCS V3] Stable node UI mode active. AudioBoard UI is loaded directly by Comfy extension registry."); + +const CINE_VERSION = "2026-05-26-v3-perf-idle-sync"; const SHOTBOARD_V3_RIGID_WIDTH = 1920; const SHOTBOARD_V3_OPEN_HEIGHT = 900; -const SHOTBOARD_V3_COLLAPSED_HEIGHT = 560; +const SHOTBOARD_V3_COLLAPSED_HEIGHT = 660; // increased to accommodate global prompt always visible in collapsed mode const SHOTBOARD_NODE_MIN_SIZE = [1500, 760]; const SHOTBOARD_NODE_DEFAULT_SIZE = [1500, 780]; const SHOTBOARD_ROW_GRID = "24px 108px 164px 188px 42px 42px minmax(430px,500px) minmax(280px,1fr) 28px"; @@ -505,7 +507,7 @@ function protectControlDrag(element) { for (const eventName of ["pointerdown", "pointermove", "mousedown", "mousemove", "touchstart", "touchmove", "wheel", "drag", "dragover", "drop"]) { element.addEventListener(eventName, (event) => { if ((eventName === "dragover" || eventName === "drop") && hasFileDrag(event)) return; - if ((eventName === "pointerdown" || eventName === "pointermove" || eventName === "mousedown" || eventName === "mousemove") && isNumericStepDragTarget(event)) return; + if (isNumericStepDragTarget(event) && /^(pointer|mouse|touch)/.test(eventName)) return; event.stopPropagation(); }, { passive: false, capture: true }); } @@ -562,6 +564,10 @@ function tableShell(title, subtitle) { color: ${CINE_FILM_LAB.text}; font-family: Arial, sans-serif; pointer-events: auto; + contain: layout paint style; + content-visibility: auto; + contain-intrinsic-size: 1500px 900px; + transform: translateZ(0); `; const head = document.createElement("div"); @@ -1209,6 +1215,51 @@ function parseShotboardTimelineString(value) { } } +function parseTimelinePayloadForImport(value) { + if (typeof value !== "string" || !value.trim()) return null; + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== "object") return null; + if (Array.isArray(parsed.segments) || Array.isArray(parsed.rows) || Array.isArray(parsed.audioSegments)) return parsed; + if (parsed.timeline && typeof parsed.timeline === "object") return parsed.timeline; + if (String(parsed.schema || "").includes("timeline") || String(parsed.schema || "").includes("shotboard")) return parsed; + } catch {} + return null; +} + +function looksLikeReferencePathList(value) { + if (typeof value !== "string" || !value.trim()) return false; + if (parseTimelinePayloadForImport(value)) return false; + const lines = value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + if (!lines.length) return false; + return lines.some((line) => /\.(png|jpe?g|webp|bmp|gif|tiff?|avif)(\?|$)/i.test(line) || line.includes("/") || line.includes("\\")); +} + +function addReferencePathsFromValue(target, value) { + const seen = new Set(target.map((item) => String(item || "").trim()).filter(Boolean)); + const add = (item) => { + const clean = String(item || "").trim(); + if (!clean || seen.has(clean)) return; + seen.add(clean); + target.push(clean); + }; + if (Array.isArray(value)) { + value.forEach(add); + return; + } + splitReferencePaths(value).forEach(add); +} + +function workflowWidgetImagePaths(widgets, preferredIndex) { + const paths = []; + if (looksLikeReferencePathList(String(widgets?.[preferredIndex] || ""))) addReferencePathsFromValue(paths, widgets[preferredIndex]); + if (looksLikeReferencePathList(String(widgets?.[1] || ""))) addReferencePathsFromValue(paths, widgets[1]); + for (const value of widgets || []) { + if (looksLikeReferencePathList(String(value || ""))) addReferencePathsFromValue(paths, value); + } + return paths; +} + function boardFromWorkflowJson(data) { if (!Array.isArray(data?.nodes)) return null; const node = data.nodes.find((item) => { @@ -1217,16 +1268,16 @@ function boardFromWorkflowJson(data) { }); if (!node) return null; const widgets = Array.isArray(node.widgets_values) ? node.widgets_values : []; - const isLite = String(node?.type || node?.class_type || "") === "IAMCCS_CineShotboardLite"; - return { - metadata: { - schema: "iamccs.cine.shotboard.board", - schema_version: 0, - imported_from: "comfy_workflow_shotplanner_node", - source_node_id: node.id, - }, - global_prompt: String(widgets[0] || ""), - timeline_data: String(widgets[1] || ""), + const nodeType = String(node?.type || node?.class_type || ""); + const isLite = nodeType === "IAMCCS_CineShotboardLite"; + const backupTimelineText = String(node?.properties?.iamccs_v3_timeline_data_backup || ""); + const widgetTimelineText = widgets.map((value) => String(value || "")).find((value) => parseTimelinePayloadForImport(value)) || ""; + const timelineText = parseTimelinePayloadForImport(backupTimelineText) ? backupTimelineText : widgetTimelineText; + const timelinePayload = parseTimelinePayloadForImport(timelineText); + const imagePaths = []; + if (timelinePayload?.image_paths) addReferencePathsFromValue(imagePaths, timelinePayload.image_paths); + addReferencePathsFromValue(imagePaths, workflowWidgetImagePaths(widgets, isLite ? 8 : 10)); + const settings = { duration_seconds: widgets[2], frame_rate: widgets[3], guide_policy: widgets[4], @@ -1235,12 +1286,26 @@ function boardFromWorkflowJson(data) { default_force: widgets[7], promptrelay_epsilon: isLite ? 0.65 : widgets[8], ltx_round_mode: isLite ? "up_8n_plus_1" : widgets[9], - image_paths: String(widgets[isLite ? 8 : 10] || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean), image_width: widgets[isLite ? 9 : 11], image_height: widgets[isLite ? 10 : 12], - image_resize_method: cineResizeMethodValue(getWidget(node, "image_resize_method")?.value), - image_multiple_of: getWidget(node, "image_multiple_of")?.value, - img_compression: getWidget(node, "img_compression")?.value, + image_resize_method: isLite ? "crop" : cineResizeMethodValue(widgets[13]), + image_multiple_of: isLite ? 32 : widgets[14], + img_compression: isLite ? 0 : widgets[15], + }; + return { + metadata: { + schema: "iamccs.cine.shotboard.board", + schema_version: 0, + imported_from: "comfy_workflow_shotplanner_node", + source_node_id: node.id, + source_node_type: nodeType, + }, + global_prompt: String(widgets[0] || ""), + timeline_data: timelineText, + image_paths: imagePaths, + images: Array.isArray(data?.images) ? cloneJsonData(data.images) : [], + settings, + ...settings, }; } @@ -1464,6 +1529,43 @@ function parsedTimelineSourcesForBoard(board) { return sources; } +const IAMCCS_IMAGE_TRUTH_KEYS = ["imageTruthPath", "image_truth_path", "imageFile", "image_file", "path"]; + +function iamccsPathLookup(pathMap, value) { + const raw = String(value || "").trim(); + if (!raw || !pathMap) return ""; + if (pathMap[raw]) return pathMap[raw]; + const normalized = raw.replace(/\\/g, "/").replace(/^\/+/, ""); + if (pathMap[normalized]) return pathMap[normalized]; + const basename = normalized.split("/").filter(Boolean).pop() || ""; + if (basename && pathMap[basename]) return pathMap[basename]; + const entries = Object.entries(pathMap); + const hit = entries.find(([key]) => { + const cleanKey = String(key || "").replace(/\\/g, "/").replace(/^\/+/, ""); + return cleanKey === normalized || (basename && cleanKey.split("/").filter(Boolean).pop() === basename); + }); + return hit ? hit[1] : ""; +} + +function iamccsPathBasename(value) { + return String(value || "").replace(/\\/g, "/").split("/").filter(Boolean).pop() || ""; +} + +function iamccsNameLookup(nameMap, value) { + const raw = String(value || "").trim(); + if (!raw || !nameMap) return ""; + if (nameMap[raw]) return nameMap[raw]; + const normalized = raw.replace(/\\/g, "/").replace(/^\/+/, ""); + if (nameMap[normalized]) return nameMap[normalized]; + const basename = iamccsPathBasename(normalized); + if (basename && nameMap[basename]) return nameMap[basename]; + const hit = Object.entries(nameMap).find(([key]) => { + const cleanKey = String(key || "").replace(/\\/g, "/").replace(/^\/+/, ""); + return cleanKey === normalized || (basename && iamccsPathBasename(cleanKey) === basename); + }); + return hit ? hit[1] : ""; +} + function collectActivePackageImagePaths(board) { const referencePaths = splitReferencePaths(board?.image_paths); const seen = new Set(); @@ -1484,15 +1586,17 @@ function collectActivePackageImagePaths(board) { if (!seg || typeof seg !== "object") continue; const type = String(seg.type || "image"); if (type === "text" || type === "audio" || seg.textPlaceholder || seg.placeholder) continue; - add(seg.imageFile || seg.image_file || seg.path); - if (!(seg.imageFile || seg.image_file || seg.path)) addFromRef(seg.ref); + const segPath = seg.imageTruthPath || seg.image_truth_path || seg.imageFile || seg.image_file || seg.path; + add(segPath); + if (!segPath) addFromRef(seg.ref); } const rows = Array.isArray(data?.rows) ? data.rows : []; for (const row of rows) { if (!row || typeof row !== "object") continue; if (row.use_guide === false || Number(row.force ?? row.strength ?? row.guideStrength ?? 0) <= 0) continue; - add(row.imageFile || row.image_file || row.path); - if (!(row.imageFile || row.image_file || row.path)) addFromRef(row.ref); + const rowPath = row.imageTruthPath || row.image_truth_path || row.imageFile || row.image_file || row.path; + add(rowPath); + if (!rowPath) addFromRef(row.ref); } } return paths; @@ -1515,10 +1619,10 @@ function collectPackageImagePaths(board) { } for (const data of parsedTimelineSourcesForBoard(board)) { const segments = Array.isArray(data?.segments) ? data.segments : []; - for (const seg of segments) add(seg?.imageFile || seg?.image_file || seg?.path); + for (const seg of segments) add(seg?.imageTruthPath || seg?.image_truth_path || seg?.imageFile || seg?.image_file || seg?.path); } if (Array.isArray(board?.segments)) { - for (const seg of board.segments) add(seg?.imageFile || seg?.image_file || seg?.path); + for (const seg of board.segments) add(seg?.imageTruthPath || seg?.image_truth_path || seg?.imageFile || seg?.image_file || seg?.path); } return paths; } @@ -1609,7 +1713,7 @@ function applyPackageLocalImageFallbacks(board) { if (target && target !== originalPath) pathMap[originalPath] = target; } if (!Object.keys(pathMap).length) return { changed: false, paths: [] }; - rewriteBoardPackagedImagePaths(board, pathMap); + rewriteBoardPackagedImagePaths(board, pathMap, {}); return { changed: true, paths: collectPackageImagePaths(board) }; } @@ -1634,19 +1738,51 @@ function sourcePathForRef(ref, originalReferencePaths) { return index >= 0 && index < originalReferencePaths.length ? String(originalReferencePaths[index] || "").trim() : ""; } -function rewritePackagedSegments(segments, pathMap, refMap = {}, originalReferencePaths = []) { +function rewritePackagedSegments(segments, pathMap, refMap = {}, originalReferencePaths = [], nameMap = {}) { if (!Array.isArray(segments)) return; for (const seg of segments) { if (!seg || typeof seg !== "object") continue; - const explicitSource = String(seg.imageFile || seg.image_file || seg.path || "").trim(); + const type = String(seg.type || "image"); + if (type === "text" || type === "audio") continue; + const explicitSource = String(seg.imageTruthPath || seg.image_truth_path || seg.imageFile || seg.image_file || seg.path || "").trim(); const refSource = sourcePathForRef(seg.ref, originalReferencePaths); - const nextRef = refMap[explicitSource] || refMap[refSource]; - if (nextRef && String(seg.type || "image") !== "text" && String(seg.type || "image") !== "audio") { + const mappedExplicit = iamccsPathLookup(pathMap, explicitSource); + const mappedRef = iamccsPathLookup(pathMap, refSource); + const nextRef = refMap[explicitSource] || refMap[refSource] || refMap[mappedExplicit] || refMap[mappedRef]; + const nextPath = mappedExplicit || mappedRef; + const nextName = iamccsNameLookup(nameMap, explicitSource) || iamccsNameLookup(nameMap, refSource) || iamccsPathBasename(nextPath); + if (nextRef) { seg.ref = nextRef; + seg.imageTruthRef = nextRef; } - for (const key of ["imageFile", "image_file", "path"]) { + if (nextPath) { + seg.imageFile = nextPath; + seg.path = nextPath; + seg.imageTruthPath = nextPath; + seg.imageTruthPinned = true; + seg.imageTruthSource = "package_import_remap"; + if (nextName) { + seg.imageTruthName = nextName; + seg.imageName = nextName; + if (!String(seg.label || "").trim() || /^ref_?\d+$/i.test(String(seg.label || ""))) { + seg.label = nextName.replace(/\.[^.]+$/, ""); + } + } + delete seg.image_file; + delete seg.image_truth_path; + continue; + } + for (const key of IAMCCS_IMAGE_TRUTH_KEYS) { const value = String(seg[key] || "").trim(); - if (value && pathMap[value]) seg[key] = pathMap[value]; + const mapped = iamccsPathLookup(pathMap, value); + if (mapped) { + seg[key] = mapped; + const mappedName = iamccsNameLookup(nameMap, value) || iamccsPathBasename(mapped); + if (mappedName) { + seg.imageTruthName = mappedName; + seg.imageName = mappedName; + } + } } } } @@ -1656,7 +1792,7 @@ function rewritePackagedRows(rows, refMap = {}, originalReferencePaths = []) { for (const row of rows) { if (!row || typeof row !== "object") continue; if (row.use_guide === false) continue; - const explicitSource = String(row.imageFile || row.image_file || row.path || "").trim(); + const explicitSource = String(row.imageTruthPath || row.image_truth_path || row.imageFile || row.image_file || row.path || "").trim(); const refSource = sourcePathForRef(row.ref, originalReferencePaths); const nextRef = refMap[explicitSource] || refMap[refSource]; if (nextRef) row.ref = nextRef; @@ -2173,6 +2309,26 @@ function replaceReferencePathAt(node, index, newPath) { return compact; } +function appendReferencePath(node, newPath) { + const cleanPath = String(newPath || "").trim(); + const current = getConnectedReferencePaths(node).slice(); + const next = (current.length ? current : getOwnReferencePaths(node).slice()) + .map((item) => String(item || "").trim()) + .filter(Boolean); + if (!cleanPath) return { paths: next, refNumber: Math.max(1, next.length), appended: false }; + const normalize = (value) => String(value || "").replace(/\\/g, "/").trim(); + const existingIndex = next.findIndex((item) => normalize(item) === normalize(cleanPath)); + if (existingIndex >= 0) { + setOwnReferencePaths(node, next); + try { node?.setDirtyCanvas?.(true, true); app?.graph?.setDirtyCanvas?.(true, true); } catch {} + return { paths: next, refNumber: existingIndex + 1, appended: false }; + } + next.push(cleanPath); + setOwnReferencePaths(node, next); + try { node?.setDirtyCanvas?.(true, true); app?.graph?.setDirtyCanvas?.(true, true); } catch {} + return { paths: next, refNumber: next.length, appended: true }; +} + function syncSegmentImageFileForReference(segments, refIndex, imagePath) { const refNumber = Math.max(1, Math.round(Number(refIndex || 0) + 1)); const cleanPath = String(imagePath || "").trim(); @@ -2183,6 +2339,7 @@ function syncSegmentImageFileForReference(segments, refIndex, imagePath) { if (String(seg.type || "image") === "text" || String(seg.type || "image") === "audio") continue; if (Math.round(Number(seg.ref || 0)) !== refNumber) continue; seg.imageFile = cleanPath; + seg.path = cleanPath; delete seg.image_file; changed = true; } @@ -2263,14 +2420,18 @@ async function restorePackagedImagesToComfyInput(board, statusCallback = null) { seen.add(key); entries.push(entry); } - if (!entries.length) return { paths: [], pathMap: {} }; + if (!entries.length) return { paths: [], pathMap: {}, nameMap: {} }; const packageName = sanitizePackageComponent(board?.package?.name || board?.metadata?.package_name || "", ""); const files = []; for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]; const fallbackExt = imageExtensionForPackage(entry.filename || entry.original_name || entry.name || "", entry.content_type || ""); - const rawName = entry.filename || entry.original_name || entry.name || `ref_${String(index + 1).padStart(3, "0")}.${fallbackExt}`; - const filename = sanitizePackageComponent(rawName, `ref_${String(index + 1).padStart(3, "0")}.${fallbackExt}`); + const refFallback = `ref_${String(index + 1).padStart(3, "0")}.${fallbackExt}`; + // Prefer package_path basename (ref_001.png) over original names so imported images keep the clean package naming + // By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + const packageBasename = iamccsPathBasename(String(entry.package_path || "").replace(/\\/g, "/")); + const rawName = packageBasename || entry.filename || refFallback; + const filename = sanitizePackageComponent(rawName, refFallback); statusCallback?.(`Restoring packaged image ${index + 1}/${entries.length}...`); try { files.push(await fileFromPackageDataUrl(entry.data_url, filename, entry.content_type || "")); @@ -2280,45 +2441,64 @@ async function restorePackagedImagesToComfyInput(board, statusCallback = null) { } const uploaded = await uploadShotboardImages(files); const pathMap = {}; + const nameMap = {}; entries.forEach((entry, index) => { const uploadedPath = uploaded[index]; if (!uploadedPath) return; - for (const key of ["path", "comfy_input_path", "package_path", "original_path", "filename", "name", "original_name"]) { - const value = String(entry?.[key] || "").trim(); - if (value) pathMap[value] = uploadedPath; - } + const uploadedName = iamccsPathBasename(uploadedPath) || String(entry?.filename || entry?.original_name || entry?.name || "").trim(); + const mapAlias = (value) => { + const clean = String(value || "").trim(); + if (!clean) return; + pathMap[clean] = uploadedPath; + nameMap[clean] = uploadedName; + const normalized = clean.replace(/\\/g, "/").replace(/^\/+/, ""); + if (normalized) { + pathMap[normalized] = uploadedPath; + nameMap[normalized] = uploadedName; + } + const base = iamccsPathBasename(normalized); + if (base) { + pathMap[base] = uploadedPath; + nameMap[base] = uploadedName; + } + }; + for (const key of ["path", "comfy_input_path", "package_path", "original_path", "filename", "name", "original_name"]) mapAlias(entry?.[key]); + mapAlias(uploadedPath); const packagePath = String(entry.package_path || "").replace(/\\/g, "/").replace(/^\/+/, ""); - if (packageName && packagePath) pathMap[`${packageName}/${packagePath}`] = uploadedPath; + if (packageName && packagePath) mapAlias(`${packageName}/${packagePath}`); }); - return { paths: uploaded.filter(Boolean), pathMap }; + return { paths: uploaded.filter(Boolean), pathMap, nameMap }; } function rewriteReferencePathList(value, pathMap) { const paths = splitReferencePaths(value); if (!paths.length) return value; - return paths.map((path) => pathMap[path] || path); + return paths.map((path) => iamccsPathLookup(pathMap, path) || path); } -function rewriteBoardPackagedImagePaths(board, pathMap) { +function rewriteBoardPackagedImagePaths(board, pathMap, nameMap = {}) { if (!board || !pathMap || !Object.keys(pathMap).length) return board; + const originalReferencePaths = splitReferencePaths(board.image_paths); if (Object.prototype.hasOwnProperty.call(board, "image_paths")) { board.image_paths = rewriteReferencePathList(board.image_paths, pathMap); } - rewritePackagedSegments(board.segments, pathMap); + rewritePackagedSegments(board.segments, pathMap, {}, originalReferencePaths, nameMap); if (board.timeline && typeof board.timeline === "object") { + const timelineOriginalReferencePaths = splitReferencePaths(board.timeline.image_paths).length ? splitReferencePaths(board.timeline.image_paths) : originalReferencePaths; if (Object.prototype.hasOwnProperty.call(board.timeline, "image_paths")) { board.timeline.image_paths = rewriteReferencePathList(board.timeline.image_paths, pathMap); } - rewritePackagedSegments(board.timeline.segments, pathMap); + rewritePackagedSegments(board.timeline.segments, pathMap, {}, timelineOriginalReferencePaths, nameMap); } if (typeof board.timeline_data === "string" && board.timeline_data.trim()) { try { const parsed = JSON.parse(board.timeline_data); if (parsed && typeof parsed === "object") { + const parsedOriginalReferencePaths = splitReferencePaths(parsed.image_paths).length ? splitReferencePaths(parsed.image_paths) : originalReferencePaths; if (Object.prototype.hasOwnProperty.call(parsed, "image_paths")) { parsed.image_paths = rewriteReferencePathList(parsed.image_paths, pathMap); } - rewritePackagedSegments(parsed.segments, pathMap); + rewritePackagedSegments(parsed.segments, pathMap, {}, parsedOriginalReferencePaths, nameMap); board.timeline_data = JSON.stringify(parsed, null, 2); } } catch {} @@ -2329,7 +2509,7 @@ function rewriteBoardPackagedImagePaths(board, pathMap) { async function packagedReferencePathsForImport(board, statusCallback = null) { const restored = await restorePackagedImagesToComfyInput(board, statusCallback); if (restored.paths.length) { - rewriteBoardPackagedImagePaths(board, restored.pathMap); + rewriteBoardPackagedImagePaths(board, restored.pathMap, restored.nameMap || {}); return restored.paths; } const packageLocal = applyPackageLocalImageFallbacks(board); @@ -2591,7 +2771,7 @@ function openReferenceFrameEditor(node, index, path, onApply) { cancel.onclick = () => overlay.remove(); apply.onclick = async () => { apply.disabled = true; - status.textContent = "Saving new reference in IAMCCS_newimages..."; + status.textContent = "Saving edited frame as a new project reference..."; try { const data = await transformShotboardReference({ path, @@ -2606,7 +2786,22 @@ function openReferenceFrameEditor(node, index, path, onApply) { crop_box: previewCropBox, crop_box_source: previewCropBox ? "ui_preview" : "backend_formula", }); - const appliedPath = data?.path || data?.relative_path || data?.absolute_path || data?.filename || data?.name; + const appliedPath = data?.absolute_path || data?.path || data?.relative_path || data?.filename || data?.name; + console.info("[IAMCCS REF EDITOR] transform response", { + source: path, + appliedPath, + absolutePath: data?.absolute_path || "", + transform: data?.metadata?.transform || null, + }); + // By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + // Bug #6 fix: removed strict same-path throw β€” the backend saves a new _cineedit_ file, + // but if timestamps collide or the user re-edits the same slot, we should still apply. + if (!appliedPath) { + throw new Error("Reference editor: transform API returned no output path."); + } + if (String(appliedPath) === String(path)) { + console.warn("[IAMCCS REF EDITOR] output path equals source β€” applying anyway (same-edit idempotent)"); + } try { onApply?.(appliedPath, data); } catch (applyErr) { @@ -2969,13 +3164,14 @@ function attachVerticalStepDrag(element, getValue, applyValue, stepValue, option const maxValue = Number.isFinite(Number(options.max)) ? Number(options.max) : Infinity; const clamp = (value) => Math.min(maxValue, Math.max(minValue, Number(value) || 0)); element.dataset.iamccsStepDrag = "true"; - element.style.cursor = element.style.cursor || "ns-resize"; + element.style.cursor = element.style.cursor || "ew-resize"; element.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; if (event.detail > 1 || element.dataset.iamccsNumberEditing === "true") return; event.preventDefault(); event.stopPropagation(); - const startY = event.clientY; + const startX = Number(event.clientX || 0); + const startY = Number(event.clientY || 0); let stepsApplied = 0; let dragging = true; element.setPointerCapture?.(event.pointerId); @@ -2991,7 +3187,10 @@ function attachVerticalStepDrag(element, getValue, applyValue, stepValue, option if (!dragging || !(moveEvent.buttons & 1)) return; moveEvent.preventDefault(); moveEvent.stopPropagation(); - const nextSteps = Math.trunc((startY - moveEvent.clientY) / threshold); + const dx = Number(moveEvent.clientX || 0) - startX; + const dy = startY - Number(moveEvent.clientY || 0); + const dominantDelta = Math.abs(dx) >= Math.abs(dy) ? dx : dy; + const nextSteps = Math.trunc(dominantDelta / threshold); const deltaSteps = nextSteps - stepsApplied; if (!deltaSteps) return; stepsApplied = nextSteps; @@ -3002,7 +3201,7 @@ function attachVerticalStepDrag(element, getValue, applyValue, stepValue, option window.addEventListener("pointermove", move, { passive: false, capture: true }); window.addEventListener("pointerup", finish, { passive: false, capture: true }); window.addEventListener("pointercancel", finish, { passive: false, capture: true }); - }, { passive: false }); + }, { passive: false, capture: true }); } function numberStepperControl(value, step, min, max, onChange, options = {}) { @@ -3020,7 +3219,7 @@ function numberStepperControl(value, step, min, max, onChange, options = {}) { input.inputMode = "decimal"; input.readOnly = true; input.value = formatStepperValue(value, precision); - input.style.cssText = inputBase() + "height:30px;padding:4px 6px;text-align:center;font-variant-numeric:tabular-nums;cursor:ns-resize;"; + input.style.cssText = inputBase() + "height:30px;padding:4px 6px;text-align:center;font-variant-numeric:tabular-nums;cursor:ew-resize;"; const clamp = (raw) => Math.min(maxValue, Math.max(minValue, Number(raw))); const apply = (raw) => { @@ -3052,7 +3251,7 @@ function numberStepperControl(value, step, min, max, onChange, options = {}) { const setEditing = (editing) => { input.dataset.iamccsNumberEditing = editing ? "true" : "false"; input.readOnly = !editing; - input.style.cursor = editing ? "text" : "ns-resize"; + input.style.cursor = editing ? "text" : "ew-resize"; }; input.ondblclick = (event) => { event.preventDefault(); @@ -4721,8 +4920,20 @@ function renderShotboardPro(node) { }; stripeRail.append( makeRailButton("E", "Open frame editor: crop, pan, zoom and save a new reference", () => { - openReferenceFrameEditor(node, index, path, (newPath) => { - replaceReferencePathAt(node, index, newPath); + openReferenceFrameEditor(node, index, path, (newPath, data) => { + const appended = appendReferencePath(node, newPath); + const nextRef = Math.max(1, Number(appended?.refNumber || index + 1)); + refPreviewBusters.set(String(newPath), String(data?.cache_bust || Date.now())); + console.info("[IAMCCS REF STRIP EDIT] saved candidate reference only; timeline truth unchanged", { + oldRef: index + 1, + newRef: nextRef, + candidatePath: newPath, + timelineTruthUnchanged: true, + savedTo: data?.absolute_path || data?.path || "", + appended: Boolean(appended?.appended), + }); + showTimelineNotice(`Reference candidate saved as ref ${nextRef}. Timeline panels unchanged until edited/applied from the panel.`, "warn"); + writeTimeline({ force: true }); drawReferenceStrip(); draw(); }); @@ -5013,8 +5224,24 @@ function renderShotboardPro(node) { const applyBoard = async (data) => { const workflowBoard = boardFromWorkflowJson(data); const nestedBoard = data?.board && typeof data.board === "object" ? data.board : null; - const board = workflowBoard || nestedBoard || data || {}; + const rootLooksLikeBoard = Boolean( + data?.timeline || + data?.timeline_data || + data?.image_paths || + data?.package || + Array.isArray(data?.images) || + String(data?.metadata?.schema || data?.schema || "").includes("shotboard") + ); + const board = (rootLooksLikeBoard ? (nestedBoard || data) : null) || workflowBoard || nestedBoard || data || {}; if ((!Array.isArray(board.images) || !board.images.length) && Array.isArray(data?.images)) board.images = data.images; + console.log("[IAMCCS V3 BOARD IMPORT] board source selected", { + nodeId: node?.id, + source: rootLooksLikeBoard ? (nestedBoard ? "nested_package_board" : "root_package_board") : (workflowBoard ? "workflow_node" : "root_fallback"), + workflowNode: Boolean(workflowBoard), + hasPackage: Boolean(board?.package || data?.package), + images: Array.isArray(board?.images) ? board.images.length : 0, + imagePaths: splitReferencePaths(board?.image_paths).length, + }); const metadata = board.metadata || data?.metadata || {}; const settings = { ...(board.settings || {}) }; for (const name of settingNames) { @@ -5540,8 +5767,18 @@ function renderShotboardPro(node) { thumbWidth: rowThumbWidth, thumbHeight: rowThumbHeight, onEdit: shotboardV2 ? (referenceIndex, path) => { - openReferenceFrameEditor(node, referenceIndex, path, (newPath) => { - replaceReferencePathAt(node, referenceIndex, newPath); + openReferenceFrameEditor(node, referenceIndex, path, (newPath, data) => { + const appended = appendReferencePath(node, newPath); + const nextRef = Math.max(1, Number(appended?.refNumber || referenceIndex + 1)); + updateRow({ ref: nextRef }); + console.info("[IAMCCS ROW REF EDIT] applied edited reference", { + row: index, + oldRef: referenceIndex + 1, + newRef: nextRef, + path: newPath, + savedTo: data?.absolute_path || data?.path || "", + appended: Boolean(appended?.appended), + }); drawReferenceStrip(); draw(); }); @@ -6180,6 +6417,10 @@ function renderShotboardV3(node) { "scrollbar-gutter:stable", `box-shadow:inset 0 1px 0 ${chrome.glow},0 0 0 1px rgba(0,0,0,.45)`, "pointer-events:auto", + "contain:layout paint style", + "content-visibility:auto", + "contain-intrinsic-size:1920px 900px", + "transform:translateZ(0)", ].join(";"); const promptWidget = getWidget(node, "global_prompt"); @@ -6202,6 +6443,7 @@ function renderShotboardV3(node) { "frame_rate", "default_force", "promptrelay_epsilon", + "ltx_round_mode", "image_width", "image_height", "image_resize_method", @@ -6242,6 +6484,9 @@ function renderShotboardV3(node) { ? Math.max(0.5, storedTimelineMeter) : Math.max(0.5, Number(durationWidget?.value || 20) || 20); let selectedId = null; + let timelineExtraH = 0; // extra px added by user timeline-height resize drag + let _tlResizeDragStartY = null; // pointer Y at drag start + let _tlResizeDragStartExtra = 0; // timelineExtraH value at drag start let pendingImageInsertFrame = null; let pendingImageTargetId = null; let timelineNotice = null; @@ -6249,6 +6494,34 @@ function renderShotboardV3(node) { let lastDefaultForce = Math.max(0, Math.min(1, Number(defaultForceWidget?.value || 0.25))); let durationValueControl = null; + const positiveNumberOrNull = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + }; + const objectDurationTruth = (source) => { + if (!source || typeof source !== "object") return null; + const settings = source.settings && typeof source.settings === "object" ? source.settings : {}; + return positiveNumberOrNull( + source.duration_seconds ?? + source.durationSeconds ?? + source.duration ?? + settings.duration_seconds ?? + settings.durationSeconds ?? + settings.duration + ); + }; + const objectFpsTruth = (source) => { + if (!source || typeof source !== "object") return null; + const settings = source.settings && typeof source.settings === "object" ? source.settings : {}; + return positiveNumberOrNull( + source.frame_rate ?? + source.frameRate ?? + source.fps ?? + settings.frame_rate ?? + settings.frameRate ?? + settings.fps + ); + }; const getDuration = () => Math.max(0.1, Number(durationWidget?.value || 20)); const getFps = () => Math.max(1, Math.round(Number(fpsWidget?.value || 24))); const getTotalFrames = () => Math.max(1, Math.round(getDuration() * getFps())); @@ -6322,19 +6595,13 @@ function renderShotboardV3(node) { candidates.push(backupCandidate); } catch {} } - const selected = candidates.length - ? candidates.slice().sort((a, b) => { - const revisionDelta = Number(b.revision || 0) - Number(a.revision || 0); - if (revisionDelta) return revisionDelta; - if (a.source === "widget.timeline_data") return -1; - if (b.source === "widget.timeline_data") return 1; - return 0; - })[0] - : { source: "empty", value: {}, revision: 0 }; + // The visible workflow widget is the current board truth. + // The property backup is only a recovery fallback for empty/invalid widgets. + const selected = widgetCandidate || backupCandidate || { source: "empty", value: {}, revision: 0 }; const data = selected.value; console.log("[IAMCCS V3 TIMELINE LOAD]", { selected: selected.source, - truth: selected.source === "widget.timeline_data" ? "node.timeline_data" : selected.source === "properties.iamccs_v3_timeline_data_backup" ? "backup_revision_wins" : "empty", + truth: selected.source === "widget.timeline_data" ? "node.timeline_data_current_widget" : selected.source === "properties.iamccs_v3_timeline_data_backup" ? "backup_fallback_widget_empty_or_invalid" : "empty", revision: Number(selected.revision || 0), nodeId: node?.id, candidates: candidates.map((candidate) => ({ @@ -6354,23 +6621,28 @@ function renderShotboardV3(node) { schema: data.schema || "iamccs.cine.filmmaker_timeline", schema_version: Number(data.schema_version || 1), segments: Array.isArray(data.segments) ? data.segments : [], + rows: Array.isArray(data.rows) ? data.rows : [], audioSegments: Array.isArray(data.audioSegments) ? data.audioSegments : [], audioTrackCount: Math.max(1, Number(data.audioTrackCount || 2)), masterAudioGain: Math.max(0, Math.min(2, Number(data.masterAudioGain ?? data.master_audio_gain ?? 1) || 1)), masterAudioNormalize: Boolean(data.masterAudioNormalize || data.master_audio_normalize), + duration_seconds: objectDurationTruth(data), + frame_rate: objectFpsTruth(data), audioSyncMode: String(data.audioSyncMode || "timeline_audio"), generationStrategy: String(data.generationStrategy || "single_timeline"), flfrealMode: String(data.flfrealMode || data.flfreal_mode || "iamccs_enhanced"), globalPromptOnly: Boolean(data.global_prompt_only || data.use_global_prompt_only), verboseLog: data.verbose_log !== undefined ? Boolean(data.verbose_log) : data.verboseLog !== undefined ? Boolean(data.verboseLog) : true, + multiGeneration: data.multiGeneration && typeof data.multiGeneration === "object" ? data.multiGeneration : {}, truthRevision: candidateRevision(data), }; } } catch {} - return { schema: "iamccs.cine.filmmaker_timeline", schema_version: 1, segments: [], audioSegments: [], audioTrackCount: 2, audioSyncMode: "timeline_audio", generationStrategy: "single_timeline", flfrealMode: "iamccs_enhanced", globalPromptOnly: false, verboseLog: true }; + return { schema: "iamccs.cine.filmmaker_timeline", schema_version: 1, segments: [], rows: [], audioSegments: [], audioTrackCount: 2, duration_seconds: null, frame_rate: null, audioSyncMode: "timeline_audio", generationStrategy: "single_timeline", flfrealMode: "iamccs_enhanced", globalPromptOnly: false, verboseLog: true }; } let timeline = readTimeline(); + const refPreviewBusters = new Map(); const refPaths = () => getConnectedReferencePaths(node); const normalizeReferencePathKey = (value) => String(value || "").replace(/\\/g, "/").trim(); const referencePathBasename = (value) => normalizeReferencePathKey(value).split("/").pop(); @@ -6392,17 +6664,63 @@ function renderShotboardV3(node) { const nextPath = String(paths[nextRef - 1] || "").trim(); if (nextPath) { seg.imageFile = nextPath; + seg.path = nextPath; + seg.imageTruthPath = nextPath; + seg.imageTruthRef = nextRef; + seg.imageTruthPinned = options.pinned !== false; + seg.imageTruthSource = options.truthSource || "set_segment_reference"; delete seg.image_file; + delete seg.image_truth_path; if (options.updateAutoLabel !== false && /^ref_\d+$/i.test(String(seg.label || ""))) seg.label = `ref_${nextRef}`; } else { delete seg.imageFile; delete seg.image_file; + delete seg.path; + delete seg.imageTruthPath; + delete seg.image_truth_path; + delete seg.imageTruthRef; + delete seg.imageTruthPinned; } }; + const applyEditedReferenceTruth = (seg, refNumber, newPath, options = {}) => { + if (!seg || !isTimelineImageSegment(seg)) return false; + setSegmentReference(seg, refNumber, { ...options, pinned: true, truthSource: options.truthSource || "timeline_panel_edit" }); + const cleanPath = String(newPath || "").trim(); + if (cleanPath) { + seg.imageFile = cleanPath; + seg.path = cleanPath; + seg.imageTruthPath = cleanPath; + seg.imageTruthRef = Math.max(1, Math.round(Number(refNumber || seg.ref || 1))); + seg.imageTruthPinned = true; + seg.imageTruthSource = options.truthSource || "timeline_panel_edit"; + seg.imageTruthUpdatedAt = new Date().toISOString(); + delete seg.image_file; + delete seg.image_truth_path; + } + return true; + }; + const applyEditedReferenceToMatches = (oldRef, oldPath, newRef, newPath, options = {}) => { + const oldRefNumber = Math.max(1, Math.round(Number(oldRef || 1))); + const cleanOldPath = String(oldPath || "").trim(); + let count = 0; + for (const item of (timeline.segments || [])) { + if (!isTimelineImageSegment(item)) continue; + const itemPath = segmentReferencePath(item); + const refMatches = Math.round(Number(item.ref || 0)) === oldRefNumber; + const pathMatches = cleanOldPath && sameReferencePath(itemPath, cleanOldPath); + if (refMatches || pathMatches) { + if (applyEditedReferenceTruth(item, newRef, newPath, options)) count += 1; + } + } + return count; + }; + // Fix 2 (auto-fill): removed Math.max(1,...) clamping β€” ref:0 (new empty slots) now correctly returns "" instead of the first board image β€” By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com const segmentReferencePath = (seg) => { - const explicitPath = String(seg?.imageFile || seg?.image_file || "").trim(); + const truthPath = String(seg?.imageTruthPath || seg?.image_truth_path || "").trim(); + if (truthPath) return truthPath; + const explicitPath = String(seg?.imageFile || seg?.image_file || seg?.path || "").trim(); if (explicitPath) return explicitPath; - const refNumber = Math.max(1, Math.round(Number(seg?.ref || 0))); + const refNumber = Math.round(Number(seg?.ref || 0)); return refNumber >= 1 ? String(refPaths()[refNumber - 1] || "") : ""; }; const normalizeTimelineSegmentReferences = () => { @@ -6410,29 +6728,46 @@ function renderShotboardV3(node) { let changed = false; for (const seg of (timeline.segments || [])) { if (!isTimelineImageSegment(seg)) continue; - const explicitPath = String(seg.imageFile || seg.image_file || "").trim(); + const explicitPath = String(seg.imageTruthPath || seg.image_truth_path || seg.imageFile || seg.image_file || seg.path || "").trim(); if (explicitPath) { const refFromPath = referenceIndexForPath(explicitPath, paths); if (refFromPath && Math.round(Number(seg.ref || 0)) !== refFromPath) { - console.warn("[IAMCCS V3 REF SYNC] imageFile/ref mismatch fixed", { + console.warn("[IAMCCS V3 REF SYNC] image truth/ref mismatch fixed", { segmentId: seg.id, label: seg.label, oldRef: seg.ref, newRef: refFromPath, - imageFile: explicitPath, + imageTruthPath: explicitPath, oldRefPath: paths[Math.max(0, Math.round(Number(seg.ref || 1)) - 1)] || "", }); seg.ref = refFromPath; if (/^ref_\d+$/i.test(String(seg.label || ""))) seg.label = `ref_${refFromPath}`; changed = true; } + if (seg.imageFile !== explicitPath || seg.path !== explicitPath || seg.imageTruthPath !== explicitPath || !seg.imageTruthPinned) { + seg.imageFile = explicitPath; + seg.path = explicitPath; + seg.imageTruthPath = explicitPath; + seg.imageTruthRef = refFromPath || Math.max(1, Math.round(Number(seg.ref || 1))); + seg.imageTruthPinned = true; + seg.imageTruthSource = seg.imageTruthSource || "normalized_existing_segment"; + delete seg.image_file; + delete seg.image_truth_path; + changed = true; + } continue; } const refNumber = Math.max(1, Math.round(Number(seg.ref || 1))); const path = String(paths[refNumber - 1] || "").trim(); if (path) { seg.imageFile = path; + seg.path = path; + seg.imageTruthPath = path; + seg.imageTruthRef = refNumber; + seg.imageTruthPinned = true; + seg.imageTruthSource = seg.imageTruthSource || "normalized_ref_path"; delete seg.image_file; + delete seg.image_truth_path; changed = true; } } @@ -6449,12 +6784,30 @@ function renderShotboardV3(node) { timelineNotice.style.borderColor = tone === "error" ? purple.danger : purple.play; timelineNotice.style.color = tone === "error" ? "#FFE3DD" : "#FFF1BE"; }; - const setDurationSeconds = (seconds) => { + const setDurationSeconds = (seconds, reason = "manual") => { const next = Math.max(0.1, Number(seconds) || 0.1); if (durationWidget) durationWidget.value = next; setWidgetValue(node, "duration_seconds", next); + timeline.duration_seconds = next; + timelineMeterSeconds = clampTimelineMeterSeconds(timelineMeterSeconds); durationValueControl?._iamccsSetValue?.(next); + console.log("[IAMCCS V3 DURATION TRUTH]", { nodeId: node?.id, reason, duration_seconds: next, fps: getFps() }); }; + const setFrameRateValue = (fps, reason = "manual") => { + const next = Math.max(1, Math.round(Number(fps) || 24)); + if (fpsWidget) fpsWidget.value = next; + setWidgetValue(node, "frame_rate", next); + timeline.frame_rate = next; + timelineMeterSeconds = clampTimelineMeterSeconds(timelineMeterSeconds); + console.log("[IAMCCS V3 FPS TRUTH]", { nodeId: node?.id, reason, frame_rate: next, duration_seconds: getDuration() }); + }; + const syncTimingWidgetsFromTimelineTruth = (reason = "timeline_truth") => { + const durationTruth = objectDurationTruth(timeline); + const fpsTruth = objectFpsTruth(timeline); + if (durationTruth !== null) setDurationSeconds(durationTruth, reason); + if (fpsTruth !== null) setFrameRateValue(fpsTruth, reason); + }; + syncTimingWidgetsFromTimelineTruth("initial_timeline_load"); const enforceDurationMinimum = () => { const fps = getFps(); const minFrames = endOfVisualSegments(); @@ -6468,18 +6821,16 @@ function renderShotboardV3(node) { return false; } const minSeconds = Number((minFrames / fps).toFixed(3)); - setDurationSeconds(minSeconds); - showTimelineNotice(`Duration too short: minimum is ${minSeconds}s for the current image timeline. Shorten image blocks first.`); - return true; + showTimelineNotice(`Timeline content reaches ${minSeconds}s, but board duration remains ${getDuration().toFixed(3)}s. Change Duration explicitly or shorten/ripple-delete the extra space.`); + return false; }; const ensureDurationForFrames = (requiredFrames) => { const fps = getFps(); const need = Math.max(1, Math.round(Number(requiredFrames || 1))); if (Math.round(getDuration() * fps) >= need) return false; - const nextSeconds = Number((need / fps).toFixed(3)); - setDurationSeconds(nextSeconds); - showTimelineNotice(`Duration expanded to ${nextSeconds}s to fit the new timeline blocks.`); - return true; + const needSeconds = Number((need / fps).toFixed(3)); + showTimelineNotice(`Timeline content reaches ${needSeconds}s. Duration is locked to ${getDuration().toFixed(3)}s until you change Duration explicitly.`); + return false; }; const followsDefaultForce = (seg, previousDefault) => { if (!seg || String(seg.type || "image") === "text") return false; @@ -6511,9 +6862,13 @@ function renderShotboardV3(node) { seg.start = Math.max(0, Math.min(Math.round(Number(seg.start || 0)), Math.max(0, total - 1))); if (seg.start + seg.length > total) seg.length = Math.max(1, total - seg.start); if (String(seg.type || "image") !== "text" && String(seg.type || "image") !== "audio") { - const strength = clampGuideStrength(seg.guideStrength ?? seg.force ?? defaultForceWidget?.value ?? 0.25); + const strength = clampGuideStrength(seg.guideStrength ?? seg.guide_strength ?? seg.force ?? seg.strength ?? defaultForceWidget?.value ?? 0.25); seg.guideStrength = strength; - seg.imageLockStrength = clampGuideStrength(seg.imageLockStrength ?? strength, strength); + seg.guide_strength = strength; + seg.force = strength; + seg.strength = strength; + seg.imageLockStrength = strength; + seg.image_lock_strength = strength; if (seg.defaultForceSource !== undefined) seg.defaultForceSource = clampGuideStrength(seg.defaultForceSource, strength); } return seg; @@ -6537,6 +6892,25 @@ function renderShotboardV3(node) { let payload = null; try { payload = JSON.parse(String(timelineText || "{}")); } catch {} const cleanPayload = stripEmptyAudioForBoardExport(payload || timeline || {}); + if (Array.isArray(cleanPayload.segments)) { + for (const seg of cleanPayload.segments) { + if (!isTimelineImageSegment(seg)) continue; + const truth = String(seg.imageTruthPath || seg.image_truth_path || seg.imageFile || seg.image_file || seg.path || "").trim(); + if (!truth) continue; + seg.imageFile = truth; + seg.path = truth; + seg.imageTruthPath = truth; + seg.imageTruthRef = Math.max(1, Math.round(Number(seg.ref || seg.imageTruthRef || 1))); + seg.imageTruthPinned = true; + seg.imageTruthSource = seg.imageTruthSource || "board_export"; + delete seg.image_file; + delete seg.image_truth_path; + } + } + const settings = v3SettingsSnapshot(); + Object.entries(settings).forEach(([key, value]) => { + if (cleanPayload[key] === undefined || cleanPayload[key] === null || cleanPayload[key] === "") cleanPayload[key] = value; + }); return { payload: cleanPayload, text: JSON.stringify(cleanPayload, null, 2), @@ -6570,13 +6944,13 @@ function renderShotboardV3(node) { const segments = Array.isArray(payload?.segments) ? payload.segments : []; for (const seg of segments) { if (!isTimelineImageSegment(seg)) continue; - addPath(seg.imageFile || seg.image_file || seg.path || pathForRef(seg.ref)); + addPath(seg.imageTruthPath || seg.image_truth_path || seg.imageFile || seg.image_file || seg.path || pathForRef(seg.ref)); } if (activePaths.length) continue; const rows = Array.isArray(payload?.rows) ? payload.rows : []; for (const row of rows) { if (!row || row.use_guide === false || Number(row.force ?? row.strength ?? row.guideStrength ?? 0) <= 0) continue; - addPath(row.imageFile || row.image_file || row.path || pathForRef(row.ref)); + addPath(row.imageTruthPath || row.image_truth_path || row.imageFile || row.image_file || row.path || pathForRef(row.ref)); } } if (!activePaths.length) return compact; @@ -6585,12 +6959,18 @@ function renderShotboardV3(node) { if (!Array.isArray(segments)) return; for (const seg of segments) { if (!isTimelineImageSegment(seg)) continue; - const source = String(seg.imageFile || seg.image_file || seg.path || pathForRef(seg.ref) || "").trim(); + const source = String(seg.imageTruthPath || seg.image_truth_path || seg.imageFile || seg.image_file || seg.path || pathForRef(seg.ref) || "").trim(); const nextRef = refMap.get(source); if (!nextRef) continue; seg.ref = nextRef; seg.imageFile = source; + seg.path = source; + seg.imageTruthPath = source; + seg.imageTruthRef = nextRef; + seg.imageTruthPinned = true; + seg.imageTruthSource = seg.imageTruthSource || "package_export"; delete seg.image_file; + delete seg.image_truth_path; if (/^ref_\d+$/i.test(String(seg.label || ""))) seg.label = `ref_${nextRef}`; } }; @@ -6598,7 +6978,7 @@ function renderShotboardV3(node) { if (!Array.isArray(rows)) return; for (const row of rows) { if (!row || row.use_guide === false) continue; - const source = String(row.imageFile || row.image_file || row.path || pathForRef(row.ref) || "").trim(); + const source = String(row.imageTruthPath || row.image_truth_path || row.imageFile || row.image_file || row.path || pathForRef(row.ref) || "").trim(); const nextRef = refMap.get(source); if (nextRef) row.ref = nextRef; } @@ -6677,12 +7057,24 @@ function renderShotboardV3(node) { const segmentToRow = (seg, index) => { const fps = getFps(); const isText = String(seg.type || "image") === "text"; + const truthPath = isText ? "" : String(seg.imageTruthPath || seg.image_truth_path || seg.imageFile || seg.image_file || seg.path || "").trim(); + const singleStrength = isText ? 0 : Math.max(0, Math.min(1, Number(seg.guideStrength ?? seg.guide_strength ?? seg.force ?? seg.strength ?? defaultForceWidget?.value ?? 0.25))); return { second: Number((Number(seg.start || 0) / fps).toFixed(3)), frame: Math.round(Number(seg.start || 0)), ref: Math.max(1, Number(seg.ref || index + 1)), - force: isText ? 0 : Math.max(0, Math.min(1, Number(seg.guideStrength ?? defaultForceWidget?.value ?? 0.25))), - image_lock_strength: isText ? 0 : Math.max(0, Math.min(1, Number(seg.guideStrength ?? defaultForceWidget?.value ?? 0.25))), + imageFile: truthPath, + path: truthPath, + imageTruthPath: truthPath, + imageTruthRef: Math.max(1, Number(seg.imageTruthRef || seg.ref || index + 1)), + imageTruthPinned: !isText, + imageTruthSource: String(seg.imageTruthSource || "segment_to_row"), + force: singleStrength, + strength: singleStrength, + guide_strength: singleStrength, + guideStrength: singleStrength, + image_lock_strength: singleStrength, + imageLockStrength: singleStrength, label: String(seg.label || `${isText ? "text" : "shot"}_${index + 1}`), camera: String(seg.camera || "cinematic motion"), transition: String(seg.transition || "continuous_motion"), @@ -6703,9 +7095,9 @@ function renderShotboardV3(node) { step_transition_prompt: "", step_transition_easing: String(seg.step_transition_easing || "ease_in_out"), step_transition_force_curve: String(seg.step_transition_force_curve || "late_target"), - step_transition_duration: Math.max(0, Number(seg.step_transition_duration || 0) || 0), - step_transition_arrival: String(seg.step_transition_arrival || "auto"), - step_transition_auto_fit: seg.step_transition_auto_fit !== false, + step_transition_duration: 0, + step_transition_arrival: "auto", + step_transition_auto_fit: true, }; }; let pendingTimelineCommit = 0; @@ -6826,6 +7218,9 @@ function renderShotboardV3(node) { magnetize(); normalizeTimelineSegmentReferences(); const fps = getFps(); + const effectiveDurationSeconds = getDuration(); + timeline.duration_seconds = effectiveDurationSeconds; + timeline.frame_rate = fps; const rows = timeline.segments.filter((seg) => !seg.placeholder).map(segmentToRow); const promptRelayEnabled = rows.some((row) => { const hasPrompt = row.use_prompt && String(row.relay_prompt || "").trim(); @@ -6885,6 +7280,34 @@ function renderShotboardV3(node) { const effectiveDirectorPrompts = globalPromptOnly ? [] : directorPrompts; const effectiveDirectorLengths = globalPromptOnly ? [] : directorLengths; const effectivePromptRelayEnabled = globalPromptOnly ? false : promptRelayEnabled; + timeline.rows = rows; + const timelineRows = rows; + const multiGeneration = timeline.multiGeneration && typeof timeline.multiGeneration === "object" + ? JSON.parse(JSON.stringify(timeline.multiGeneration)) + : {}; + if (multiGeneration.activeTimelineId) { + const activeTimelineId = String(multiGeneration.activeTimelineId || "T01"); + multiGeneration.visualTimelines = multiGeneration.visualTimelines && typeof multiGeneration.visualTimelines === "object" + ? multiGeneration.visualTimelines + : {}; + multiGeneration.visualTimelines[activeTimelineId] = { + schema: "iamccs.multigeneration.visual_timeline", + schema_version: 1, + timeline_id: activeTimelineId, + saved_at: new Date().toISOString(), + duration_seconds: effectiveDurationSeconds, + frame_rate: fps, + image_paths: refPaths(), + segments: JSON.parse(JSON.stringify(timeline.segments || [])), + rows: JSON.parse(JSON.stringify(timelineRows)), + director_local_prompts: effectiveDirectorPrompts.join(" | "), + director_segment_lengths: effectiveDirectorLengths.join(","), + director_guide_strength: guideStrength, + local_prompts: effectiveDirectorPrompts.join(" | "), + segment_lengths: effectiveDirectorLengths.join(","), + guide_strength: guideStrength, + }; + } node.properties = node.properties || {}; const nextTruthRevision = Math.max( Number(node.properties.iamccs_v3_timeline_revision || 0), @@ -6916,7 +7339,7 @@ function renderShotboardV3(node) { segment_lengths: effectiveDirectorLengths.join(","), guide_strength: guideStrength, audio_data: audioData, - duration_seconds: getDuration(), + duration_seconds: effectiveDurationSeconds, frame_rate: fps, image_paths: refPaths(), image_width: Number(imageWidthWidget?.value || 768), @@ -6925,12 +7348,18 @@ function renderShotboardV3(node) { image_multiple_of: Number(getWidget(node, "image_multiple_of")?.value || 32), promptrelay_epsilon: Number(getWidget(node, "promptrelay_epsilon")?.value || 0.65), img_compression: Number(getWidget(node, "img_compression")?.value || 0), + default_force: Number(defaultForceWidget?.value || 0.25), + guide_policy: String(getWidget(node, "guide_policy")?.value || "every_checked_row"), + min_guide_gap_seconds: Number(getWidget(node, "min_guide_gap_seconds")?.value || 0), + max_guides: Number(getWidget(node, "max_guides")?.value || 50), + ltx_round_mode: String(getWidget(node, "ltx_round_mode")?.value || "up_8n_plus_1"), audioTrackCount: Math.max(1, Math.round(Number(timeline.audioTrackCount || 2))), masterAudioGain: Math.max(0, Math.min(2, Number(timeline.masterAudioGain ?? 1) || 1)), masterAudioNormalize: Boolean(timeline.masterAudioNormalize), segments: timeline.segments, audioSegments: timeline.audioSegments, - rows, + rows: timelineRows, + multiGeneration, }; node.properties.iamccs_v3_timeline_revision = nextTruthRevision; node.properties.iamccs_v3_timeline_updated_at = truthUpdatedAt; @@ -7014,6 +7443,192 @@ function renderShotboardV3(node) { }; return btn; }; + const cloneForMultiTimeline = (value, fallback) => { + try { + return JSON.parse(JSON.stringify(value ?? fallback)); + } catch { + return fallback; + } + }; + const multiTimelineId = (takeIndex) => `T${String(Math.max(1, Math.round(Number(takeIndex) || 1))).padStart(2, "0")}`; + const multiTimelineTakeFromId = (timelineId) => Math.max(1, Math.round(Number(String(timelineId || "T01").replace(/\D/g, "") || 1))); + const multiTargetDurationSeconds = () => { + const multi = timeline.multiGeneration && typeof timeline.multiGeneration === "object" ? timeline.multiGeneration : {}; + const chunk = Math.max(0, Number(multi.chunkSeconds || 0)); + return chunk > 0 ? chunk : getDuration(); + }; + const findMultiTimelineBridge = () => { + const nodes = Array.isArray(app?.graph?._nodes) ? app.graph._nodes : []; + return nodes.find((candidate) => { + const type = String(candidate?.type || candidate?.comfyClass || candidate?.constructor?.type || ""); + return type === "IAMCCS_MultiTimelineBridge" || type.includes("MultiTimelineBridge"); + }); + }; + const setBridgeTake = (takeIndex) => { + const bridge = findMultiTimelineBridge(); + if (!bridge) return; + const widget = (bridge.widgets || []).find((item) => item?.name === "active_take"); + if (!widget) return; + const take = Math.max(1, Math.round(Number(takeIndex) || 1)); + widget.value = take; + try { widget.callback?.(take); } catch {} + try { + window.dispatchEvent(new CustomEvent("iamccs:multigeneration-active-take", { + detail: { nodeId: bridge.id, activeTake: take }, + })); + } catch {} + }; + const snapshotCurrentVisualTimeline = (timelineId) => ({ + schema: "iamccs.multigeneration.visual_timeline", + schema_version: 1, + timeline_id: String(timelineId || "T01"), + saved_at: new Date().toISOString(), + duration_seconds: getDuration(), + frame_rate: getFps(), + image_paths: refPaths(), + segments: cloneForMultiTimeline(timeline.segments || [], []), + rows: cloneForMultiTimeline(timeline.rows || [], []), + guide_strength: Number(defaultForceWidget?.value || 0.3), + }); + const defaultVisualTimeline = (timelineId) => ({ + schema: "iamccs.multigeneration.visual_timeline", + schema_version: 1, + timeline_id: String(timelineId || "T01"), + created_at: new Date().toISOString(), + duration_seconds: multiTargetDurationSeconds(), + frame_rate: getFps(), + image_paths: [], + segments: [], + rows: [], + guide_strength: Number(defaultForceWidget?.value || 0.3), + }); + const multiAudioSegmentsForTake = (multi, takeIndex) => { + const take = Math.max(1, Math.round(Number(takeIndex) || 1)); + const timelineId = multiTimelineId(take); + const normalizeMultiTimelineId = (value, fallbackTake = take) => { + const raw = String(value || "").trim(); + const normalizedTake = Math.max(1, Math.round(Number(raw.replace(/\D/g, "")) || Number(fallbackTake) || 1)); + return multiTimelineId(normalizedTake); + }; + const all = Array.isArray(multi?.audioSegmentsAll) + ? multi.audioSegmentsAll + : Array.isArray(multi?.allAudioSegments) + ? multi.allAudioSegments + : []; + if (!all.length) return []; + const matches = all.filter((seg) => { + const segTake = Math.max(0, Math.round(Number(seg?.multiTakeIndex || 0))); + const rawTimelineId = String(seg?.timelineId || "").trim(); + const segTimelineId = rawTimelineId ? normalizeMultiTimelineId(rawTimelineId, segTake || take) : ""; + const isMulti = Boolean(seg?.multiGenerationClip) || /^T\d+/i.test(rawTimelineId) || segTake > 0; + if (!isMulti) return false; + return segTimelineId === timelineId || segTake === take; + }); + return matches.map((seg) => { + const localStart = Number(seg?.localStart); + const next = cloneForMultiTimeline(seg, {}); + next.track = 0; + next.start = Number.isFinite(localStart) + ? Math.max(0, Math.round(localStart)) + : 0; + next.timelineId = timelineId; + next.multiTakeIndex = take; + next.shotboardActiveTakeAudio = true; + next.sourceTrackOriginal = Math.max(0, Math.round(Number(seg?.track || seg?.sourceTrackOriginal || 0))); + return next; + }); + }; + const applyMultiAudioForTake = (multi, takeIndex) => { + const sourceAll = Array.isArray(multi?.audioSegmentsAll) || Array.isArray(multi?.allAudioSegments); + if (!sourceAll) return false; + timeline.audioSegments = multiAudioSegmentsForTake(multi, takeIndex); + timeline.audioTrackCount = 1; + timeline.audioBusMode = "shotboard_only_first"; + timeline.onlyFirstTrack = true; + return true; + }; + const switchMultiTimeline = (takeIndex) => { + const take = Math.max(1, Math.round(Number(takeIndex) || 1)); + const nextId = multiTimelineId(take); + const multi = timeline.multiGeneration && typeof timeline.multiGeneration === "object" + ? cloneForMultiTimeline(timeline.multiGeneration, {}) + : {}; + const currentId = String(multi.activeTimelineId || multiTimelineId(multi.activeTake || 1)); + const visualTimelines = multi.visualTimelines && typeof multi.visualTimelines === "object" + ? cloneForMultiTimeline(multi.visualTimelines, {}) + : {}; + visualTimelines[currentId] = snapshotCurrentVisualTimeline(currentId); + const selected = visualTimelines[nextId] || defaultVisualTimeline(nextId); + visualTimelines[nextId] = selected; + timeline.segments = cloneForMultiTimeline(selected.segments || [], []); + timeline.rows = cloneForMultiTimeline(selected.rows || [], []); + const selectedDuration = Math.max(0, Number(selected.duration_seconds || 0)) || multiTargetDurationSeconds(); + if (durationWidget && selectedDuration > 0) durationWidget.value = Number(selectedDuration.toFixed(3)); + if (fpsWidget && Number(selected.frame_rate) > 0) fpsWidget.value = Number(selected.frame_rate); + timeline.generationStrategy = "multigeneration_manual_take"; + const nextMultiGeneration = { + ...multi, + enabled: true, + activeTake: take, + activeTimelineId: nextId, + timelineIds: Array.from(new Set([ + ...(Array.isArray(multi.timelineIds) ? multi.timelineIds : []), + ...Object.keys(visualTimelines), + nextId, + ])).sort(), + visualTimelines, + updatedAt: new Date().toISOString(), + }; + applyMultiAudioForTake(nextMultiGeneration, take); + timeline.multiGeneration = nextMultiGeneration; + setBridgeTake(take); + writeTimeline({ force: true }); + showTimelineNotice(`Loaded ${nextId}. Visual boxes are independent for this generation.`, "info"); + draw(); + }; + const makeMultiTimelineControl = () => { + const multi = timeline.multiGeneration && typeof timeline.multiGeneration === "object" ? timeline.multiGeneration : {}; + const bridge = findMultiTimelineBridge(); + const maxWidget = (bridge?.widgets || []).find((item) => item?.name === "max_takes"); + const fixedWidget = (bridge?.widgets || []).find((item) => item?.name === "fixed_take_count"); + const known = Math.max( + Array.isArray(multi.timelineIds) ? multi.timelineIds.length : 0, + multi.visualTimelines && typeof multi.visualTimelines === "object" ? Object.keys(multi.visualTimelines).length : 0, + 0 + ); + const maxTakes = Math.max(2, Math.min(12, Math.round(Number(maxWidget?.value || fixedWidget?.value || known || 5)))); + const activeTake = Math.max(1, Math.min(maxTakes, Math.round(Number(multi.activeTake || multiTimelineTakeFromId(multi.activeTimelineId) || 1)))); + const wrap = document.createElement("div"); + wrap.title = "Switch real Shotboard visual timelines for staged multigeneration."; + wrap.style.cssText = `display:flex;align-items:center;gap:4px;height:28px;padding:0 5px;border:1px solid ${purple.border};border-radius:5px;background:${purple.button};`; + const label = document.createElement("span"); + label.textContent = "MULTI"; + label.style.cssText = `color:#fff1ba;font-size:9px;font-weight:950;`; + const select = document.createElement("select"); + select.style.cssText = `height:22px;min-width:112px;border:1px solid ${purple.border};border-radius:4px;background:${purple.valueBg};color:${purple.valueText};font-size:10px;font-weight:900;`; + for (let i = 1; i <= maxTakes; i += 1) { + const id = multiTimelineId(i); + const option = document.createElement("option"); + option.value = String(i); + option.textContent = `${id} / gen ${i}`; + select.appendChild(option); + } + select.value = String(activeTake); + select.onchange = (event) => { + event.preventDefault(); + event.stopPropagation(); + switchMultiTimeline(Number(select.value)); + }; + wrap.append(label, select); + if (String(multi.durationWarning || "").trim()) { + const warn = document.createElement("span"); + warn.textContent = "DURATION"; + warn.title = String(multi.durationWarning || ""); + warn.style.cssText = "color:#ffdf8a;font-size:8px;font-weight:950;border-left:1px solid rgba(255,255,255,.16);padding-left:5px;"; + wrap.appendChild(warn); + } + return protectControlDrag(wrap); + }; const setFlfrealMode = (value) => { timeline.flfrealMode = ["flfreal_parity", "iamccs_enhanced"].includes(String(value)) ? String(value) : "iamccs_enhanced"; writeTimeline({ force: true }); @@ -7083,6 +7698,7 @@ function renderShotboardV3(node) { const timelineMeterReadout = document.createElement("span"); timelineMeterReadout.style.cssText = `min-width:50px;text-align:center;color:${purple.muted};font-size:9px;font-weight:900;`; timelineMeterWrap.append(timelineMeterButton("-", -0.5), timelineMeterReadout, timelineMeterButton("+", 0.5)); + const multiTimelineControl = makeMultiTimelineControl(); const addImageBtn = makeBtn("Add Image", "blue"); const addTextBtn = makeBtn("Add Text", "violet"); const addAudioBtn = makeBtn("Add Audio", "olive"); @@ -7093,7 +7709,7 @@ function renderShotboardV3(node) { const saveBtn = makeBtn("Save Board", "green"); const savePackageBtn = makeBtn("Save Package", "gold"); const clearBtn = makeBtn("Clear Board", "danger"); - topActions.append(logBtn, globalOnlyBtn, promptSizeWrap, timelineMeterWrap, addImageBtn, addTextBtn, addAudioBtn, addTrackBtn, collapseBtn, openEditorBtn, importBoardBtn, saveBtn, savePackageBtn, clearBtn); + topActions.append(logBtn, globalOnlyBtn, multiTimelineControl, promptSizeWrap, timelineMeterWrap, addImageBtn, addTextBtn, addAudioBtn, addTrackBtn, collapseBtn, openEditorBtn, importBoardBtn, saveBtn, savePackageBtn, clearBtn); head.append(title, topActions); root.addEventListener("iamccs:cine-fullscreen", (event) => { openEditorBtn.textContent = event.detail?.open ? "Close Editor" : "Open Editor"; @@ -7130,8 +7746,12 @@ function renderShotboardV3(node) { const nextValue = name === "default_force" ? clampGuideStrength(value) : value; if (name === "default_force") applyDefaultForceToLinkedSegments(nextValue); setWidgetValue(node, name, nextValue); - if (name === "duration_seconds" && enforceDurationMinimum()) { - ctrl._iamccsSetValue?.(durationWidget?.value); + if (name === "duration_seconds") { + setDurationSeconds(nextValue, "duration_control"); + enforceDurationMinimum(); + } + if (name === "frame_rate") { + setFrameRateValue(nextValue, "fps_control"); } writeTimeline(); draw(); @@ -7232,14 +7852,25 @@ function renderShotboardV3(node) { ruler.style.cssText = `height:36px;position:relative;border:1px solid ${purple.border};border-bottom:0;background:linear-gradient(180deg,#3D3A36 0%,#2D2C2A 58%,#242423 100%);border-radius:6px 6px 0 0;overflow:hidden;box-shadow:inset 0 1px 0 rgba(255,255,255,.10);`; const timelineBox = document.createElement("div"); timelineBox.title = "Double click in the image timeline to import a reference at that frame."; - timelineBox.style.cssText = `position:relative;height:328px;border:1px solid ${purple.border};background:#242220;overflow:hidden;border-radius:0 0 6px 6px;margin-bottom:6px;box-shadow:inset 0 0 0 1px rgba(216,155,69,.08);`; + timelineBox.style.cssText = `position:relative;height:344px;border:1px solid ${purple.border};background:#242220;overflow:hidden;border-radius:0 0 6px 6px;margin-bottom:6px;box-shadow:inset 0 0 0 1px rgba(216,155,69,.08);`; const imageTrack = document.createElement("div"); - imageTrack.style.cssText = `position:absolute;left:0;right:0;top:0;height:238px;border-bottom:1px solid ${purple.borderSoft};background:linear-gradient(180deg,rgba(85,184,178,.14),rgba(36,34,32,.16));`; + // imageTrack fills full width; endEdge marker overlays the last 4px so there is no dark gap at the right β€” By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + imageTrack.style.cssText = `position:absolute;left:0;right:0;top:0;height:254px;border-bottom:1px solid ${purple.borderSoft};background:linear-gradient(180deg,rgba(85,184,178,.14),rgba(36,34,32,.16));`; const actionTrack = document.createElement("div"); actionTrack.style.cssText = "display:none;"; const audioTracks = document.createElement("div"); - audioTracks.style.cssText = "position:absolute;left:0;right:0;top:238px;bottom:0;"; + // audioTracks also fills full width matching imageTrack β€” By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + audioTracks.style.cssText = "position:absolute;left:0;right:0;top:254px;bottom:0;"; timelineBox.append(imageTrack, audioTracks); + // Timeline start/end edge markers β€” visible boundaries showing where the timeline begins and ends + // By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + const timelineStartEdge = document.createElement("div"); + timelineStartEdge.title = "Timeline start (frame 0)"; + timelineStartEdge.style.cssText = "position:absolute;left:0;top:0;bottom:0;width:4px;background:linear-gradient(180deg,#40DCCE,rgba(64,220,206,.45));pointer-events:none;z-index:25;border-radius:2px 0 0 2px;box-shadow:2px 0 10px rgba(64,220,206,.5),0 0 0 1px rgba(64,220,206,.22);"; + const timelineEndEdge = document.createElement("div"); + timelineEndEdge.title = "Timeline end"; + timelineEndEdge.style.cssText = "position:absolute;right:0;top:0;bottom:0;width:4px;background:linear-gradient(180deg,#E09040,rgba(224,144,64,.45));pointer-events:none;z-index:25;border-radius:0 2px 2px 0;box-shadow:-2px 0 10px rgba(224,144,64,.5),0 0 0 1px rgba(224,144,64,.22);"; + timelineBox.append(timelineStartEdge, timelineEndEdge); const playbar = document.createElement("div"); playbar.style.cssText = `display:flex;align-items:center;gap:9px;margin-bottom:8px;padding:8px 9px;border:1px solid ${purple.border};background:linear-gradient(180deg,#3B3834 0%,#2B2926 100%);border-radius:7px;box-shadow:inset 0 1px 0 rgba(255,255,255,.10), inset 0 -10px 18px rgba(0,0,0,.18);`; const playBtn = makeBtn("Play"); @@ -7329,7 +7960,41 @@ function renderShotboardV3(node) { playbar.append(scrubStyle, playBtn, loopBtn, timeReadout, audioPlaybarControls, scrub); timelineCanvas.append(ruler, timelineBox); timelineViewport.appendChild(timelineCanvas); - root.append(timelineViewport, playbar); + // Timeline height resize handle β€” drag to expand/shrink timeline rows (slots + local prompts) + // By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + const timelineResizeHandle = document.createElement("div"); + timelineResizeHandle.title = "Drag up/down to resize the timeline height (expands slots and local prompts)"; + timelineResizeHandle.style.cssText = [ + "display:flex", "align-items:center", "justify-content:center", + "height:10px", "margin:0 0 4px 0", + `border:1px solid ${purple.borderSoft}`, + "background:linear-gradient(180deg,rgba(108,101,90,.35),rgba(50,46,42,.35))", + "cursor:row-resize", "user-select:none", "box-sizing:border-box", + `color:${purple.muted}`, "font-size:9px", "letter-spacing:4px", "flex-shrink:0", + ].join(";"); + timelineResizeHandle.innerHTML = `•••••`; + timelineResizeHandle.addEventListener("pointerdown", (e) => { + e.preventDefault(); e.stopPropagation(); + _tlResizeDragStartY = e.clientY; + _tlResizeDragStartExtra = Math.max(0, Number(node.properties?.iamccs_v3_timeline_extra_height || 0)); + timelineResizeHandle.setPointerCapture(e.pointerId); + }); + timelineResizeHandle.addEventListener("pointermove", (e) => { + if (_tlResizeDragStartY === null) return; + e.preventDefault(); + const delta = e.clientY - _tlResizeDragStartY; + const newExtra = Math.max(0, Math.min(600, Math.round(_tlResizeDragStartExtra + delta))); + node.properties = node.properties || {}; + node.properties.iamccs_v3_timeline_extra_height = newExtra; + draw(); + }); + timelineResizeHandle.addEventListener("pointerup", () => { + if (_tlResizeDragStartY === null) return; + _tlResizeDragStartY = null; + writeTimeline({ force: true }); + }); + timelineResizeHandle.addEventListener("pointercancel", () => { _tlResizeDragStartY = null; }); + root.append(timelineViewport, timelineResizeHandle, playbar); const inspector = document.createElement("div"); inspector.style.cssText = `display:${collapsed ? "none" : "block"};`; @@ -7967,6 +8632,48 @@ function renderShotboardV3(node) { draw(); } + + function splitImageSlotAfterSegment(seg) { + if (!seg) return; + const sorted = (timeline.segments || []).slice().sort((a, b) => Number(a.start || 0) - Number(b.start || 0)); + const index = sorted.findIndex((item) => item.id === seg.id); + if (index < 0) return; + const source = sorted[index]; + const oldLength = Math.max(1, Math.round(Number(source.length || defaultLen()))); + let slotLength = Math.max(1, Math.floor(oldLength / 2)); + if (oldLength > 1) { + source.length = Math.max(1, oldLength - slotLength); + } else { + slotLength = 1; + for (let i = index + 1; i < sorted.length; i += 1) sorted[i].start = Math.round(Number(sorted[i].start || 0) + 1); + ensureDurationForFrames(endOfSegments(sorted) + 1); + } + const slot = { + id: newId("slot"), + type: "image", + placeholder: true, + start: Math.round(Number(source.start || 0) + Number(source.length || 1)), + length: slotLength, + ref: 0, + label: "empty_slot", + prompt: "", + note: "", + camera: "cut to", + transition: "hard_cut", + guideStrength: Number(defaultForceWidget?.value || 0.25), + imageLockStrength: Number(defaultForceWidget?.value || 0.25), + defaultForceSource: Number(defaultForceWidget?.value || 0.25), + forceCustom: false, + use_guide: false, + use_prompt: false, + }; + sorted.splice(index + 1, 0, slot); + timeline.segments = sorted; + selectedId = slot.id; + writeTimeline({ force: true }); + draw(); + } + function renderAudioWaveform(block, seg) { const peaks = Array.isArray(seg.waveformPeaks) ? seg.waveformPeaks : []; const shell = document.createElement("div"); @@ -8024,9 +8731,18 @@ function renderShotboardV3(node) { const count = 96; const top = []; const bottom = []; + const peakValue = (raw) => { + if (raw && typeof raw === "object") { + const min = Math.abs(Number(raw.min) || 0); + const max = Math.abs(Number(raw.max) || 0); + const rms = Math.abs(Number(raw.rms) || 0); + return Math.max(min, max, rms); + } + return Math.abs(Number(raw) || 0); + }; for (let i = 0; i < count; i += 1) { const idx = Math.min(peaks.length - 1, Math.floor((i / Math.max(1, count - 1)) * peaks.length)); - const peak = Math.max(0.04, Math.min(1, Number(peaks[idx] || 0))); + const peak = Math.max(0.04, Math.min(1, peakValue(peaks[idx]))); const x = (i / Math.max(1, count - 1)) * 240; const amp = 4 + peak * 22; top.push(`${x.toFixed(2)} ${(32 - amp).toFixed(2)}`); @@ -8056,6 +8772,9 @@ function renderShotboardV3(node) { function openAppendImagePicker(targetId = null) { pendingImageTargetId = targetId; pendingImageInsertFrame = null; + // By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + // Bug #5 fix: reset fileInput before triggering click so re-selecting the same file fires onchange. + try { fileInput.value = ""; } catch {} fileInput.click(); } @@ -8188,6 +8907,41 @@ function renderShotboardV3(node) { draw(); return; } + // By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + // Bug #1 fix: when the source segment is the last one (no room to append after it), + // split it in half so the new slot is always visible on the timeline. + const total = getTotalFrames(); + if (Math.round(start) >= total) { + const oldLength = Math.max(2, Math.round(Number(source.length || defaultLen()))); + const splitLen = Math.max(1, Math.floor(oldLength / 2)); + source.length = Math.max(1, oldLength - splitLen); + const splitStart = Math.round(Number(source.start || 0) + Number(source.length || 1)); + const placeholder = kind === "text" ? textRelaySegment(splitStart, splitLen) : { + id: newId("slot"), + type: "image", + placeholder: true, + start: splitStart, + length: splitLen, + ref: 0, + label: "empty_slot", + prompt: "", + note: "", + camera: "continuous dolly-in", + transition: "continuous_motion", + guideStrength: Number(defaultForceWidget?.value || 0.25), + imageLockStrength: Number(defaultForceWidget?.value || 0.25), + defaultForceSource: Number(defaultForceWidget?.value || 0.25), + forceCustom: false, + use_guide: false, + use_prompt: false, + }; + sorted.splice(index + 1, 0, placeholder); + timeline.segments = sorted; + selectedId = placeholder.id; + writeTimeline({ force: true }); + draw(); + return; + } const length = defaultLen(); ensureDurationForFrames(endOfSegments(sorted) + length); for (let i = index + 1; i < sorted.length; i += 1) { @@ -8273,7 +9027,7 @@ function renderShotboardV3(node) { else createTailTextPlaceholder(); }); addChoice("Image Slot", "Create or fill an image guide slot", () => { - if (seg) createPlaceholderAfterSegment(seg, "image"); + if (seg) splitImageSlotAfterSegment(seg); else openAppendImagePicker(targetId); }); addChoice("Audio", "Import audio at this timeline position", () => { @@ -8309,7 +9063,7 @@ function renderShotboardV3(node) { `left:${left}%`, `width:${normalWidth}%`, "top:8px", - "height:222px", + "height:238px", "box-sizing:border-box", `border:1px dashed ${purple.border}`, "border-radius:4px", @@ -8356,13 +9110,14 @@ function renderShotboardV3(node) { const left = (Number(seg.start || 0) / total) * 100; const width = Math.max(1, (Number(seg.length || 1) / total) * 100); const top = isAudio ? (Number(seg.track || 0) * 45 + 4) : 8; - const frameShellHeight = 128; - const height = isAudio ? 36 : 222; + const truthRailHeight = isAudio ? 0 : 16; + const frameShellHeight = isAudio ? 128 : 144; + const height = isAudio ? 36 : 238 + timelineExtraH; // grows with user timeline resize const imageHeight = 108; - const transitionLaneTop = 108; + const transitionLaneTop = truthRailHeight + imageHeight; const transitionLaneHeight = 20; const promptTop = frameShellHeight + 10; - const promptHeight = 74; + const promptHeight = isAudio ? 0 : 74 + timelineExtraH; // local prompt textarea grows with timeline height const selected = selectedId === seg.id; const showDragStripes = Boolean(dragState && !isAudio && dragState.targetId === seg.id && dragState.kind !== "center"); const color = isAudio ? purple.audio : (String(seg.type) === "text" ? purple.textBlock : purple.image2); @@ -8374,25 +9129,25 @@ function renderShotboardV3(node) { `top:${top}px`, `height:${height}px`, `background:${color}`, - `border:1px solid ${selected ? purple.accent : purple.borderSoft}`, + (selected ? "border:2px solid #F9C859" : `border:1px solid ${purple.borderSoft}`), "border-radius:4px", "box-sizing:border-box", - "overflow:hidden", + "overflow:visible", "cursor:grab", - "box-shadow:0 6px 16px rgba(0,0,0,.38), inset 0 1px 0 rgba(255,190,120,.08)", + (selected ? "box-shadow:0 0 0 2px rgba(249,200,89,.35),0 6px 16px rgba(0,0,0,.38),inset 0 1px 0 rgba(255,190,120,.08)" : "box-shadow:0 6px 16px rgba(0,0,0,.38),inset 0 1px 0 rgba(255,190,120,.08)"), "user-select:none", `z-index:${isAudio ? 14 : 4}`, ].join(";"); const content = document.createElement("div"); content.style.cssText = isAudio ? "position:absolute;left:8px;right:8px;bottom:2px;height:14px;display:flex;align-items:center;justify-content:center;text-align:center;color:#FFF2E4;font-size:10px;font-weight:800;text-shadow:0 1px 2px rgba(0,0,0,.75);padding:0;box-sizing:border-box;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" - : `position:absolute;left:24px;right:0;top:0;height:${imageHeight}px;display:flex;align-items:center;justify-content:center;text-align:center;color:#fff;font-size:11px;font-weight:800;text-shadow:0 1px 2px rgba(0,0,0,.75);padding:8px;box-sizing:border-box;`; + : `position:absolute;left:24px;right:0;top:${truthRailHeight}px;height:${imageHeight}px;display:flex;align-items:center;justify-content:center;text-align:center;color:#fff;font-size:11px;font-weight:800;text-shadow:0 1px 2px rgba(0,0,0,.75);padding:8px;box-sizing:border-box;`; const appendAddAfterButton = () => { const addAfter = document.createElement("button"); addAfter.type = "button"; addAfter.textContent = "+"; addAfter.title = "Add text, image or audio after this slot"; - addAfter.style.cssText = `position:absolute;right:14px;top:8px;width:24px;height:24px;border:1px solid ${purple.border};border-radius:999px;background:${purple.valueBg};color:${purple.valueText};font-size:17px;font-weight:900;line-height:1;cursor:pointer;box-shadow:0 3px 10px rgba(0,0,0,.45);z-index:9;`; + addAfter.style.cssText = `position:absolute;right:14px;top:${truthRailHeight + 8}px;width:24px;height:24px;border:1px solid ${purple.border};border-radius:999px;background:${purple.valueBg};color:${purple.valueText};font-size:17px;font-weight:900;line-height:1;cursor:pointer;box-shadow:0 3px 10px rgba(0,0,0,.45);z-index:9;`; addAfter.onpointerdown = (event) => { event.preventDefault(); event.stopPropagation(); }; addAfter.onclick = (event) => { event.preventDefault(); @@ -8404,29 +9159,24 @@ function renderShotboardV3(node) { if (!isAudio && String(seg.type || "image") !== "text") { const path = segmentReferencePath(seg); if (path) { - const previewUrl = previewUrlForPath(path); - if (showDragStripes) { - const stripe = document.createElement("div"); - stripe.style.cssText = [ - "position:absolute", - "left:24px", - "right:0", - "top:0", - `height:${imageHeight}px`, - `background-image:url("${previewUrl}")`, - "background-size:132px 108px", - "background-repeat:repeat-x", - "background-position:left center", - "opacity:.9", - "box-shadow:inset 0 0 0 999px rgba(0,0,0,.08)", - ].join(";"); - block.appendChild(stripe); - } else { - const img = document.createElement("img"); - img.src = previewUrl; - img.style.cssText = `position:absolute;left:24px;right:0;top:0;width:calc(100% - 24px);height:${imageHeight}px;object-fit:cover;opacity:.9;`; - block.appendChild(img); - } + let previewUrl = previewUrlForPath(path); + const previewBust = refPreviewBusters.get(String(path)) || ""; + if (previewBust) previewUrl += `${previewUrl.includes("?") ? "&" : "?"}v=${encodeURIComponent(previewBust)}`; + const stripe = document.createElement("div"); + stripe.style.cssText = [ + "position:absolute", + "left:24px", + "right:8px", + `top:${truthRailHeight}px`, + `height:${imageHeight}px`, + `background-image:url("${previewUrl}")`, + "background-size:132px 108px", + "background-repeat:repeat-x", + "background-position:left center", + `opacity:${showDragStripes ? ".96" : ".9"}`, + "box-shadow:inset 0 0 0 999px rgba(0,0,0,.08)", + ].join(";"); + block.appendChild(stripe); const replaceImage = document.createElement("button"); replaceImage.type = "button"; replaceImage.textContent = "+"; @@ -8434,7 +9184,7 @@ function renderShotboardV3(node) { replaceImage.style.cssText = [ "position:absolute", "left:calc(50% + 12px)", - "top:54px", + `top:${truthRailHeight + 54}px`, "transform:translate(-50%,-50%)", "width:28px", "height:28px", @@ -8462,7 +9212,7 @@ function renderShotboardV3(node) { plus.type = "button"; plus.textContent = "+"; plus.title = "Import image into this empty image slot"; - plus.style.cssText = `position:absolute;left:calc(50% + 12px);top:44px;transform:translate(-50%,-50%);width:38px;height:38px;border:1px solid ${purple.border};border-radius:999px;background:${purple.valueBg};color:${purple.valueText};font-size:24px;font-weight:900;line-height:1;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.35);z-index:3;`; + plus.style.cssText = `position:absolute;left:calc(50% + 12px);top:${truthRailHeight + 44}px;transform:translate(-50%,-50%);width:38px;height:38px;border:1px solid ${purple.border};border-radius:999px;background:${purple.valueBg};color:${purple.valueText};font-size:24px;font-weight:900;line-height:1;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.35);z-index:3;`; plus.onpointerdown = (event) => { event.preventDefault(); event.stopPropagation(); }; plus.onclick = (event) => { event.preventDefault(); @@ -8498,7 +9248,7 @@ function renderShotboardV3(node) { transitionLane.style.cssText = [ "position:absolute", "left:24px", - "right:0", + "right:8px", `top:${transitionLaneTop}px`, `height:${transitionLaneHeight}px`, "background:linear-gradient(180deg,rgba(0,0,0,.18),rgba(255,238,205,.12) 48%,rgba(0,0,0,.20))", @@ -8532,9 +9282,23 @@ function renderShotboardV3(node) { const refIndex = Math.max(0, currentRef - 1); const path = currentPath || refPaths()[refIndex]; if (path) { - openReferenceFrameEditor(node, refIndex, path, (newPath) => { - replaceReferencePathAt(node, refIndex, newPath); - setSegmentReference(seg, refIndex + 1); + openReferenceFrameEditor(node, refIndex, path, (newPath, data) => { + const appended = appendReferencePath(node, newPath); + const nextRef = Math.max(1, Number(appended?.refNumber || refIndex + 1)); + const truthSeg = (timeline.segments || []).find((item) => String(item?.id || "") === String(seg.id || "")) || seg; + applyEditedReferenceTruth(truthSeg, nextRef, newPath, { updateAutoLabel: false }); + if (truthSeg !== seg) applyEditedReferenceTruth(seg, nextRef, newPath, { updateAutoLabel: false }); + if (newPath) refPreviewBusters.set(String(newPath), String(data?.cache_bust || Date.now())); + console.info("[IAMCCS V3 REF EDIT] applied edited reference to timeline truth", { + segmentId: seg.id, + truthSegmentFound: truthSeg !== seg, + oldRef: refIndex + 1, + newRef: nextRef, + path: newPath, + truthPath: truthSeg?.imageFile || truthSeg?.path || "", + savedTo: data?.absolute_path || data?.path || "", + appended: Boolean(appended?.appended), + }); writeTimeline({ force: true }); draw(); }); @@ -8558,7 +9322,7 @@ function renderShotboardV3(node) { caption.spellcheck = false; caption.dataset.iamccsV3SegmentId = String(seg.id); caption.dataset.iamccsV3Key = "prompt"; - caption.style.cssText = `position:absolute;left:8px;right:8px;top:${promptTop}px;width:calc(100% - 16px);height:${promptHeight}px;box-sizing:border-box;padding:7px 9px;background:${purple.valueBg};border:1px solid ${purple.border};border-radius:5px;color:${purple.valueText};font:${promptFontSize(10)}/1.28 monospace;font-weight:700;outline:none;resize:none;overflow-y:auto;overflow-x:hidden;box-shadow:inset 0 1px 0 rgba(255,255,255,.66);`; + caption.style.cssText = `position:absolute;left:8px;right:16px;top:${promptTop}px;width:calc(100% - 24px);height:${promptHeight}px;box-sizing:border-box;padding:7px 9px;background:${purple.valueBg};border:1px solid ${purple.border};border-radius:5px;color:${purple.valueText};font:${promptFontSize(10)}/1.28 monospace;font-weight:700;outline:none;resize:none;overflow-y:auto;overflow-x:hidden;box-shadow:inset 0 1px 0 rgba(255,255,255,.66);`; caption.onpointerdown = (event) => event.stopPropagation(); caption.onclick = (event) => event.stopPropagation(); caption.ondblclick = (event) => event.stopPropagation(); @@ -8613,58 +9377,92 @@ function renderShotboardV3(node) { block.appendChild(caption); } const label = document.createElement("div"); - label.style.cssText = "position:absolute;left:30px;top:4px;right:26px;color:#fff;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-shadow:0 1px 2px #000;"; + label.style.cssText = `position:absolute;left:30px;top:${truthRailHeight + 4}px;right:26px;color:#fff;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-shadow:0 1px 2px #000;`; label.textContent = `${frameLabel(seg.start)} - ${frameLabel(Number(seg.start || 0) + Number(seg.length || 0))}`; block.appendChild(label); - const resize = document.createElement("div"); - resize.title = isAudio ? "Drag right edge to trim audio end" : "Drag right edge to resize this slot"; - resize.style.cssText = [ - "position:absolute", - "right:0", - "top:0", - `height:${isAudio ? 36 : frameShellHeight}px`, - `width:${isAudio ? 7 : 7}px`, - "cursor:ew-resize", - "z-index:18", - "background:rgba(255,255,255,.035)", - "border-left:1px solid rgba(244,229,196,.44)", - "box-shadow:inset 1px 0 0 rgba(0,0,0,.18)", - ].join(";"); - const rightGrip = document.createElement("div"); - rightGrip.style.cssText = "position:absolute;right:1px;top:50%;width:2px;height:36px;transform:translateY(-50%);border-radius:999px;background:rgba(244,229,196,.88);box-shadow:0 0 5px rgba(0,0,0,.45);opacity:.78;pointer-events:none;"; - resize.appendChild(rightGrip); - block.appendChild(resize); - const resizeLeft = document.createElement("div"); - resizeLeft.title = isAudio ? "Drag left edge to trim audio start" : "Drag left grip to move the start of this slot"; - resizeLeft.style.cssText = [ - "position:absolute", - "left:0", - "top:0", - `height:${isAudio ? 36 : frameShellHeight}px`, - `width:${isAudio ? 7 : 7}px`, - "cursor:ew-resize", - "z-index:18", - "background:rgba(255,255,255,.03)", - "border-right:1px solid rgba(143,208,204,.48)", - "box-shadow:inset -1px 0 0 rgba(0,0,0,.18)", - ].join(";"); - const leftGrip = document.createElement("div"); - leftGrip.style.cssText = "position:absolute;left:1px;top:50%;width:2px;height:36px;transform:translateY(-50%);border-radius:999px;background:rgba(143,208,204,.86);box-shadow:0 0 5px rgba(0,0,0,.45);opacity:.78;pointer-events:none;"; - resizeLeft.appendChild(leftGrip); - block.appendChild(resizeLeft); + if (!isAudio && isTimelineImageSegment(seg) && String(seg.type || "image") !== "text") { + const truthPath = String(seg.imageTruthPath || seg.image_truth_path || seg.imageFile || seg.image_file || seg.path || "").trim(); + if (truthPath) { + const truthBadge = document.createElement("div"); + const truthName = String(seg.imageTruthName || seg.imageName || "").trim() || truthPath.split(/[\\/]/).pop() || truthPath; + truthBadge.textContent = truthName; + truthBadge.title = `Backend guide truth: ${truthPath}`; + truthBadge.style.cssText = "position:absolute;left:30px;right:42px;top:3px;z-index:30;padding:0 4px;border-radius:3px;background:rgba(5,12,13,.28);border:0;color:#CDBB92;font:8px/1.15 monospace;font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;text-shadow:0 1px 2px #000;opacity:.78;"; + block.appendChild(truthBadge); + } + } + let resize = null; + let resizeLeft = null; + if (isAudio) { + resize = document.createElement("div"); + resize.title = "Drag right edge to trim audio end"; + resize.style.cssText = [ + "position:absolute", + "right:-5px", + "top:0", + "height:36px", + "width:12px", + "cursor:ew-resize", + "z-index:36", + "background:rgba(255,255,255,.055)", + "border-left:1px solid rgba(244,229,196,.50)", + "box-shadow:0 0 0 1px rgba(0,0,0,.22), inset 1px 0 0 rgba(0,0,0,.18)", + ].join(";"); + const rightGrip = document.createElement("div"); + rightGrip.style.cssText = "position:absolute;right:4px;top:50%;width:3px;height:30px;transform:translateY(-50%);border-radius:999px;background:rgba(244,229,196,.92);box-shadow:0 0 5px rgba(0,0,0,.45);opacity:.88;pointer-events:none;"; + resize.appendChild(rightGrip); + block.appendChild(resize); + resizeLeft = document.createElement("div"); + resizeLeft.title = "Drag left edge to trim audio start"; + resizeLeft.style.cssText = [ + "position:absolute", + "left:-5px", + "top:0", + "height:36px", + "width:12px", + "cursor:ew-resize", + "z-index:36", + "background:rgba(255,255,255,.045)", + "border-right:1px solid rgba(143,208,204,.56)", + "box-shadow:0 0 0 1px rgba(0,0,0,.22), inset -1px 0 0 rgba(0,0,0,.18)", + ].join(";"); + const leftGrip = document.createElement("div"); + leftGrip.style.cssText = "position:absolute;left:4px;top:50%;width:3px;height:30px;transform:translateY(-50%);border-radius:999px;background:rgba(143,208,204,.92);box-shadow:0 0 5px rgba(0,0,0,.45);opacity:.88;pointer-events:none;"; + resizeLeft.appendChild(leftGrip); + block.appendChild(resizeLeft); + } block.onpointerdown = (event) => { - if (event.target === resize || resize.contains(event.target) || event.target === resizeLeft || resizeLeft.contains(event.target)) return; + if (resize && (event.target === resize || resize.contains(event.target))) return; + if (resizeLeft && (event.target === resizeLeft || resizeLeft.contains(event.target))) return; + if (isAudio && !event.target?.closest?.("textarea,input,select,button")) { + const rect = block.getBoundingClientRect(); + const edgePx = 12; + if (event.clientX <= rect.left + edgePx) { + startTimelineDrag(event, seg, isAudio, "left"); + return; + } + if (event.clientX >= rect.right - edgePx) { + startTimelineDrag(event, seg, isAudio, "right"); + return; + } + } startTimelineDrag(event, seg, isAudio, "center"); }; - resize.onpointerdown = (event) => { - event.stopPropagation(); - startTimelineDrag(event, seg, isAudio, "right"); - }; - resizeLeft.onpointerdown = (event) => { - event.stopPropagation(); - startTimelineDrag(event, seg, isAudio, "left"); - }; + if (resize) { + resize.onpointerdown = (event) => { + event.preventDefault(); + event.stopPropagation(); + startTimelineDrag(event, seg, isAudio, "right"); + }; + } + if (resizeLeft) { + resizeLeft.onpointerdown = (event) => { + event.preventDefault(); + event.stopPropagation(); + startTimelineDrag(event, seg, isAudio, "left"); + }; + } if (!isAudio) { block.ondblclick = (event) => { if (event.target?.tagName === "TEXTAREA") return; @@ -8672,13 +9470,84 @@ function renderShotboardV3(node) { event.stopPropagation(); if (String(seg.type || "image") === "text") openTimelineAddMenu(event, seg, null); else if (seg.placeholder || Number(seg.ref || 0) < 1) openAppendImagePicker(seg.id); - else createPlaceholderAfterSegment(seg); + else splitImageSlotAfterSegment(seg); }; } block.onclick = () => { selectedId = seg.id; draw(); }; return block; } + function appendVisualEdgeHandle(seg, edge, tone = "right") { + if (!seg || String(seg.type || "image") === "audio") return; + const total = Math.max(1, getTotalFrames()); + const frame = edge === "left" + ? Math.max(0, Math.round(Number(seg.start || 0))) + : Math.max(0, Math.round(Number(seg.start || 0) + Number(seg.length || 1))); + const pct = Math.max(0, Math.min(100, (frame / total) * 100)); + const handle = document.createElement("div"); + handle.className = `iamccs-v3-solid-edge-handle iamccs-v3-solid-edge-${edge}`; + handle.title = edge === "left" + ? "Solid edge handle: drag to resize the start of this slot" + : "Solid edge handle: drag to resize this slot boundary"; + const warm = tone === "left"; + handle.style.cssText = [ + "position:absolute", + `left:calc(${pct}% - 9px)`, + "top:8px", + "width:9px", + "height:222px", + "box-sizing:border-box", + "cursor:ew-resize", + "z-index:58", + "border-radius:4px", + `background:${warm ? "linear-gradient(180deg,#6ABDB9,#2C5E63)" : "linear-gradient(180deg,#F0CE78,#A66E32)"}`, + `border:1px solid ${warm ? "rgba(158,237,232,.88)" : "rgba(255,229,159,.92)"}`, + "box-shadow:0 0 0 1px rgba(0,0,0,.72),0 6px 14px rgba(0,0,0,.45),inset 0 1px 0 rgba(255,255,255,.36)", + "opacity:.94", + ].join(";"); + const notch = document.createElement("div"); + notch.style.cssText = [ + "position:absolute", + "left:2px", + "right:2px", + "top:50%", + "height:48px", + "transform:translateY(-50%)", + "border-radius:999px", + "background:rgba(6,10,11,.42)", + "box-shadow:inset 0 1px 2px rgba(0,0,0,.55)", + "pointer-events:none", + ].join(";"); + handle.appendChild(notch); + handle.onpointerdown = (event) => { + event.preventDefault(); + event.stopPropagation(); + startTimelineDrag(event, seg, false, edge); + }; + imageTrack.appendChild(handle); + } + + // By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + // Bug #2/#3 fix: include placeholder image segments so new "+" slots get resize handles. + // Bug #4 fix: skip left handle at timeline start (frame 0) and right handle at timeline end (totalFrames). + function drawVisualEdgeHandles(segments) { + const total = getTotalFrames(); + const sorted = (segments || []) + .filter((seg) => seg && String(seg.type || "image") !== "audio") + .slice() + .sort((a, b) => Number(a.start || 0) - Number(b.start || 0)); + sorted.forEach((seg, index) => { + const start = Math.round(Number(seg.start || 0)); + const end = Math.round(Number(seg.start || 0) + Number(seg.length || 1)); + const prev = sorted[index - 1]; + const prevEnd = prev ? Math.round(Number(prev.start || 0) + Number(prev.length || 1)) : -1; + // Skip left handle at the very start of the timeline β€” By IAMCCS + if (start > 0 && (!prev || Math.abs(prevEnd - start) > 1)) appendVisualEdgeHandle(seg, "left", "left"); + // Skip right handle at the very end of the timeline β€” By IAMCCS + if (end < total) appendVisualEdgeHandle(seg, "right", "right"); + }); + } + function drawActionLaneSegments(segments) { const total = Math.max(1, getTotalFrames()); const fps = getFps(); @@ -9292,10 +10161,10 @@ function renderShotboardV3(node) { "align-items:stretch", "margin:0 0 8px 46px", "padding:8px 10px", - `border:1px solid ${selectedId === seg.id ? "rgba(143,208,204,.82)" : "rgba(143,208,204,.42)"}`, + (selectedId === seg.id ? "border:2px solid #F9C859" : "border:1px solid rgba(143,208,204,.42)"), "border-radius:6px", "background:linear-gradient(180deg,rgba(18,37,38,.88),rgba(37,35,31,.86))", - "box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 4px 10px rgba(0,0,0,.14)", + (selectedId === seg.id ? "box-shadow:0 0 0 2px rgba(249,200,89,.3),inset 0 1px 0 rgba(255,255,255,.08),0 4px 10px rgba(0,0,0,.14)" : "box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 4px 10px rgba(0,0,0,.14)"), "box-sizing:border-box", ].join(";"); const badge = document.createElement("button"); @@ -9409,10 +10278,10 @@ function renderShotboardV3(node) { "margin-bottom:6px", "padding:8px 9px", "min-height:96px", - `border:1px solid ${selectedId === seg.id ? purple.border : purple.borderSoft}`, + (selectedId === seg.id ? "border:2px solid #F9C859" : `border:1px solid ${purple.borderSoft}`), "border-radius:6px", `background:${selectedId === seg.id ? "linear-gradient(180deg,#3E3B35,#34312D)" : "linear-gradient(180deg,rgba(51,54,55,.98),rgba(42,43,42,.98))"}`, - "box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 4px 10px rgba(0,0,0,.10)", + (selectedId === seg.id ? "box-shadow:0 0 0 2px rgba(249,200,89,.3),inset 0 1px 0 rgba(255,255,255,.06),0 4px 10px rgba(0,0,0,.10)" : "box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 4px 10px rgba(0,0,0,.10)"), "box-sizing:border-box", ].join(";"); const badge = document.createElement("button"); @@ -9789,8 +10658,14 @@ function renderShotboardV3(node) { ruler.style.width = "100%"; timelineBox.style.width = "100%"; timelineViewport.scrollLeft = Math.min(previousScrollLeft, Math.max(0, canvasWidth - Number(timelineViewport.clientWidth || 0))); - timelineBox.style.height = `${248 + Math.max(1, Number(timeline.audioTrackCount || 2)) * 45}px`; - audioTracks.style.height = `${Math.max(1, Number(timeline.audioTrackCount || 2)) * 45}px`; + // Read user timeline-height resize value, update all related heights + // By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + timelineExtraH = Math.max(0, Math.min(600, Math.round(Number(node.properties?.iamccs_v3_timeline_extra_height || 0)))); + const _atCount = Math.max(1, Number(timeline.audioTrackCount || 2)); + timelineBox.style.height = `${264 + _atCount * 45 + timelineExtraH}px`; + audioTracks.style.height = `${_atCount * 45}px`; + imageTrack.style.height = `${254 + timelineExtraH}px`; + audioTracks.style.top = `${254 + timelineExtraH}px`; for (let i = 0; i < Math.max(1, Number(timeline.audioTrackCount || 2)); i += 1) { const lane = document.createElement("div"); lane.style.cssText = `position:absolute;left:0;right:0;top:${i * 45}px;height:45px;border-bottom:1px solid ${purple.borderSoft};background:${i % 2 ? "rgba(255,255,255,.025)" : "transparent"};`; @@ -9840,27 +10715,38 @@ function renderShotboardV3(node) { } visualSegments.forEach((seg) => imageTrack.appendChild(makeBlock(seg, false))); if (!visualSegments.length) imageTrack.appendChild(makeImagePlaceholderBlock()); + drawVisualEdgeHandles(visualSegments); audioSegmentsForDraw.forEach((seg) => audioTracks.appendChild(makeBlock(seg, true))); timelineBox.querySelectorAll(".iamccs-v3-playhead").forEach((item) => item.remove()); const playhead = document.createElement("div"); playhead.className = "iamccs-v3-playhead"; playhead.style.cssText = `position:absolute;top:0;bottom:0;left:${(playFrame / Math.max(1, getTotalFrames())) * 100}%;width:2px;background:${purple.play};box-shadow:0 0 0 1px rgba(0,0,0,.55),0 0 12px rgba(214,182,93,.7);pointer-events:none;z-index:30;`; timelineBox.appendChild(playhead); - promptWrap.style.display = collapsed ? "none" : "block"; + promptWrap.style.display = "block"; // always visible β€” global prompt shown in collapsed mode too β€” By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com settings.style.display = collapsed ? "none" : "grid"; timelineNotice.style.display = collapsed ? "none" : timelineNotice.style.display; inspector.style.display = collapsed ? "none" : "block"; refsPanel.style.display = "none"; collapseBtn.textContent = collapsed ? "Show Boxes" : "Collapse Boxes"; - root.style.maxHeight = `${currentNodeHeight()}px`; + // In fullscreen editor mode, let root grow freely so panel can scroll vertically + // By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + if (root._iamccsFullscreenState) { + root.style.maxHeight = "none"; + root.style.height = "auto"; + root.style.overflow = "visible"; + } else { + root.style.maxHeight = `${currentNodeHeight() + timelineExtraH}px`; + } boxList.style.maxHeight = collapsed ? "0" : "none"; boxList.style.overflow = collapsed ? "hidden" : "visible"; if (!collapsed && !dragState) drawBoxes(); - const desiredHeight = currentNodeHeight(); + const desiredHeight = currentNodeHeight() + timelineExtraH; node._iamccsCineMinSize = [SHOTBOARD_V3_RIGID_WIDTH, desiredHeight]; - if (Math.abs(Number(node.size?.[1] || 0) - desiredHeight) > 18) { - if (typeof node.setSize === "function") node.setSize([SHOTBOARD_V3_RIGID_WIDTH, desiredHeight]); - else node.size = [SHOTBOARD_V3_RIGID_WIDTH, desiredHeight]; + if (!root._iamccsFullscreenState) { + if (Math.abs(Number(node.size?.[1] || 0) - desiredHeight) > 18) { + if (typeof node.setSize === "function") node.setSize([SHOTBOARD_V3_RIGID_WIDTH, desiredHeight]); + else node.size = [SHOTBOARD_V3_RIGID_WIDTH, desiredHeight]; + } } try { node.setDirtyCanvas?.(true, true); app.graph?.setDirtyCanvas?.(true, true); } catch {} } @@ -9906,7 +10792,24 @@ function renderShotboardV3(node) { target.type = "image"; target.placeholder = false; target.ref = current.length + 1; + // By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + // Bug #5 fix: clear all old truth/path fields before assigning the new image, + // so imported-board pins or stale values never shadow the replacement. + delete target.imageTruthPath; + delete target.image_truth_path; + delete target.imageTruthRef; + delete target.imageTruthPinned; target.imageFile = uploaded[0] || target.imageFile || ""; + target.path = target.imageFile; + target.imageTruthPath = target.imageFile; + target.imageTruthName = target.imageFile.split(/[\\/]/).pop() || target.imageFile; + target.imageName = target.imageTruthName; + target.imageTruthRef = target.ref; + target.imageTruthPinned = true; + target.imageTruthSource = "upload_replace_slot"; + delete target.image_file; + delete target.image_truth_path; + refPreviewBusters.set(String(target.imageFile), String(Date.now())); target.label = `ref_${current.length + 1}`; target.use_guide = true; target.guideStrength = Number(target.guideStrength ?? defaultForceWidget?.value ?? 0.25); @@ -9930,6 +10833,13 @@ function renderShotboardV3(node) { length, ref: current.length + refOffset + i + 1, imageFile, + path: imageFile, + imageTruthPath: imageFile, + imageTruthName: imageFile.split(/[\\/]/).pop() || imageFile, + imageName: imageFile.split(/[\\/]/).pop() || imageFile, + imageTruthRef: current.length + refOffset + i + 1, + imageTruthPinned: true, + imageTruthSource: "upload_new_slot", label: `ref_${current.length + refOffset + i + 1}`, prompt: "", note: "", @@ -9943,6 +10853,7 @@ function renderShotboardV3(node) { }); cursor += length; }); + uploaded.forEach((path) => refPreviewBusters.set(String(path), String(Date.now()))); if (endOfSegments(timeline.segments) > getTotalFrames() || timeline.segments.some((seg) => Number(seg.length || 0) <= 1)) { spreadVisualSegmentsAcrossDuration(); } @@ -10159,6 +11070,8 @@ function renderShotboardV3(node) { const row = item.row; const prompt = String(row.relay_prompt ?? row.local_prompt ?? row.prompt ?? "").trim(); const isText = String(row.type || "").toLowerCase() === "text" || Number(row.ref ?? row.image_ref ?? row.reference_index ?? 1) <= 0; + const rowTruthPath = isText ? "" : String(row.imageTruthPath || row.image_truth_path || row.imageFile || row.image_file || row.path || "").trim(); + const rowTruthName = isText ? "" : (String(row.imageTruthName || row.imageName || row.name || row.filename || "").trim() || rowTruthPath.split(/[\\/]/).pop() || ""); const defaultForceValue = Math.max(0, Math.min(1, Number(defaultForceWidget?.value ?? 0.25))); const rowForceValue = Math.max(0, Math.min(1, Number(row.force ?? row.strength ?? defaultForceValue))); const explicitForceCustom = typeof row.forceCustom === "boolean" @@ -10172,14 +11085,26 @@ function renderShotboardV3(node) { start, length: Math.max(1, end - start), ref: Math.max(1, Math.round(Number(row.ref ?? row.image_ref ?? row.reference_index ?? index + 1) || index + 1)), - label: String(row.label ?? row.shot_label ?? `${isText ? "text" : "shot"}_${index + 1}`), + imageFile: rowTruthPath, + path: rowTruthPath, + imageTruthPath: rowTruthPath, + imageTruthName: rowTruthName, + imageName: rowTruthName, + imageTruthPinned: Boolean(rowTruthPath), + imageTruthSource: rowTruthPath ? "row_import_truth" : "", + imageTruthRef: Math.max(1, Math.round(Number(row.ref ?? row.image_ref ?? row.reference_index ?? index + 1) || index + 1)), + label: String(row.label ?? row.shot_label ?? (rowTruthName ? rowTruthName.replace(/\.[^.]+$/, "") : `${isText ? "text" : "shot"}_${index + 1}`)), prompt, note: String(row.note ?? row.camera_note ?? ""), camera: String(row.camera ?? row.camera_move ?? "cinematic motion"), transition: String(row.transition ?? row.transition_intent ?? "continuous_motion"), - guideStrength: isText ? 0 : rowForceValue, - imageLockStrength: isText ? 0 : rowForceValue, - defaultForceSource: isText ? 0 : (explicitForceCustom ? rowForceValue : defaultForceValue), + guideStrength: isText ? 0 : rowForceValue, + guide_strength: isText ? 0 : rowForceValue, + force: isText ? 0 : rowForceValue, + strength: isText ? 0 : rowForceValue, + imageLockStrength: isText ? 0 : rowForceValue, + image_lock_strength: isText ? 0 : rowForceValue, + defaultForceSource: isText ? 0 : (explicitForceCustom ? rowForceValue : defaultForceValue), forceCustom: Boolean(!isText && explicitForceCustom), use_guide: !isText && row.use_guide !== false, use_prompt: Boolean(prompt || row.dialogue_pin || row.dialoguePin || row.image_lock || row.imageLock || row.motion_boost || row.motionBoost), @@ -10199,24 +11124,92 @@ function renderShotboardV3(node) { }); } async function applyImportedBoard(data) { - const board = data?.board && typeof data.board === "object" ? data.board : data || {}; + const workflowBoard = boardFromWorkflowJson(data); + const nestedBoard = data?.board && typeof data.board === "object" ? data.board : null; + const rootLooksLikeBoard = Boolean( + data?.timeline || + data?.timeline_data || + data?.image_paths || + data?.package || + Array.isArray(data?.images) || + String(data?.metadata?.schema || data?.schema || "").includes("shotboard") + ); + const board = (rootLooksLikeBoard ? (nestedBoard || data) : null) || workflowBoard || nestedBoard || data || {}; if ((!Array.isArray(board.images) || !board.images.length) && Array.isArray(data?.images)) board.images = data.images; + console.log("[IAMCCS V3 BOARD IMPORT] board source selected", { + nodeId: node?.id, + source: rootLooksLikeBoard ? (nestedBoard ? "nested_package_board" : "root_package_board") : (workflowBoard ? "workflow_node" : "root_fallback"), + workflowNode: Boolean(workflowBoard), + hasPackage: Boolean(board?.package || data?.package), + images: Array.isArray(board?.images) ? board.images.length : 0, + imagePaths: splitReferencePaths(board?.image_paths).length, + }); + let importedDefaultForceExplicit = false; + const validImportedValue = (value) => value !== undefined && value !== null && value !== ""; + const applyImportedSettings = (source, sourceLabel = "settings") => { + if (!source || typeof source !== "object") return; + for (const name of v3SettingNames) { + if (!Object.prototype.hasOwnProperty.call(source, name) || !getWidget(node, name)) continue; + const value = name === "image_resize_method" ? cineResizeMethodValue(source[name]) : source[name]; + if (!validImportedValue(value)) continue; + setWidgetValue(node, name, value); + if (name === "default_force") importedDefaultForceExplicit = true; + } + console.log("[IAMCCS V3 BOARD IMPORT] settings absorbed", { + nodeId: node?.id, + source: sourceLabel, + explicitDefaultForce: importedDefaultForceExplicit, + settings: v3SettingsSnapshot(), + }); + }; + const inferDefaultForceFromTimeline = (source) => { + const values = []; + const defaultSourceValues = []; + const add = (value, target = values) => { + const n = Number(value); + if (Number.isFinite(n) && n >= 0 && n <= 1) target.push(Math.round(n * 1000) / 1000); + }; + const addSegment = (seg) => { + if (!seg || typeof seg !== "object" || seg.placeholder) return; + const type = String(seg.type || "image").toLowerCase(); + if (type === "audio" || type === "text") return; + add(seg.defaultForceSource ?? seg.default_force_source, defaultSourceValues); + add(seg.guideStrength ?? seg.guide_strength ?? seg.force ?? seg.strength); + }; + const addRow = (row) => { + if (!row || typeof row !== "object") return; + add(row.default_force ?? row.defaultForce ?? row.defaultForceSource, defaultSourceValues); + add(row.force ?? row.strength ?? row.guideStrength ?? row.guide_strength); + }; + const visit = (item) => { + if (!item || typeof item !== "object") return; + if (Array.isArray(item.segments)) item.segments.forEach(addSegment); + if (Array.isArray(item.rows)) item.rows.forEach(addRow); + if (item.timeline && typeof item.timeline === "object") visit(item.timeline); + if (typeof item.timeline_data === "string" && item.timeline_data.trim()) { + try { visit(JSON.parse(item.timeline_data)); } catch {} + } + }; + visit(source); + const pickMode = (list) => { + if (!list.length) return null; + const counts = new Map(); + list.forEach((value) => { + const key = value.toFixed(3); + counts.set(key, (counts.get(key) || 0) + 1); + }); + return Number([...counts.entries()].sort((a, b) => b[1] - a[1] || Number(b[0]) - Number(a[0]))[0][0]); + }; + return pickMode(defaultSourceValues) ?? pickMode(values); + }; const settingsData = board.settings && typeof board.settings === "object" ? board.settings : {}; const importedPrompt = board.global_prompt ?? board.prompt ?? board.globalPrompt; if (typeof importedPrompt === "string") { promptArea.value = importedPrompt; setWidgetValue(node, "global_prompt", importedPrompt); } - for (const [name, value] of Object.entries(settingsData)) { - if (getWidget(node, name)) setWidgetValue(node, name, name === "image_resize_method" ? cineResizeMethodValue(value) : value); - } - for (const name of v3SettingNames) { - if (Object.prototype.hasOwnProperty.call(board, name) && getWidget(node, name)) setWidgetValue(node, name, name === "image_resize_method" ? cineResizeMethodValue(board[name]) : board[name]); - } - console.log("[IAMCCS V3 BOARD IMPORT] settings absorbed into node widgets", { - nodeId: node?.id, - settings: v3SettingsSnapshot(), - }); + applyImportedSettings(settingsData, "board.settings"); + applyImportedSettings(board, workflowBoard ? "workflow.shotboard.widgets" : "board.root"); const paths = await packagedReferencePathsForImport(board, (message) => { showTimelineNotice(message, /failed/i.test(String(message || "")) ? "error" : "warn"); }); @@ -10263,14 +11256,50 @@ function renderShotboardV3(node) { : String((candidate.value?.rows || []).find((row) => String(row?.relay_prompt ?? row?.local_prompt ?? row?.prompt ?? "").trim())?.relay_prompt || "").slice(0, 220), })), }); + applyImportedSettings(loadedTimeline.settings, `${loadedTimelineSource}.settings`); + applyImportedSettings(loadedTimeline, loadedTimelineSource); + } + if (!importedDefaultForceExplicit) { + const inferredDefaultForce = inferDefaultForceFromTimeline(loadedTimeline || board); + if (Number.isFinite(Number(inferredDefaultForce))) { + setWidgetValue(node, "default_force", Math.max(0, Math.min(1, Number(inferredDefaultForce)))); + showTimelineNotice(`Default force inferred from imported board: ${Number(inferredDefaultForce).toFixed(3)}`, "warn"); + console.log("[IAMCCS V3 BOARD IMPORT] default_force inferred", { + nodeId: node?.id, + value: Number(inferredDefaultForce), + source: loadedTimelineSource || "board", + }); + } } if (loadedTimeline && typeof loadedTimeline === "object" && Array.isArray(loadedTimeline.segments)) { + const baseTimeline = cloneJsonData(loadedTimeline); + let audioData = {}; + if (typeof baseTimeline.audio_data === "string" && baseTimeline.audio_data.trim()) { + try { audioData = JSON.parse(baseTimeline.audio_data); } catch {} + } else if (baseTimeline.audio_data && typeof baseTimeline.audio_data === "object") { + audioData = baseTimeline.audio_data; + } + const importedAudioSegments = Array.isArray(baseTimeline.audioSegments) + ? baseTimeline.audioSegments + : Array.isArray(audioData.audioSegments) + ? audioData.audioSegments + : []; timeline = { + ...baseTimeline, schema: "iamccs.cine.filmmaker_timeline", - schema_version: Number(loadedTimeline.schema_version || 1), - segments: loadedTimeline.segments.map((seg) => normalizeV3RelayOnlySegment({ ...seg, id: seg.id || newId(seg.type === "text" ? "text" : "seg") })), - audioSegments: Array.isArray(loadedTimeline.audioSegments) ? loadedTimeline.audioSegments.map((seg) => ({ ...seg, id: seg.id || newId("aud") })) : [], - audioTrackCount: Math.max(1, Number(loadedTimeline.audioTrackCount || 2)), + schema_version: Number(baseTimeline.schema_version || 1), + segments: baseTimeline.segments.map((seg) => normalizeV3RelayOnlySegment({ ...seg, id: seg.id || newId(seg.type === "text" ? "text" : "seg") })), + rows: Array.isArray(baseTimeline.rows) ? cloneJsonData(baseTimeline.rows) : [], + audioSegments: importedAudioSegments.map((seg) => ({ ...seg, id: seg.id || newId("aud") })), + audioTrackCount: Math.max(1, Number(baseTimeline.audioTrackCount || audioData.audioTrackCount || importedAudioSegments.length || 2)), + audioSyncMode: String(baseTimeline.audioSyncMode || audioData.audioSyncMode || "timeline_audio"), + generationStrategy: String(baseTimeline.generationStrategy || "single_timeline"), + flfrealMode: String(baseTimeline.flfrealMode || baseTimeline.flfreal_mode || "iamccs_enhanced"), + globalPromptOnly: Boolean(baseTimeline.globalPromptOnly ?? baseTimeline.global_prompt_only ?? baseTimeline.use_global_prompt_only ?? false), + verboseLog: baseTimeline.verboseLog ?? baseTimeline.verbose_log ?? true, + masterAudioGain: Number(baseTimeline.masterAudioGain ?? audioData.masterAudioGain ?? 1), + masterAudioNormalize: Boolean(baseTimeline.masterAudioNormalize ?? audioData.masterAudioNormalize ?? false), + multiGeneration: baseTimeline.multiGeneration && typeof baseTimeline.multiGeneration === "object" ? cloneJsonData(baseTimeline.multiGeneration) : {}, }; } else { const rows = Array.isArray(board.rows) @@ -10291,6 +11320,18 @@ function renderShotboardV3(node) { }; } } + const importedDurationTruth = objectDurationTruth(timeline) ?? objectDurationTruth(loadedTimeline) ?? objectDurationTruth(board) ?? objectDurationTruth(settingsData) ?? objectDurationTruth(data); + const importedFpsTruth = objectFpsTruth(timeline) ?? objectFpsTruth(loadedTimeline) ?? objectFpsTruth(board) ?? objectFpsTruth(settingsData) ?? objectFpsTruth(data); + if (importedDurationTruth !== null) setDurationSeconds(importedDurationTruth, `import:${loadedTimelineSource || "board"}`); + if (importedFpsTruth !== null) setFrameRateValue(importedFpsTruth, `import:${loadedTimelineSource || "board"}`); + console.log("[IAMCCS V3 BOARD IMPORT] timing truth applied", { + nodeId: node?.id, + source: loadedTimelineSource || "board", + duration_seconds: getDuration(), + frame_rate: getFps(), + timelineDuration: objectDurationTruth(timeline), + timelineFps: objectFpsTruth(timeline), + }); showTimelineNotice(`Imported board timeline source: ${loadedTimelineSource || "rows/timeline_data"}`, "warn"); selectedId = timeline.segments?.[0]?.id || null; clearV3BoardTransientState(); @@ -11613,11 +12654,20 @@ function renderForNode(node) { } } -function scheduleRender(node) { +function scheduleRender(node, options = {}) { const klass = nodeClassName(node); if (klass !== "IAMCCS_CineLTXSequencer" && klass !== "IAMCCS_CinePromptRelayLatentShapeSync" && klass !== "IAMCCS_CinePromptRelayTimeline" && klass !== "IAMCCS_CineShotboardLite" && klass !== "IAMCCS_CineShotboardTimelinePro" && klass !== "IAMCCS_CineShotboardPlannerPro" && klass !== "IAMCCS_CineShotboardPlannerProV2" && klass !== "IAMCCS_CineShotboardPlannerV3" && klass !== "IAMCCS_CineShotboardPlannerProLegacy" && klass !== "IAMCCS_CineFLFEngineSimple" && klass !== "IAMCCS_CineInfo" && klass !== "IAMCCS_CinePromptArchitect" && klass !== "IAMCCS_BoardMaker" && klass !== "IAMCCS_CineMusicVideoPlanner") return; - setTimeout(() => renderForNode(node), 80); - setTimeout(() => renderForNode(node), 450); + if (Array.isArray(node._iamccsCineRenderTimers)) { + node._iamccsCineRenderTimers.forEach((timer) => window.clearTimeout(timer)); + } + const delay = Math.max(0, Number(options.delay ?? 80)); + const secondPass = options.secondPass !== false; + node._iamccsCineRenderTimers = [ + window.setTimeout(() => renderForNode(node), delay), + ]; + if (secondPass) { + node._iamccsCineRenderTimers.push(window.setTimeout(() => renderForNode(node), Math.max(delay + 220, Number(options.secondDelay ?? 450)))); + } } function flushAllShotboardV3Timelines(reason = "flush") { @@ -11693,6 +12743,7 @@ app.registerExtension({ document.addEventListener("iamccs:planner_rows_updated", (ev) => { const nodeId = ev?.detail?.node_id; if (!nodeId) return; + if (ev?.detail?.render === false) return; const plannerNode = app.graph?.getNodeById(nodeId); if (!plannerNode) return; const klass = String(plannerNode?.comfyClass || plannerNode?.type || ""); @@ -11703,7 +12754,10 @@ document.addEventListener("iamccs:planner_rows_updated", (ev) => { plannerNode._iamccsCineShotboardLiteReady = false; plannerNode._iamccsCineShotboardV3Ready = false; plannerNode._iamccsCineShotboardV3Version = ""; - scheduleRender(plannerNode); + const fromAudioBoard = ev?.detail?.source === "IAMCCS_AudioBoardArranger"; + scheduleRender(plannerNode, fromAudioBoard ? { delay: 180, secondPass: false } : {}); }); + + diff --git a/web/iamccs_dialogue_script_planner_ui.js b/web/iamccs_dialogue_script_planner_ui.js new file mode 100644 index 0000000..39eab0e --- /dev/null +++ b/web/iamccs_dialogue_script_planner_ui.js @@ -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); + }; + }, +}); diff --git a/web/iamccs_multitimeline_bridge_ui.js b/web/iamccs_multitimeline_bridge_ui.js new file mode 100644 index 0000000..371b961 --- /dev/null +++ b/web/iamccs_multitimeline_bridge_ui.js @@ -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 = `
IAMCCS MultiTimeline Bridge
real indexed take bridge, no raw JSON editing
`; + 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 = `T${String(take).padStart(2, "0")}${take === active ? "prepared now" : "click to prepare"}`; + 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); + }; + }, +}); diff --git a/web/iamccs_supernodes_exec_ui.js b/web/iamccs_supernodes_exec_ui.js index 457ab26..69008df 100644 --- a/web/iamccs_supernodes_exec_ui.js +++ b/web/iamccs_supernodes_exec_ui.js @@ -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; }; diff --git a/web/iamccs_workflow_persist_cleanup.js b/web/iamccs_workflow_persist_cleanup.js index a4e0da9..5ac6e89 100644 --- a/web/iamccs_workflow_persist_cleanup.js +++ b/web/iamccs_workflow_persist_cleanup.js @@ -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(); + }, });