From 246f2f19665eeed9f21f368c2ae0ec520a361c97 Mon Sep 17 00:00:00 2001 From: IAMCCS Date: Thu, 21 May 2026 00:20:33 +0200 Subject: [PATCH] Updated IAMCCS-nodes to version 1.4.6 --- CHANGELOG.md | 2 + README.md | 2 + __init__.py | 281 +- iamccs_cine_duration_lock.py | 234 + iamccs_cine_flf_engine_simple_dyno.py | 170 + iamccs_cine_flf_productor_dyno.py | 287 + iamccs_cine_nodes.py | 2997 +++++++++- iamccs_cine_resolution_parity.py | 419 ++ iamccs_cine_stage2_preview_toggle.py | 84 + iamccs_cine_stage_switch.py | 83 + iamccs_detail_atelier.py | 835 +++ iamccs_wdc_ltx_port.py | 52 +- version.json | 2 +- web/iamccs_cine_timeline_ui.js | 6918 +++++++++++++++++++++++- web/iamccs_detail_atelier_ui.js | 308 ++ web/iamccs_workflow_persist_cleanup.js | 443 +- 16 files changed, 12258 insertions(+), 859 deletions(-) create mode 100644 iamccs_cine_duration_lock.py create mode 100644 iamccs_cine_flf_engine_simple_dyno.py create mode 100644 iamccs_cine_flf_productor_dyno.py create mode 100644 iamccs_cine_resolution_parity.py create mode 100644 iamccs_cine_stage2_preview_toggle.py create mode 100644 iamccs_cine_stage_switch.py create mode 100644 iamccs_detail_atelier.py create mode 100644 web/iamccs_detail_atelier_ui.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b4cc355..37a2859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # IAMCCS Nodes - Changelog +## ๐Ÿ†• 2026-05-20 - version 1.4.6 โ€” Shotboard planner v2 and v3 added + ## ๐Ÿ†• 2026-05-12 - version 1.4.5 โ€” Cine nodes added ## ๐Ÿ†• 2026-05-04 - version 1.4.4 โ€” Supernodes v.2 and bug fixed pplus utilities added diff --git a/README.md b/README.md index 4a83d7d..66200d9 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.6 (Shotboard planner v2 and v3 added) + Version: 1.4.5 (Cine nodes added) Version: 1.4.4 (Supernodes and wan 2.2 + LTX 2.2 utilities added) diff --git a/__init__.py b/__init__.py index 360b97c..965bbf7 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,4 @@ -# ========================================================== +๏ปฟ# ========================================================== # __init__.py โ€” Registro nodi IAMCCS # ========================================================== @@ -108,14 +108,24 @@ from .iamccs_cine_nodes import ( IAMCCS_CineShotboardTimelinePro, IAMCCS_CineShotboardPlannerPro, IAMCCS_CineShotboardPlannerProV2, + IAMCCS_CineShotboardPlannerV3, IAMCCS_CineShotboardLite, IAMCCS_CineShotboardPlannerProLegacy, IAMCCS_CineInfo, + IAMCCS_CineInfoV2, + IAMCCS_CineFLFProductor, + IAMCCS_CineFilmmaker, + IAMCCS_CineFilmmakerBackend, + IAMCCS_CineShotboardBackendPro, + IAMCCS_CineFilmmakerGuide, + IAMCCS_CineFilmmakerGuide1to1, IAMCCS_CineSwitch, IAMCCS_CinePromptRelayLatentShapeSync, IAMCCS_CineFLFLengthCompensator, IAMCCS_CinePromptRelaySafeEncode, IAMCCS_CineRelayOrBypass, + IAMCCS_CinePromptArchitect, + IAMCCS_BoardMaker, IAMCCS_CineMusicVideoPlanner, IAMCCS_CineShotPlanner, IAMCCS_CineRefLatentControl, @@ -131,6 +141,12 @@ from .iamccs_cine_nodes import ( IAMCCS_CineV2VAssetSelector, IAMCCS_CineWorkflowInspector, ) +from .iamccs_cine_resolution_parity import IAMCCS_CineResolutionParityTranslator +from .iamccs_cine_stage_switch import IAMCCS_CineStage2BypassSwitch +from .iamccs_cine_stage2_preview_toggle import IAMCCS_CineStage2PreviewToggle +from .iamccs_cine_flf_productor_dyno import IAMCCS_CineFLFProductorDyno +from .iamccs_cine_flf_engine_simple_dyno import IAMCCS_CineFLFEngineSimpleDyno +from .iamccs_cine_duration_lock import IAMCCS_CineBoardDurationLock, IAMCCS_CineLatentDurationCrop from .iamccs_ltx2_temporal_overlap_samplers import ( IAMCCS_LTX2_ConditionNextLatentWithPrevOverlap, @@ -212,6 +228,12 @@ from .iamccs_hw_probe_node import ( IAMCCS_HWProbeRecommendations, ) +from .iamccs_detail_atelier import ( + IAMCCS_DetailAtelier, + IAMCCS_DetailAtelierAdvanced, + IAMCCS_DetailAtelierSampler, +) + from .iamccs_qwen_vl_flf import ( IAMCCS_QWEN_VL_FLF, IAMCCS_QWEN_VL_FLF_Advanced, @@ -374,14 +396,30 @@ NODE_CLASS_MAPPINGS = { "IAMCCS_CineShotboardTimelinePro": IAMCCS_CineShotboardTimelinePro, "IAMCCS_CineShotboardPlannerPro": IAMCCS_CineShotboardPlannerPro, "IAMCCS_CineShotboardPlannerProV2": IAMCCS_CineShotboardPlannerProV2, + "IAMCCS_CineShotboardPlannerV3": IAMCCS_CineShotboardPlannerV3, "IAMCCS_CineShotboardLite": IAMCCS_CineShotboardLite, "IAMCCS_CineShotboardPlannerProLegacy": IAMCCS_CineShotboardPlannerProLegacy, + "IAMCCS_CineResolutionParityTranslator": IAMCCS_CineResolutionParityTranslator, + "IAMCCS_CineStage2BypassSwitch": IAMCCS_CineStage2BypassSwitch, + "IAMCCS_CineStage2PreviewToggle": IAMCCS_CineStage2PreviewToggle, "IAMCCS_CineInfo": IAMCCS_CineInfo, + "IAMCCS_CineInfoV2": IAMCCS_CineInfoV2, + "IAMCCS_CineFLFProductor": IAMCCS_CineFLFProductor, + "IAMCCS_CineFLFProductorDyno": IAMCCS_CineFLFProductorDyno, + "IAMCCS_CineFilmmaker": IAMCCS_CineFilmmaker, + "IAMCCS_CineFilmmakerBackend": IAMCCS_CineFilmmakerBackend, + "IAMCCS_CineShotboardBackendPro": IAMCCS_CineShotboardBackendPro, + "IAMCCS_CineFilmmakerGuide": IAMCCS_CineFilmmakerGuide, + "IAMCCS_CineFilmmakerGuide1to1": IAMCCS_CineFilmmakerGuide1to1, "IAMCCS_CineSwitch": IAMCCS_CineSwitch, "IAMCCS_CinePromptRelayLatentShapeSync": IAMCCS_CinePromptRelayLatentShapeSync, "IAMCCS_CineFLFLengthCompensator": IAMCCS_CineFLFLengthCompensator, + "IAMCCS_CineBoardDurationLock": IAMCCS_CineBoardDurationLock, + "IAMCCS_CineLatentDurationCrop": IAMCCS_CineLatentDurationCrop, "IAMCCS_CinePromptRelaySafeEncode": IAMCCS_CinePromptRelaySafeEncode, "IAMCCS_CineRelayOrBypass": IAMCCS_CineRelayOrBypass, + "IAMCCS_CinePromptArchitect": IAMCCS_CinePromptArchitect, + "IAMCCS_BoardMaker": IAMCCS_BoardMaker, "IAMCCS_CineMusicVideoPlanner": IAMCCS_CineMusicVideoPlanner, "IAMCCS_CineShotPlanner": IAMCCS_CineShotPlanner, "IAMCCS_CineRefLatentControl": IAMCCS_CineRefLatentControl, @@ -401,6 +439,7 @@ NODE_CLASS_MAPPINGS = { "IAMCCS_WDC_LTXSequencer": IAMCCS_WDC_LTXSequencer, "IAMCCS_CineLTXSequencerExact": IAMCCS_CineLTXSequencerExact, "IAMCCS_CineFLFEngineSimple": IAMCCS_CineFLFEngineSimple, + "IAMCCS_CineFLFEngineSimpleDyno": IAMCCS_CineFLFEngineSimpleDyno, "IAMCCS_WDC_LTXSequencerFixed5": IAMCCS_WDC_LTXSequencerFixed5, "IAMCCS_LTX2_InitLatentSampler": IAMCCS_LTX2_InitLatentSampler, "IAMCCS_LTX2_LoopingSampler": IAMCCS_LTX2_LoopingSampler, @@ -457,6 +496,9 @@ NODE_CLASS_MAPPINGS = { "IAMCCS_VAEDecodeTiledSafe": IAMCCS_VAEDecodeTiledSafe, "IAMCCS_VAEDecodeToDisk": IAMCCS_VAEDecodeToDisk, "IAMCCS_HWProbeRecommendations": IAMCCS_HWProbeRecommendations, + "IAMCCS_DetailAtelier": IAMCCS_DetailAtelier, + "IAMCCS_DetailAtelierAdvanced": IAMCCS_DetailAtelierAdvanced, + "IAMCCS_DetailAtelierSampler": IAMCCS_DetailAtelierSampler, "IAMCCS_MoveAhead": IAMCCS_MoveAhead, "IAMCCS_MoveAheadEnforcer": IAMCCS_MoveAheadEnforcer, @@ -586,14 +628,30 @@ NODE_DISPLAY_NAME_MAPPINGS = { "IAMCCS_CineShotboardTimelinePro": "IAMCCS Cine Shotboard Timeline Pro", "IAMCCS_CineShotboardPlannerPro": "IAMCCS Cine Shotboard Planner Pro", "IAMCCS_CineShotboardPlannerProV2": "IAMCCS Cine Shotboard Planner Pro V2", + "IAMCCS_CineShotboardPlannerV3": "IAMCCS Cine Shotboard Planner V3", "IAMCCS_CineShotboardLite": "IAMCCS Cine Shotboard Lite", "IAMCCS_CineShotboardPlannerProLegacy": "IAMCCS Cine Shotboard Planner Pro Legacy Outputs", + "IAMCCS_CineResolutionParityTranslator": "IAMCCS Cine Resolution Parity Translator", + "IAMCCS_CineStage2BypassSwitch": "IAMCCS Cine Stage 2 Bypass Switch", + "IAMCCS_CineStage2PreviewToggle": "IAMCCS Cine Stage 2 Preview Toggle", "IAMCCS_CineInfo": "IAMCCS CineInfo", + "IAMCCS_CineInfoV2": "IAMCCS CineInfo V2", + "IAMCCS_CineFLFProductor": "IAMCCS Cine FLF Productor", + "IAMCCS_CineFLFProductorDyno": "IAMCCS Cine FLF Productor Dyno", + "IAMCCS_CineFilmmaker": "IAMCCS Cine Filmmaker", + "IAMCCS_CineFilmmakerBackend": "IAMCCS Cine Filmmaker Backend", + "IAMCCS_CineShotboardBackendPro": "IAMCCS Cine Shotboard Backend Pro", + "IAMCCS_CineFilmmakerGuide": "IAMCCS Cine Filmmaker Guide", + "IAMCCS_CineFilmmakerGuide1to1": "IAMCCS Cine Filmmaker Guide 1:1", "IAMCCS_CineSwitch": "IAMCCS CineSwitch Lazy FLF/PromptRelay", "IAMCCS_CinePromptRelayLatentShapeSync": "IAMCCS Cine PromptRelay Latent Shape Sync", "IAMCCS_CineFLFLengthCompensator": "IAMCCS Cine FLF Length Compensator", + "IAMCCS_CineBoardDurationLock": "IAMCCS Cine Board Duration Lock", + "IAMCCS_CineLatentDurationCrop": "IAMCCS Cine Latent Duration Crop", "IAMCCS_CinePromptRelaySafeEncode": "IAMCCS Cine PromptRelay Safe Encode", "IAMCCS_CineRelayOrBypass": "IAMCCS Cine Relay Or Bypass", + "IAMCCS_CinePromptArchitect": "IAMCCS CinePrompt Architect", + "IAMCCS_BoardMaker": "IAMCCS_BoardMaker", "IAMCCS_CineMusicVideoPlanner": "IAMCCS Cine Videoclip Maker Planner", "IAMCCS_CineShotPlanner": "IAMCCS Cine Shot Planner", "IAMCCS_CineRefLatentControl": "IAMCCS Cine Reference Latent Control", @@ -613,6 +671,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "IAMCCS_WDC_LTXSequencer": "IAMCCS Cine LTX Sequencer (legacy alias)", "IAMCCS_CineLTXSequencerExact": "IAMCCS Cine LTX Sequencer Exact", "IAMCCS_CineFLFEngineSimple": "IAMCCS Cine FLF Engine Simple", + "IAMCCS_CineFLFEngineSimpleDyno": "IAMCCS Cine FLF Engine Simple Dyno", "IAMCCS_WDC_LTXSequencerFixed5": "IAMCCS Cine LTX Sequencer Fixed 5 (legacy alias)", "IAMCCS_LTX2_InitLatentSampler": "LTX-2 Init Latent Sampler ๐Ÿงฑ", "IAMCCS_LTX2_LoopingSampler": "LTX-2 Looping Sampler (temporal overlap) ๐Ÿงท", @@ -700,6 +759,9 @@ NODE_DISPLAY_NAME_MAPPINGS = { "IAMCCS_VAEDecodeTiledSafe": "VAE Decode Tiled (safe, optional cleanup)", "IAMCCS_VAEDecodeToDisk": "VAE Decode โ†’ Disk (frames, low RAM)", "IAMCCS_HWProbeRecommendations": "HW Probe Recommendations (JSON)", + "IAMCCS_DetailAtelier": "IAMCCS Detail Atelier", + "IAMCCS_DetailAtelierAdvanced": "IAMCCS Detail Atelier Advanced", + "IAMCCS_DetailAtelierSampler": "IAMCCS Detail Atelier", "IAMCCS_MoveAhead": "MoveAhead (FreeLong spectral blend) ๐ŸŽฌ", "IAMCCS_MoveAheadEnforcer": "MoveAhead Enforcer (3-tier motion lock) ๐ŸŽฌ", @@ -855,7 +917,7 @@ def setup_api_routes() -> None: 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 = im.rotate(-rotation, resample=Image.Resampling.BICUBIC, expand=True, fillcolor=fill) src_w, src_h = im.size crop_box = None preview_crop_box = None @@ -940,6 +1002,221 @@ def setup_api_routes() -> None: except Exception as e: return web.json_response({"error": str(e)}, status=500) + @routes.post("/api/iamccs/cine/save_shotboard_package") + async def iamccs_cine_save_shotboard_package(request): + try: + import base64 + import copy + import json + import re + import shutil + import time + import folder_paths + + def _sanitize(value, fallback="cine_shotboard_package"): + clean = re.sub(r'[<>:"/\\|?*\x00-\x1F]+', "_", str(value or fallback).strip()) + clean = re.sub(r"\s+", "_", clean).strip("._") + return (clean[:90] or fallback) + + def _split_paths(value): + if isinstance(value, list): + return [str(item).strip() for item in value if str(item or "").strip()] + raw = str(value or "").strip() + if not raw: + return [] + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [str(item).strip() for item in parsed if str(item or "").strip()] + except Exception: + pass + if "\n" in raw or "\r" in raw: + return [item.strip() for item in raw.splitlines() if item.strip()] + return [item.strip() for item in raw.split(",") if item.strip()] if "," in raw else [raw] + + def _add_path(paths, seen, value): + clean = str(value or "").strip() + if clean and clean not in seen: + seen.add(clean) + paths.append(clean) + + def _collect_paths(board): + paths = [] + seen = set() + for path in _split_paths(board.get("image_paths")): + _add_path(paths, seen, path) + for item in board.get("images") or []: + if isinstance(item, dict): + _add_path(paths, seen, item.get("path") or item.get("original_path") or item.get("filename") or item.get("name")) + for seg in (board.get("segments") or []): + if isinstance(seg, dict): + _add_path(paths, seen, seg.get("imageFile") or seg.get("image_file") or seg.get("path")) + timeline = board.get("timeline") + if isinstance(timeline, dict): + for seg in (timeline.get("segments") or []): + if isinstance(seg, dict): + _add_path(paths, seen, seg.get("imageFile") or seg.get("image_file") or seg.get("path")) + timeline_data = board.get("timeline_data") + if isinstance(timeline_data, str) and timeline_data.strip(): + try: + parsed = json.loads(timeline_data) + for path in _split_paths(parsed.get("image_paths") if isinstance(parsed, dict) else None): + _add_path(paths, seen, path) + for seg in (parsed.get("segments") if isinstance(parsed, dict) else []) or []: + if isinstance(seg, dict): + _add_path(paths, seen, seg.get("imageFile") or seg.get("image_file") or seg.get("path")) + except Exception: + pass + return paths + + def _resolve_source(path, input_dir): + clean = str(path or "").strip() + if not clean or clean.startswith("data:"): + return None + if os.path.isabs(clean): + return os.path.abspath(os.path.expanduser(clean)) + return os.path.abspath(os.path.join(input_dir, clean.replace("/", os.sep))) + + def _rewrite_segments(segments, path_map): + if not isinstance(segments, list): + return + for seg in segments: + if not isinstance(seg, dict): + continue + for key in ("imageFile", "image_file", "path"): + value = str(seg.get(key) or "").strip() + if value in path_map: + seg[key] = path_map[value] + + def _rewrite_board_paths(board, ordered_paths, path_map): + board["image_paths"] = [path_map.get(path, path) for path in ordered_paths if path_map.get(path, path)] + if isinstance(board.get("images"), list): + for item in board["images"]: + if not isinstance(item, dict): + continue + value = str(item.get("path") or item.get("original_path") or item.get("filename") or item.get("name") or "").strip() + if value in path_map: + item["original_path"] = value + item["path"] = path_map[value] + _rewrite_segments(board.get("segments"), path_map) + if isinstance(board.get("timeline"), dict): + board["timeline"]["image_paths"] = [path_map.get(path, path) for path in ordered_paths if path_map.get(path, path)] + _rewrite_segments(board["timeline"].get("segments"), path_map) + if isinstance(board.get("timeline_data"), str) and board["timeline_data"].strip(): + try: + parsed = json.loads(board["timeline_data"]) + if isinstance(parsed, dict): + parsed["image_paths"] = [path_map.get(path, path) for path in ordered_paths if path_map.get(path, path)] + _rewrite_segments(parsed.get("segments"), path_map) + board["timeline_data"] = json.dumps(parsed, indent=2) + except Exception: + pass + + def _write_data_url(value, out_path): + header, _, payload = str(value or "").partition(",") + if not header.startswith("data:image") or not payload: + return False + with open(out_path, "wb") as fh: + fh.write(base64.b64decode(payload)) + return True + + data = await request.json() + board = data.get("board") + if not isinstance(board, dict): + return web.json_response({"error": "Missing board object"}, status=400) + + input_dir = folder_paths.get_input_directory() + package_root = os.path.join(input_dir, "IAMCCS_shotboard_packages") + package_name = _sanitize(data.get("package_name") or data.get("label") or f"cine_shotboard_{int(time.time())}") + package_dir = os.path.join(package_root, package_name) + images_dir = os.path.join(package_dir, "images") + os.makedirs(images_dir, exist_ok=True) + + original_board = copy.deepcopy(board) + ordered_paths = _collect_paths(original_board) + path_map = {} + manifest_images = [] + allowed_ext = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tif", ".tiff", ".avif"} + + for index, original_path in enumerate(ordered_paths, start=1): + source_path = _resolve_source(original_path, input_dir) + source_name = os.path.basename(source_path or original_path) or f"ref_{index:03d}.png" + ext = os.path.splitext(source_name)[1].lower() + if ext not in allowed_ext: + ext = ".png" + clean_stem = _sanitize(os.path.splitext(source_name)[0], f"ref_{index:03d}")[:52] + filename = f"ref_{index:03d}_{clean_stem}{ext}" + target_path = os.path.join(images_dir, filename) + rel_path = "/".join(["IAMCCS_shotboard_packages", package_name, "images", filename]) + entry = { + "ref": index, + "original_path": original_path, + "package_path": rel_path, + "filename": filename, + } + try: + if str(original_path).startswith("data:image"): + if not _write_data_url(original_path, target_path): + raise ValueError("Unsupported data URL") + else: + if not source_path or not os.path.isfile(source_path): + raise FileNotFoundError(f"Image not found: {original_path}") + shutil.copy2(source_path, target_path) + entry["source_path"] = source_path + entry["bytes"] = os.path.getsize(target_path) + path_map[original_path] = rel_path + except Exception as err: + entry["error"] = str(err) + manifest_images.append(entry) + + packaged_board = copy.deepcopy(original_board) + _rewrite_board_paths(packaged_board, ordered_paths, path_map) + saved_at = time.strftime("%Y-%m-%dT%H:%M:%S%z") + packaged_board["metadata"] = { + **(packaged_board.get("metadata") or {}), + "packaged_at": saved_at, + "package_schema": "iamccs.cine.shotboard.package", + } + packaged_board["package"] = { + "name": package_name, + "root": package_dir, + "images_dir": "images", + "images": manifest_images, + } + + manifest = { + "metadata": { + "schema": "iamccs.cine.shotboard.package", + "schema_version": 2, + "saved_at": saved_at, + "package_name": package_name, + "package_root": package_dir, + }, + "board_file": "board.json", + "images_dir": "images", + "image_count": len(ordered_paths), + "images": manifest_images, + } + + with open(os.path.join(package_dir, "board.json"), "w", encoding="utf-8") as fh: + json.dump(packaged_board, fh, indent=2, ensure_ascii=False) + with open(os.path.join(package_dir, "manifest.json"), "w", encoding="utf-8") as fh: + json.dump(manifest, fh, indent=2, ensure_ascii=False) + + failed = sum(1 for item in manifest_images if item.get("error")) + return web.json_response({ + "ok": True, + "package_name": package_name, + "package_dir": package_dir, + "board_file": os.path.join(package_dir, "board.json"), + "manifest_file": os.path.join(package_dir, "manifest.json"), + "image_count": len(ordered_paths), + "failed_images": failed, + "images": manifest_images, + }) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + @routes.get("/api/iamccs/hw_probe") async def iamccs_hw_probe_endpoint(request): try: diff --git a/iamccs_cine_duration_lock.py b/iamccs_cine_duration_lock.py new file mode 100644 index 0000000..7978676 --- /dev/null +++ b/iamccs_cine_duration_lock.py @@ -0,0 +1,234 @@ +import json +import math +from typing import Any, Dict, Optional + +try: + import torch +except Exception: # pragma: no cover - ComfyUI normally provides torch + torch = None + + +SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX" + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except Exception: + return float(default) + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(round(float(value))) + except Exception: + return int(default) + + +def _safe_json_loads(value: Any) -> Any: + try: + return json.loads(str(value or "")) + except Exception: + return None + + +def _round_ltx_frames(frames: int, mode: str) -> int: + value = max(1, int(frames)) + if str(mode) == "none": + return value + rounded = int(round((value - 1) / 8.0) * 8 + 1) + if str(mode) == "nearest_8n_plus_1": + return max(1, rounded) + return max(1, int(math.ceil(max(0, value - 1) / 8.0) * 8 + 1)) + + +def _cine_linx_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 _cine_linx_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 _duration_from_cine_linx(cine_linx: Any) -> Optional[float]: + resources = _cine_linx_resources(cine_linx) + outputs = _cine_linx_outputs(cine_linx) + payload = resources.get("cine_payload") if isinstance(resources.get("cine_payload"), dict) else {} + for source in (resources, outputs, payload): + for key in ("cine_duration_seconds", "duration_seconds", "duration", "duration_sec"): + if isinstance(source, dict) and key in source: + value = _safe_float(source.get(key), 0.0) + if value > 0: + return value + return None + + +def _frame_rate_from_cine_linx(cine_linx: Any) -> Optional[int]: + resources = _cine_linx_resources(cine_linx) + outputs = _cine_linx_outputs(cine_linx) + payload = resources.get("cine_payload") if isinstance(resources.get("cine_payload"), dict) else {} + for source in (resources, outputs, payload): + for key in ("cine_frame_rate", "frame_rate", "fps"): + if isinstance(source, dict) and key in source: + value = _safe_int(source.get(key), 0) + if value > 0: + return value + return None + + +def _duration_from_timeline_data(timeline_data: Any) -> Optional[float]: + data = _safe_json_loads(timeline_data) + if not isinstance(data, dict): + return None + for key in ("duration_seconds", "duration", "duration_sec"): + value = _safe_float(data.get(key), 0.0) + if value > 0: + return value + payload = data.get("payload") if isinstance(data.get("payload"), dict) else {} + for key in ("duration_seconds", "duration", "duration_sec"): + value = _safe_float(payload.get(key), 0.0) + if value > 0: + return value + return None + + +def _guide_count_from_timeline_data(timeline_data: Any) -> int: + data = _safe_json_loads(timeline_data) + if isinstance(data, dict): + rows = data.get("guides") or data.get("keyframes") or data.get("rows") or data.get("segments") or [] + elif isinstance(data, list): + rows = data + else: + rows = [] + count = 0 + for row in rows: + if not isinstance(row, dict): + continue + if row.get("use_guide") is False: + continue + ref = _safe_int(row.get("ref", row.get("reference_index", row.get("image_ref", 0))), 0) + strength = _safe_float(row.get("strength", row.get("guide_strength", row.get("force", 1.0))), 1.0) + if ref > 0 and strength > 0: + count += 1 + return count + + +class IAMCCS_CineBoardDurationLock: + """Lock production latent length to the shotboard duration, without guide-tail padding.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "timeline_data": ("STRING", {"default": "", "multiline": True}), + "duration_seconds": ("INT", {"default": 8, "min": 1, "max": 36000, "step": 1}), + "frame_rate": ("INT", {"default": 24, "min": 1, "max": 120, "step": 1}), + "ltx_round_mode": (["up_8n_plus_1", "nearest_8n_plus_1", "none"], {"default": "up_8n_plus_1"}), + }, + "optional": { + "cine_linx": (SUPERNODE_LINX_TYPE,), + }, + } + + RETURN_TYPES = ("INT", "INT", "FLOAT", "INT", "STRING") + RETURN_NAMES = ("model_length_frames", "target_frames", "duration_seconds_exact", "frame_rate_int", "report") + FUNCTION = "compute" + CATEGORY = "IAMCCS/Cine/00 Utilities" + + def compute(self, timeline_data, duration_seconds, frame_rate, ltx_round_mode, cine_linx=None): + fps = max(1, _frame_rate_from_cine_linx(cine_linx) or _safe_int(frame_rate, 24)) + duration = ( + _duration_from_cine_linx(cine_linx) + or _duration_from_timeline_data(timeline_data) + or max(0.1, _safe_float(duration_seconds, 8.0)) + ) + raw_frames = max(1, int(round(float(duration) * fps))) + target_frames = _round_ltx_frames(raw_frames, str(ltx_round_mode)) + guide_count = _guide_count_from_timeline_data(timeline_data) + report = json.dumps( + { + "node": "IAMCCS_CineBoardDurationLock", + "duration_seconds_exact": float(duration), + "frame_rate": int(fps), + "raw_frames": int(raw_frames), + "target_frames": int(target_frames), + "model_length_frames": int(target_frames), + "guide_count_observed": int(guide_count), + "guide_tail_padding_frames": 0, + "ltx_round_mode": str(ltx_round_mode), + "truth": "The production latent length is locked to the board duration. Guides do not extend final narrative duration.", + }, + ensure_ascii=False, + indent=2, + ) + return int(target_frames), int(target_frames), float(duration), int(fps), report + + +class IAMCCS_CineLatentDurationCrop: + """Safety crop for video latents when a workflow branch still carries padded tail frames.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "latent": ("LATENT",), + "target_frames": ("INT", {"default": 81, "min": 1, "max": 36000, "step": 1}), + "ltx_time_factor": ("INT", {"default": 8, "min": 1, "max": 32, "step": 1}), + } + } + + RETURN_TYPES = ("LATENT", "STRING") + RETURN_NAMES = ("latent", "report") + FUNCTION = "crop" + CATEGORY = "IAMCCS/Cine/00 Utilities" + + def crop(self, latent, target_frames, ltx_time_factor): + if not isinstance(latent, dict) or torch is None: + return latent, json.dumps({"node": "IAMCCS_CineLatentDurationCrop", "changed": False, "reason": "invalid latent"}) + samples = latent.get("samples") + if not torch.is_tensor(samples) or samples.ndim < 3: + return latent, json.dumps({"node": "IAMCCS_CineLatentDurationCrop", "changed": False, "reason": "missing samples tensor"}) + target = max(1, _safe_int(target_frames, 1)) + time_factor = max(1, _safe_int(ltx_time_factor, 8)) + target_latent_frames = max(1, int(math.ceil(max(1, target - 1) / float(time_factor))) + 1) + current_latent_frames = int(samples.shape[2]) if samples.ndim == 5 else int(samples.shape[0]) + next_latent = dict(latent) + if samples.ndim == 5 and current_latent_frames > target_latent_frames: + next_latent["samples"] = samples[:, :, :target_latent_frames, :, :].clone() + elif samples.ndim != 5 and current_latent_frames > target_latent_frames: + next_latent["samples"] = samples[:target_latent_frames].clone() + if "noise_mask" in latent and torch.is_tensor(latent["noise_mask"]): + mask = latent["noise_mask"] + if mask.ndim == 5 and int(mask.shape[2]) > target_latent_frames: + next_latent["noise_mask"] = mask[:, :, :target_latent_frames, :, :].clone() + elif mask.ndim != 5 and int(mask.shape[0]) > target_latent_frames: + next_latent["noise_mask"] = mask[:target_latent_frames].clone() + report = json.dumps( + { + "node": "IAMCCS_CineLatentDurationCrop", + "target_pixel_frames": int(target), + "target_latent_frames": int(target_latent_frames), + "current_latent_frames": int(current_latent_frames), + "changed": bool(current_latent_frames > target_latent_frames), + }, + ensure_ascii=False, + indent=2, + ) + return next_latent, report + + +NODE_CLASS_MAPPINGS = { + "IAMCCS_CineBoardDurationLock": IAMCCS_CineBoardDurationLock, + "IAMCCS_CineLatentDurationCrop": IAMCCS_CineLatentDurationCrop, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "IAMCCS_CineBoardDurationLock": "IAMCCS Cine Board Duration Lock", + "IAMCCS_CineLatentDurationCrop": "IAMCCS Cine Latent Duration Crop", +} diff --git a/iamccs_cine_flf_engine_simple_dyno.py b/iamccs_cine_flf_engine_simple_dyno.py new file mode 100644 index 0000000..8114721 --- /dev/null +++ b/iamccs_cine_flf_engine_simple_dyno.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import torch +from comfy_api.latest import io as comfy_io + +try: + from comfy_extras.nodes_lt import _append_guide_attention_entry +except Exception: # pragma: no cover - depends on ComfyUI/KJ/LTX version + _append_guide_attention_entry = None + +from .iamccs_wdc_ltx_port import IAMCCS_CineFLFEngineSimple + + +class IAMCCS_CineFLFEngineSimpleDyno(IAMCCS_CineFLFEngineSimple): + """Timeline/manual FLF engine with locked guide frames plus dynamic attention.""" + + @classmethod + def define_schema(cls): + inputs = [ + comfy_io.Conditioning.Input("positive", tooltip="Positive conditioning to which guide keyframe info will be added"), + comfy_io.Conditioning.Input("negative", tooltip="Negative conditioning to which guide keyframe info will be added"), + comfy_io.Vae.Input("vae", tooltip="Video VAE used to encode the guide images"), + comfy_io.Latent.Input("latent", tooltip="Video latent, guides are added to this latent"), + comfy_io.Image.Input("multi_input", tooltip="Batched images from the Cine Shotboard or Reference Board"), + ] + + inputs.append(comfy_io.Int.Input( + "num_images", + default=0, + min=0, + max=50, + step=1, + display_name="images_loaded", + tooltip="How many reference guide slots to read.", + )) + inputs.append(comfy_io.Combo.Input( + "insert_mode", + options=["frames", "seconds"], + default="frames", + tooltip="How manual_keyframes are interpreted.", + )) + inputs.append(comfy_io.Int.Input( + "frame_rate", + default=24, + min=1, + max=120, + step=1, + tooltip="FPS used when manual_keyframes/timeline_data use seconds.", + )) + + inputs.append(comfy_io.String.Input( + "timeline_data", + default="", + multiline=True, + tooltip="Optional synced Cine Shotboard FLF timeline. When connected, this overrides manual keyframes.", + )) + inputs.append(comfy_io.String.Input( + "manual_keyframes", + default="0 | 1 | 1.00\n96 | 2 | 0.92\n184 | 3 | 0.88\n288 | 4 | 0.84\n-1 | 5 | 0.88", + multiline=True, + tooltip="Compact fallback. Frames mode: frame | ref | strength. Seconds mode: second | ref | strength.", + )) + + return comfy_io.Schema( + node_id="IAMCCS_CineFLFEngineSimpleDyno", + display_name="IAMCCS Cine FLF Engine Simple Dyno", + category="IAMCCS/Cine/02 Single Generation", + description=( + "Compact IAMCCS technical FLF engine. Dyno keeps append_keyframe image locking " + "and also adds legacy guide attention entries for motion-heavy shots." + ), + inputs=inputs, + outputs=[ + comfy_io.Conditioning.Output(display_name="positive"), + comfy_io.Conditioning.Output(display_name="negative"), + comfy_io.Latent.Output(display_name="latent", tooltip="Video latent with added FLF guides"), + ], + ) + + @classmethod + def _append_dyno_attention(cls, positive, negative, encoded, strength): + if _append_guide_attention_entry is None: + return positive, negative, False + pre_filter_count = int(encoded.shape[2] * encoded.shape[3] * encoded.shape[4]) + guide_latent_shape = [int(v) for v in encoded.shape[2:]] + positive, negative = _append_guide_attention_entry( + positive, + negative, + pre_filter_count, + guide_latent_shape, + strength=float(strength), + ) + return positive, negative, True + + @classmethod + def execute(cls, positive, negative, vae, latent, multi_input, num_images, **kwargs) -> comfy_io.NodeOutput: + scale_factors = vae.downscale_index_formula + latent_image = latent["samples"].clone() + + if "noise_mask" in latent: + noise_mask = latent["noise_mask"].clone() + else: + batch, _, latent_frames, _, _ = latent_image.shape + noise_mask = torch.ones( + (batch, 1, latent_frames, 1, 1), + dtype=torch.float32, + device=latent_image.device, + ) + + _, _, latent_length, latent_height, latent_width = latent_image.shape + batch_size = multi_input.shape[0] if multi_input is not None else 0 + insert_mode = kwargs.get("insert_mode", "frames") + frame_rate = kwargs.get("frame_rate", 24) + try: + image_limit = max(0, min(50, int(num_images))) + except Exception: + image_limit = 0 + + if image_limit <= 0 or batch_size <= 0: + return comfy_io.NodeOutput(positive, negative, {"samples": latent_image, "noise_mask": noise_mask}) + + synced_keyframes = cls._parse_synced_timeline(kwargs.get("timeline_data", ""), frame_rate) + if synced_keyframes: + guide_items = synced_keyframes + else: + guide_items = cls._parse_manual_keyframes( + kwargs.get("manual_keyframes", ""), + insert_mode, + frame_rate, + image_limit, + ) + + max_ref = min(batch_size, image_limit) + applied = 0 + attention_entries = 0 + for guide in guide_items: + i = int(guide.get("reference_index", 1)) + if i > max_ref: + continue + img = multi_input[i - 1:i] + if img is None: + continue + + f_idx = int(guide.get("frame", 0)) + strength = cls._clamp_float(guide.get("strength", 1.0), 0.0, 1.0, 1.0) + image_1, encoded = cls.encode(vae, latent_width, latent_height, img, scale_factors) + frame_idx, latent_idx = cls.get_latent_index(positive, latent_length, len(image_1), f_idx, scale_factors) + assert latent_idx + encoded.shape[2] <= latent_length, "Conditioning frames exceed the length of the latent sequence." + + positive, negative, latent_image, noise_mask = cls.append_keyframe( + positive, + negative, + frame_idx, + latent_image, + noise_mask, + encoded, + strength, + scale_factors, + ) + positive, negative, did_attention = cls._append_dyno_attention(positive, negative, encoded, strength) + applied += 1 + if did_attention: + attention_entries += 1 + + print( + "[IAMCCS CineFLFEngineSimpleDyno] " + f"guides={applied} guide_attention_available={_append_guide_attention_entry is not None} " + f"guide_attention_entries={attention_entries}" + ) + return comfy_io.NodeOutput(positive, negative, {"samples": latent_image, "noise_mask": noise_mask}) diff --git a/iamccs_cine_flf_productor_dyno.py b/iamccs_cine_flf_productor_dyno.py new file mode 100644 index 0000000..86a1801 --- /dev/null +++ b/iamccs_cine_flf_productor_dyno.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import json +from copy import deepcopy +from typing import Any, Dict + +import torch +from comfy_extras.nodes_lt import LTXVAddGuide + +try: + from comfy_extras.nodes_lt import _append_guide_attention_entry +except Exception: # pragma: no cover - depends on ComfyUI/KJ/LTX version + _append_guide_attention_entry = None + +from .iamccs_cine_nodes import ( + IAMCCS_CineFLFProductor, + _clamp, + _json_report, + _safe_bool, + _safe_float, + _safe_int, +) + + +class IAMCCS_CineFLFProductorDyno(IAMCCS_CineFLFProductor): + """Dynamic guide Productor variant. + + The normal Productor keeps the Filmmaker V3 clean guide path for maximum + image lock. Dyno keeps the same inputs/outputs and guide plan contract, but + also appends the legacy guide attention entry used by the older dynamic + FLF path. This makes it safe to A/B test motion-heavy shots without + changing existing workflows. + """ + + CATEGORY = "IAMCCS/Cine/02 Single Generation" + + @classmethod + def INPUT_TYPES(cls): + schema = deepcopy(IAMCCS_CineFLFProductor.INPUT_TYPES()) + schema.setdefault("optional", {}) + schema["optional"]["guide_attention_mode"] = ( + ["skip_step_transitions", "all_guides", "off"], + { + "default": "skip_step_transitions", + "tooltip": ( + "Controls Dyno guide_attention_entries. " + "skip_step_transitions keeps step-transition pairs on the clean Production guide path." + ), + }, + ) + return schema + + @staticmethod + def _normalise_attention_mode(value: Any) -> str: + mode = str(value or "skip_step_transitions").strip().lower() + if mode in {"all", "all_guides", "on", "true"}: + return "all_guides" + if mode in {"off", "none", "false", "0"}: + return "off" + return "skip_step_transitions" + + @staticmethod + def _apply_attention_entry(positive, negative, encoded, strength: float): + if _append_guide_attention_entry is None: + return positive, negative, False, "guide attention helper unavailable" + pre_filter_count = int(encoded.shape[2] * encoded.shape[3] * encoded.shape[4]) + guide_latent_shape = [int(v) for v in encoded.shape[2:]] + positive, negative = _append_guide_attention_entry( + positive, + negative, + pre_filter_count, + guide_latent_shape, + strength=float(strength), + ) + return positive, negative, True, "" + + @staticmethod + def _execute_guide_data( + positive, + negative, + vae, + latent, + guide_data: Dict[str, Any], + strength_scale: float, + tail_safety_frames: int, + guide_attention_mode: str = "skip_step_transitions", + ): + latent_samples = latent.get("samples") if isinstance(latent, dict) else None + if not torch.is_tensor(latent_samples) or latent_samples.ndim != 5: + return positive, negative, latent, { + "applied_guides": [], + "skipped_guides": [{"reason": "invalid latent samples"}], + "latent_pixel_frames": 0, + "guide_attention_available": _append_guide_attention_entry is not None, + "guide_attention_entries": False, + "guide_attention_entry_count": 0, + } + + scale_factors = vae.downscale_index_formula + latent_image = latent_samples.clone() + if "noise_mask" in latent: + noise_mask = latent["noise_mask"].clone() + else: + batch, _, latent_frames, _, _ = latent_image.shape + noise_mask = torch.ones((batch, 1, latent_frames, 1, 1), dtype=torch.float32, device=latent_image.device) + + _, _, latent_length, latent_height, latent_width = latent_image.shape + time_scale_factor = int(scale_factors[0]) if scale_factors else 8 + latent_pixel_frames = max(1, (int(latent_length) - 1) * max(1, time_scale_factor) + 1) + max_frame = max(0, int(latent_pixel_frames) - 1 - max(0, int(tail_safety_frames))) + images = guide_data.get("images", []) if isinstance(guide_data, dict) else [] + insert_frames = guide_data.get("insert_frames", []) if isinstance(guide_data, dict) else [] + strengths = guide_data.get("strengths", []) if isinstance(guide_data, dict) else [] + labels = guide_data.get("labels", []) if isinstance(guide_data, dict) else [] + refs = guide_data.get("reference_indices", []) if isinstance(guide_data, dict) else [] + step_sources = guide_data.get("step_transition_sources", []) if isinstance(guide_data, dict) else [] + step_targets = guide_data.get("step_transition_targets", []) if isinstance(guide_data, dict) else [] + step_protected = guide_data.get("step_transition_protected", []) if isinstance(guide_data, dict) else [] + attention_mode = IAMCCS_CineFLFProductorDyno._normalise_attention_mode(guide_attention_mode) + + applied = [] + skipped = [] + attention_entries = 0 + attention_skipped = [] + for idx, img in enumerate(images): + label = str(labels[idx]) if idx < len(labels) else f"guide_{idx + 1}" + ref = _safe_int(refs[idx], idx + 1) if idx < len(refs) else idx + 1 + if not torch.is_tensor(img) or img.ndim != 4 or int(img.shape[0]) <= 0: + skipped.append({"label": label, "reference_index": int(ref), "reason": "empty guide image tensor"}) + continue + requested_frame = _safe_int(insert_frames[idx], 0) if idx < len(insert_frames) else 0 + frame_idx = requested_frame if requested_frame < 0 else min(max(0, requested_frame), max_frame) + strength = _clamp((_safe_float(strengths[idx], 1.0) if idx < len(strengths) else 1.0) * float(strength_scale), 0.0, 1.0, 1.0) + if strength <= 0: + skipped.append({"label": label, "reference_index": int(ref), "frame": int(frame_idx), "reason": "zero strength"}) + continue + try: + image_1, encoded = LTXVAddGuide.encode(vae, latent_width, latent_height, img, scale_factors) + conditioning_frame, latent_idx = LTXVAddGuide.get_latent_index(positive, latent_length, len(image_1), frame_idx, scale_factors) + if latent_idx + encoded.shape[2] > latent_length: + skipped.append({ + "label": label, + "reference_index": int(ref), + "frame": int(frame_idx), + "reason": "guide exceeds latent length", + }) + continue + positive, negative, latent_image, noise_mask = LTXVAddGuide.append_keyframe( + positive, + negative, + conditioning_frame, + latent_image, + noise_mask, + encoded, + float(strength), + scale_factors, + ) + is_step_source = _safe_bool(step_sources[idx], False) if idx < len(step_sources) else False + is_step_target = _safe_bool(step_targets[idx], False) if idx < len(step_targets) else False + if idx > 0 and idx - 1 < len(step_sources): + is_step_target = bool(is_step_target or _safe_bool(step_sources[idx - 1], False)) + is_step_protected = _safe_bool(step_protected[idx], False) if idx < len(step_protected) else False + is_step_protected = bool(is_step_protected or is_step_source or is_step_target) + if attention_mode == "off": + did_attention = False + attention_reason = "guide_attention_mode=off" + elif attention_mode == "skip_step_transitions" and is_step_protected: + positive, negative, did_attention, attention_reason = IAMCCS_CineFLFProductorDyno._apply_attention_entry( + positive, + negative, + encoded, + 0.0, + ) + if did_attention: + attention_reason = "step transition protected; guide_attention_entry kept with zero strength for LTX mask alignment" + else: + positive, negative, did_attention, attention_reason = IAMCCS_CineFLFProductorDyno._apply_attention_entry( + positive, + negative, + encoded, + float(strength), + ) + if did_attention: + attention_entries += 1 + elif attention_reason: + attention_skipped.append({ + "label": label, + "reference_index": int(ref), + "reason": attention_reason, + "step_transition_protected": bool(is_step_protected), + }) + except Exception as exc: + skipped.append({"label": label, "reference_index": int(ref), "frame": int(frame_idx), "reason": f"dyno guide apply failed: {exc}"}) + continue + applied.append({ + "label": label, + "reference_index": int(ref), + "requested_frame": int(requested_frame), + "frame": int(frame_idx), + "strength": float(strength), + "guide_attention_entry": did_attention, + "guide_attention_strength": 0.0 if (attention_mode == "skip_step_transitions" and is_step_protected) else float(strength) if did_attention else None, + "step_transition_protected": bool(is_step_protected), + }) + + return positive, negative, {"samples": latent_image, "noise_mask": noise_mask}, { + "applied_guides": applied, + "skipped_guides": skipped, + "attention_skipped": attention_skipped, + "latent_pixel_frames": int(latent_pixel_frames), + "guide_attention_available": _append_guide_attention_entry is not None, + "guide_attention_entries": attention_entries > 0, + "guide_attention_entry_count": int(attention_entries), + "guide_attention_mode": attention_mode, + "guide_strength_semantics": "append_keyframe_plus_legacy_guide_attention_entry", + "compatibility_mode": "dyno_motion_conditioning_guide", + } + + def execute( + self, + positive, + negative, + vae, + latent, + multi_input, + guide_plan_json, + strength_scale, + tail_safety_frames, + timeline_data="", + duration_seconds=20, + frame_rate=24, + guide_data=None, + guide_attention_mode="skip_step_transitions", + ): + attention_mode = self._normalise_attention_mode(guide_attention_mode) + if isinstance(guide_data, dict) and guide_data.get("images"): + positive, negative, current_latent, guide_data_report = self._execute_guide_data( + positive, + negative, + vae, + latent, + guide_data, + float(strength_scale), + int(tail_safety_frames), + attention_mode, + ) + report = _json_report({ + "node": "IAMCCS_CineFLFProductorDyno", + "mode": "wdc_guide_data_compatible_dyno", + "guide_count": len(guide_data.get("images", [])), + "applied_count": len(guide_data_report.get("applied_guides", [])), + "skipped_count": len(guide_data_report.get("skipped_guides", [])), + **guide_data_report, + "truth": ( + "Dyno consumed GUIDE_DATA and appended guide_attention_entries according to guide_attention_mode. " + "Step-transition-protected guides stay on the clean Production guide path by default." + ), + }) + return positive, negative, current_latent, report + + plan = self._parse_plan(guide_plan_json, timeline_data, duration_seconds, frame_rate) + clean_guide_data = self._guide_data_from_plan_and_images(plan, multi_input, float(strength_scale)) + positive, negative, current_latent, guide_data_report = self._execute_guide_data( + positive, + negative, + vae, + latent, + clean_guide_data, + 1.0, + int(tail_safety_frames), + attention_mode, + ) + report = _json_report({ + "node": "IAMCCS_CineFLFProductorDyno", + "mode": "explicit_dyno_guide_productor", + "plan_source": plan.get("source", "") if isinstance(plan, dict) else "", + "reference_count": int(multi_input.shape[0]) if torch.is_tensor(multi_input) and multi_input.ndim == 4 else 0, + "guide_count": len(clean_guide_data.get("images", [])), + "applied_count": len(guide_data_report.get("applied_guides", [])), + "skipped_count": len(guide_data_report.get("skipped_guides", [])), + **guide_data_report, + "truth": ( + "Dyno uses the same guide plan and image slots as CineFLFProductor, then conditionally appends " + "legacy guide_attention_entries. Default skip_step_transitions protects dolly/bridge pairs." + ), + }) + return positive, negative, current_latent, report diff --git a/iamccs_cine_nodes.py b/iamccs_cine_nodes.py index 06c5821..41fd277 100644 --- a/iamccs_cine_nodes.py +++ b/iamccs_cine_nodes.py @@ -1,9 +1,12 @@ from __future__ import annotations import importlib.util +import base64 +import datetime import io import hashlib import json +import math import os import re import sys @@ -19,11 +22,6 @@ import torch.nn.functional as F from comfy_extras.nodes_lt import LTXVAddGuide from PIL import Image, ImageOps -try: - from comfy_extras.nodes_lt import _append_guide_attention_entry -except Exception: - _append_guide_attention_entry = None - from .iamccs_ltx2_cinematic_flf import ( IAMCCS_LTX2_AudioPromptDirector, IAMCCS_LTX2_CinematicLineStacker, @@ -112,6 +110,27 @@ def _clamp(value: Any, lo: float, hi: float, default: float = 0.0) -> float: return max(float(lo), min(float(hi), number)) +def _wdc_image_guide_strength(row: Dict[str, Any], fallback: float = 1.0) -> float: + """Single LTX guide strength. + + Mirrors LTX Director semantics: one per-image guide strength controls the + keyframe conditioning mask. IAMCCS no longer treats Image Lock as a second + stronger override. + """ + for key in ( + "guide_strength", + "guideStrength", + "strength", + "force", + "motion_force", + "wdc_guide_strength", + "wdcGuideStrength", + ): + if isinstance(row, dict) and row.get(key) is not None: + return _clamp(row.get(key), 0.0, 1.0, fallback) + return _clamp(fallback, 0.0, 1.0, 1.0) + + def _safe_int(value: Any, default: int = 0) -> int: try: return int(round(float(value))) @@ -126,10 +145,37 @@ def _safe_float(value: Any, default: float = 0.0) -> float: return float(default) +def _iamccs_cine_resize_method(value: Any) -> str: + method = str(value or "").strip() + return method if method in {"crop", "pad", "keep proportion", "stretch"} else "crop" + + +def _safe_bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + text = str(value).strip().lower() + if text in {"1", "true", "yes", "on", "y"}: + return True + if text in {"0", "false", "no", "off", "n"}: + return False + return default + + def _json_report(data: Dict[str, Any]) -> str: return json.dumps(data, ensure_ascii=False, indent=2) +def _safe_json_loads(text: Any, default: Optional[Any] = None) -> Any: + try: + return json.loads(str(text or "")) + except Exception: + return {} if default is None else default + + def _split_pipe(line: str, expected: int) -> List[str]: parts = [part.strip() for part in str(line or "").split("|")] while len(parts) < expected: @@ -255,6 +301,43 @@ def _cine_change_fingerprint(payload: Dict[str, Any]) -> str: return hashlib.sha256(encoded.encode("utf-8", errors="replace")).hexdigest() +def _iamccs_ltx_compress_image(tensor: torch.Tensor, crf: int) -> torch.Tensor: + """Mirror LTX Director's guide-image compression path for shotboard-owned images.""" + crf = int(crf or 0) + if crf <= 0 or not torch.is_tensor(tensor) or tensor.ndim != 4 or tensor.shape[0] < 1: + return tensor + img = tensor[0] + h = (int(img.shape[0]) // 2) * 2 + w = (int(img.shape[1]) // 2) * 2 + if h <= 0 or w <= 0: + return tensor + img_np = (img[:h, :w] * 255.0).clamp(0, 255).byte().cpu().numpy() + try: + import av + + buffer = io.BytesIO() + container = av.open(buffer, mode="w", format="mp4") + stream = container.add_stream("libx264", rate=1) + stream.width = w + stream.height = h + stream.pix_fmt = "yuv420p" + stream.options = {"crf": str(max(0, min(51, crf))), "preset": "fast"} + frame = av.VideoFrame.from_ndarray(img_np, format="rgb24") + for packet in stream.encode(frame): + container.mux(packet) + for packet in stream.encode(): + container.mux(packet) + container.close() + buffer.seek(0) + with av.open(buffer, mode="r") as decoded_container: + for decoded_frame in decoded_container.decode(video=0): + arr = decoded_frame.to_ndarray(format="rgb24").astype(np.float32) / 255.0 + return torch.from_numpy(arr).unsqueeze(0) + except Exception as exc: + print(f"IAMCCS Cine warning: LTX-style image compression failed, using uncompressed guide image: {exc}") + return tensor + + class IAMCCS_CineReferenceBoard: """Filmmaker-facing reference board for Cine FLF and multi-shot workflows.""" @@ -418,6 +501,66 @@ class IAMCCS_CineReferenceBoard: padded = results + [torch.zeros((1, 64, 64, 3))] * (50 - len(results)) return (multi_output, *padded[:50]) + 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_b64 = str(source.get("imageB64", source.get("image_b64", "")) or "").strip() + if image_file: + full_path = image_file + if not os.path.exists(full_path): + full_path = os.path.join(folder_paths.get_input_directory(), image_file) + if os.path.exists(full_path): + image = Image.open(full_path) + elif image_b64 and not image_b64.startswith("/view?"): + if "," in image_b64: + image_b64 = image_b64.split(",", 1)[1] + image = Image.open(io.BytesIO(base64.b64decode(image_b64))) + else: + path = str(source or "").strip() + if path: + full_path = path + if not os.path.exists(full_path): + full_path = os.path.join(folder_paths.get_input_directory(), path) + if os.path.exists(full_path): + image = Image.open(full_path) + if image is None: + raise FileNotFoundError(f"image source not found: {source}") + image = ImageOps.exif_transpose(image).convert("RGB") + image_np = np.array(image).astype(np.float32) / 255.0 + image_tensor = torch.from_numpy(image_np)[None,] + method = str(resize_method or "crop") + method = { + "maintain aspect ratio": "keep proportion", + "stretch to fit": "stretch", + }.get(method, method) + image_tensor = self.resize_image( + image_tensor, + int(width or 0), + int(height or 0), + method, + "lanczos", + int(multiple_of or 32), + ) + return _iamccs_ltx_compress_image(image_tensor, int(img_compression or 0)) + + def load_ltx_style_images(self, image_paths, width, height, resize_method="crop", multiple_of=32, img_compression=0): + results = [] + 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 Exception as exc: + print(f"IAMCCS Cine Shotboard warning: could not load internal guide image {path}: {exc}") + if results: + first_shape = results[0].shape + batch_safe = [] + for item in results: + if item.shape != first_shape: + item = self.resize_image(item, int(first_shape[2]), int(first_shape[1]), "crop", "lanczos", 32) + batch_safe.append(torch.clamp(item, 0, 1)) + return torch.cat(batch_safe, dim=0) + return torch.zeros((1, max(64, int(height or 64)), max(64, int(width or 64)), 3)) + class IAMCCS_CineLTXSequencer: """Cine timeline wrapper around the proven LTX guide insertion logic.""" @@ -485,13 +628,22 @@ class IAMCCS_CineLTXSequencer: continue second = _safe_float(row.get("second", row.get("time", row.get("seconds", 0.0))), 0.0) ref = _safe_int(row.get("ref", row.get("reference", row.get("reference_index", idx + 1))), idx + 1) - strength = _clamp(row.get("force", row.get("strength", fallback_strength)), 0.0, 1.0, fallback_strength) + strength = _wdc_image_guide_strength(row, _safe_float(row.get("strength", 1.0), 1.0)) + motion_force = _safe_float(row.get("motion_force", row.get("force", fallback_strength)), fallback_strength) keyframes.append({ "second": second, "reference_index": max(1, min(MAX_CINE_ITEMS, ref)), "strength": strength, + "guide_strength": strength, + "image_lock_strength": strength, + "motion_force": motion_force, + "force": motion_force, "label": str(row.get("label", f"key_{idx + 1}")), "camera": str(row.get("camera", row.get("camera_note", ""))), + "step_transition_enabled": _safe_bool(row.get("step_transition_enabled"), False), + "step_transition_type": str(row.get("step_transition_type", "off") or "off"), + "step_transition_duration": _safe_float(row.get("step_transition_duration", 0.0), 0.0), + "step_transition_arrival": str(row.get("step_transition_arrival", "auto") or "auto"), }) except Exception: keyframes = [] @@ -509,6 +661,10 @@ class IAMCCS_CineLTXSequencer: "second": second, "reference_index": max(1, min(MAX_CINE_ITEMS, ref)), "strength": strength, + "guide_strength": strength, + "image_lock_strength": strength, + "motion_force": fallback_strength, + "force": fallback_strength, "label": parts[3] or f"key_{idx + 1}", "camera": parts[4], }) @@ -523,7 +679,11 @@ class IAMCCS_CineLTXSequencer: { "second": sec, "reference_index": idx + 1, - "strength": float(fallback_strength), + "strength": 1.0, + "guide_strength": 1.0, + "image_lock_strength": 1.0, + "motion_force": float(fallback_strength), + "force": float(fallback_strength), "label": f"reference_{idx + 1}", "camera": "auto spaced cine guide", } @@ -539,9 +699,17 @@ class IAMCCS_CineLTXSequencer: "second": second, "frame": frame, "reference_index": max(1, min(MAX_CINE_ITEMS, _safe_int(row.get("reference_index"), idx + 1))), - "strength": _clamp(row.get("strength"), 0.0, 1.0, fallback_strength), + "strength": _wdc_image_guide_strength(row, _safe_float(row.get("strength", 1.0), 1.0)), + "guide_strength": _wdc_image_guide_strength(row, _safe_float(row.get("strength", 1.0), 1.0)), + "image_lock_strength": _wdc_image_guide_strength(row, _safe_float(row.get("strength", 1.0), 1.0)), + "motion_force": _safe_float(row.get("motion_force", row.get("force", fallback_strength)), fallback_strength), + "force": _safe_float(row.get("motion_force", row.get("force", fallback_strength)), fallback_strength), "label": _normalise_label(str(row.get("label", "")), f"key_{idx + 1}"), "camera": str(row.get("camera", "")), + "step_transition_enabled": _safe_bool(row.get("step_transition_enabled"), False), + "step_transition_type": str(row.get("step_transition_type", "off") or "off"), + "step_transition_duration": _safe_float(row.get("step_transition_duration", 0.0), 0.0), + "step_transition_arrival": str(row.get("step_transition_arrival", "auto") or "auto"), }) return sorted(clean, key=lambda item: ((10**9 if int(item["frame"]) < 0 else int(item["frame"])), int(item["reference_index"]))) @@ -634,16 +802,6 @@ class IAMCCS_CineLTXSequencer: strength, scale_factors, ) - if _append_guide_attention_entry is not None: - pre_filter_count = t.shape[2] * t.shape[3] * t.shape[4] - guide_latent_shape = list(t.shape[2:]) - positive, negative = _append_guide_attention_entry( - positive, - negative, - pre_filter_count, - guide_latent_shape, - strength=strength, - ) applied.append({ "label": key["label"], "reference_index": ref_idx, @@ -673,20 +831,18 @@ class IAMCCS_CineLTXSequencer: "duration_source": "timeline_metadata" if meta else "widget", "references_loaded": batch_size, "latent_pixel_frames": int(latent_pixel_frames), + "guide_attention_entries": False, + "guide_strength_semantics": "clean_noise_mask_without_attention_attenuation", + "compatibility_mode": "v3_clean_conditioning_guide", "applied_keyframes": applied, "skipped_keyframes": skipped, - "truth": "PromptRelay controls temporal text; this node applies image guide keyframes.", + "truth": "PromptRelay controls temporal text; image guides use the same clean append_keyframe conditioning path as Filmmaker V3.", }) return positive, negative, {"samples": latent_image, "noise_mask": noise_mask}, report class IAMCCS_CineAllInOneFLFEngine(IAMCCS_CineLTXSequencer): - """ShotPlanner-driven FLF engine that applies guides through native LTXVAddGuide nodes. - - This is an experimental AllInOne-style path: it keeps the compact ShotPlanner UI, - but each guide is applied by calling ComfyUI's LTXVAddGuide.execute exactly as an - explicit node chain would do. - """ + """ShotPlanner-driven FLF engine using the same clean guide path as V3.""" CATEGORY = "IAMCCS/Cine/02 Single Generation" @@ -698,70 +854,27 @@ class IAMCCS_CineAllInOneFLFEngine(IAMCCS_CineLTXSequencer): @classmethod def execute(cls, positive, negative, vae, latent, multi_input, timeline_data, duration_seconds, frame_rate, fallback_num_images, fallback_strength, tail_safety_frames): - batch_size = int(multi_input.shape[0]) if torch.is_tensor(multi_input) and multi_input.ndim == 4 else 0 - if batch_size <= 0: - report = _json_report({ - "node": "IAMCCS_CineAllInOneFLFEngine", - "mode": "allinone_native_ltxvaddguide_chain", - "applied_keyframes": [], - "skipped_keyframes": [{"reason": "multi_input is empty or invalid"}], - "truth": "No image guides were applied because no valid reference batch reached the engine.", - }) - return positive, negative, latent, report - - fps = max(1, int(frame_rate)) - total_frames = max(1, int(round(float(duration_seconds) * fps))) - max_frame = max(0, total_frames - 1 - max(0, int(tail_safety_frames))) - keyframes = cls._parse_timeline(timeline_data, float(duration_seconds), fps, int(fallback_num_images), float(fallback_strength)) - applied = [] - skipped = [] - current_latent = latent - - for key in keyframes: - ref_idx = int(key["reference_index"]) - if ref_idx > batch_size: - skipped.append({"label": key["label"], "reason": "reference index not loaded", "reference_index": ref_idx}) - continue - img = multi_input[ref_idx - 1 : ref_idx] - if not torch.is_tensor(img) or img.shape[0] <= 0: - skipped.append({"label": key["label"], "reason": "empty image", "reference_index": ref_idx}) - continue - - requested_frame = int(key["frame"]) - frame_idx = requested_frame if requested_frame < 0 else min(max(0, requested_frame), max_frame) - strength = float(key["strength"]) - try: - out = LTXVAddGuide.execute(positive, negative, vae, current_latent, img, frame_idx, strength) - positive, negative, current_latent = out[0], out[1], out[2] - except Exception as exc: - skipped.append({ - "label": key["label"], - "reason": f"native LTXVAddGuide failed: {exc}", - "frame": int(frame_idx), - "reference_index": ref_idx, - }) - continue - - applied.append({ - "label": key["label"], - "reference_index": ref_idx, - "second": float(key["second"]), - "frame": int(frame_idx), - "strength": strength, - "camera": key.get("camera", ""), - }) - - report = _json_report({ - "node": "IAMCCS_CineAllInOneFLFEngine", - "mode": "allinone_native_ltxvaddguide_chain", - "duration_seconds": float(duration_seconds), - "frame_rate": fps, - "references_loaded": batch_size, - "applied_keyframes": applied, - "skipped_keyframes": skipped, - "truth": "Experimental path: ShotPlanner timeline is applied through native LTXVAddGuide.execute, matching the AllInOne explicit guide-chain mechanism more closely than the compact legacy sequencer.", - }) - return positive, negative, current_latent, report + positive, negative, latent_out, report = IAMCCS_CineLTXSequencer.execute( + positive, + negative, + vae, + latent, + multi_input, + timeline_data, + duration_seconds, + frame_rate, + fallback_num_images, + fallback_strength, + tail_safety_frames, + ) + try: + data = json.loads(report) + data["node"] = "IAMCCS_CineAllInOneFLFEngine" + data["mode"] = "v3_clean_conditioning_guide_compat" + report = _json_report(data) + except Exception: + pass + return positive, negative, latent_out, report class IAMCCS_CinePromptRelayTimeline: @@ -770,7 +883,7 @@ class IAMCCS_CinePromptRelayTimeline: return { "required": { "global_prompt": ("STRING", { - "default": "cinematic scene, natural motion, no slideshow, coherent camera movement", + "default": "cinematic scene with natural motion and coherent camera movement", "multiline": True, }), "timeline_data": ("STRING", { @@ -885,7 +998,7 @@ class IAMCCS_CineShotboardTimelinePro: "vae": ("VAE",), "latent": ("LATENT",), "global_prompt": ("STRING", { - "default": "one continuous cinematic shot, coherent motion, no slideshow, no cross dissolve", + "default": "one continuous cinematic shot with coherent motion and connected camera travel", "multiline": True, }), "timeline_data": ("STRING", { @@ -1025,32 +1138,64 @@ class IAMCCS_CineShotboardTimelinePro: raw_frame = row.get("frame", row.get("insert_frame", None)) ref = _safe_int(row.get("ref", row.get("image_ref", row.get("reference_index", idx + 1))), idx + 1) force = _clamp(row.get("force", row.get("strength", default_force)), 0.0, 1.0, default_force) + image_lock_strength = _wdc_image_guide_strength(row, _safe_float(row.get("strength", 1.0), 1.0)) note = str(row.get("note", row.get("camera_note", row.get("prompt", "")))).strip() camera = str(row.get("camera", row.get("camera_move", "cinematic motion"))).strip() transition = str(row.get("transition", row.get("transition_intent", "continuous_motion"))).strip() or "continuous_motion" label = _normalise_label(str(row.get("label", row.get("shot_label", ""))), f"shot_{idx + 1}") legacy_modifiers = cls._as_bool(row.get("use_relay_modifiers", row.get("use_camera_transition_in_relay", row.get("relay_modifiers", False))), False) + camera_relay_mode = str(row.get("camera_relay_mode", row.get("camera_prompt_mode", "before" if legacy_modifiers else "off")) or "off").strip() + transition_relay_mode = str(row.get("transition_relay_mode", row.get("transition_prompt_mode", "safe_only" if legacy_modifiers else "off")) or "off").strip() # Relay text must come from explicit local-prompt fields only. # Notes are private/technical shot notes and must not silently # become PromptRelay segments when the Relay toggles are enabled. relay_prompt = _canonical_relay_prompt(row) + prompt_requested = cls._as_bool( + row.get("use_prompt", row.get("use_relay", row.get("relay", row.get("prompt_relay", True)))), + True, + ) + step_transition_enabled = cls._as_bool(row.get("step_transition_enabled", row.get("stepTransitionEnabled", False)), False) + option_relay_enabled = any( + cls._as_bool(row.get(key, row.get(alias, False)), False) + for key, alias in ( + ("dialogue_pin", "dialoguePin"), + ("image_lock", "imageLock"), + ("motion_boost", "motionBoost"), + ) + ) + modifier_relay_enabled = camera_relay_mode != "off" or transition_relay_mode != "off" rows.append({ "second": max(0.0, second), "frame": _safe_int(raw_frame, int(round(max(0.0, second) * 24))) if raw_frame is not None else None, "ref": max(1, min(MAX_CINE_ITEMS, ref)), "force": force, + "motion_force": force, + "image_lock_strength": image_lock_strength, + "guide_strength": image_lock_strength, "label": label, "camera": camera, "transition": transition, "note": note, "use_guide": cls._as_bool(row.get("use_guide", row.get("guide", True)), True), - "use_prompt": cls._as_bool(row.get("use_prompt", row.get("use_relay", row.get("relay", row.get("prompt_relay", True)))), True), + "use_prompt": bool((prompt_requested and relay_prompt) or step_transition_enabled or option_relay_enabled or modifier_relay_enabled), "relay_prompt": relay_prompt, "use_relay_modifiers": legacy_modifiers, - "camera_relay_mode": str(row.get("camera_relay_mode", row.get("camera_prompt_mode", "before" if legacy_modifiers else "off")) or "off").strip(), - "transition_relay_mode": str(row.get("transition_relay_mode", row.get("transition_prompt_mode", "safe_only" if legacy_modifiers else "off")) or "off").strip(), + "camera_relay_mode": camera_relay_mode, + "transition_relay_mode": transition_relay_mode, "relay_addon_position": str(row.get("relay_addon_position", row.get("addon_position", "after")) or "after").strip(), "relay_modifier_text": str(row.get("relay_modifier_text", row.get("modifier_text", row.get("relay_addon", ""))) or "").strip(), + "dialogue_pin": cls._as_bool(row.get("dialogue_pin", row.get("dialoguePin", False)), False), + "image_lock": cls._as_bool(row.get("image_lock", row.get("imageLock", False)), False), + "motion_boost": cls._as_bool(row.get("motion_boost", row.get("motionBoost", False)), False), + "clean_relay": cls._as_bool(row.get("clean_relay", row.get("cleanRelay", False)), False), + "step_transition_enabled": step_transition_enabled, + "step_transition_type": str(row.get("step_transition_type", row.get("stepTransitionType", "off")) or "off").strip(), + "step_transition_prompt": str(row.get("step_transition_prompt", row.get("stepTransitionPrompt", "")) or "").strip(), + "step_transition_easing": str(row.get("step_transition_easing", row.get("stepTransitionEasing", "ease_in_out")) or "ease_in_out").strip(), + "step_transition_force_curve": str(row.get("step_transition_force_curve", row.get("stepTransitionForceCurve", "late_target")) or "late_target").strip(), + "step_transition_duration": max(0.0, _safe_float(row.get("step_transition_duration", row.get("stepTransitionDuration", row.get("step_seconds", 0.0))), 0.0)), + "step_transition_arrival": str(row.get("step_transition_arrival", row.get("stepTransitionArrival", "auto")) or "auto").strip(), + "step_transition_auto_fit": cls._as_bool(row.get("step_transition_auto_fit", row.get("stepTransitionAutoFit", True)), True), }) if not rows and text: @@ -1059,28 +1204,45 @@ class IAMCCS_CineShotboardTimelinePro: if not line or line.startswith("#"): continue second, ref, force, label, camera, transition, note = _split_pipe(line, 7) + force_value = _clamp(force, 0.0, 1.0, default_force) rows.append({ "second": max(0.0, _safe_float(second, idx * 2.0)), "ref": max(1, min(MAX_CINE_ITEMS, _safe_int(ref, idx + 1))), - "force": _clamp(force, 0.0, 1.0, default_force), + "force": force_value, + "motion_force": force_value, + "image_lock_strength": 1.0, + "guide_strength": 1.0, "label": _normalise_label(label, f"shot_{idx + 1}"), "camera": camera.strip() or "cinematic motion", "transition": transition.strip() or "continuous_motion", "note": note.strip(), "use_guide": True, - "use_prompt": True, + "use_prompt": bool(note.strip()), + "relay_prompt": note.strip(), "use_relay_modifiers": False, "camera_relay_mode": "off", "transition_relay_mode": "off", "relay_addon_position": "after", "relay_modifier_text": "", + "dialogue_pin": False, + "image_lock": False, + "motion_boost": False, + "clean_relay": False, + "step_transition_enabled": False, + "step_transition_type": "off", + "step_transition_prompt": "", + "step_transition_easing": "ease_in_out", + "step_transition_force_curve": "late_target", + "step_transition_duration": 0.0, + "step_transition_arrival": "auto", + "step_transition_auto_fit": True, }) if not rows: rows = [ - {"second": 0.0, "ref": 1, "force": 0.78, "label": "opening_ref", "camera": "slow push-in", "transition": "continuous_motion", "note": "start from the first reference", "use_guide": True, "use_prompt": True, "use_relay_modifiers": False, "camera_relay_mode": "before", "transition_relay_mode": "off", "relay_addon_position": "after", "relay_modifier_text": ""}, - {"second": max(0.1, duration_seconds * 0.55), "ref": 2, "force": default_force, "label": "middle_ref", "camera": "continuous dolly-in", "transition": "continuous_motion", "note": "midpoint visual target", "use_guide": True, "use_prompt": True, "use_relay_modifiers": False, "camera_relay_mode": "before", "transition_relay_mode": "off", "relay_addon_position": "after", "relay_modifier_text": ""}, - {"second": max(0.2, duration_seconds - 0.4), "ref": 3, "force": default_force, "label": "ending_ref", "camera": "slow push-in", "transition": "continuous_motion", "note": "last visual target", "use_guide": True, "use_prompt": True, "use_relay_modifiers": False, "camera_relay_mode": "before", "transition_relay_mode": "off", "relay_addon_position": "after", "relay_modifier_text": ""}, + {"second": 0.0, "ref": 1, "force": 0.78, "label": "opening_ref", "camera": "slow push-in", "transition": "continuous_motion", "note": "start from the first reference", "use_guide": True, "use_prompt": False, "relay_prompt": "", "use_relay_modifiers": False, "camera_relay_mode": "off", "transition_relay_mode": "off", "relay_addon_position": "after", "relay_modifier_text": ""}, + {"second": max(0.1, duration_seconds * 0.55), "ref": 2, "force": default_force, "label": "middle_ref", "camera": "continuous dolly-in", "transition": "continuous_motion", "note": "midpoint visual target", "use_guide": True, "use_prompt": False, "relay_prompt": "", "use_relay_modifiers": False, "camera_relay_mode": "off", "transition_relay_mode": "off", "relay_addon_position": "after", "relay_modifier_text": ""}, + {"second": max(0.2, duration_seconds - 0.4), "ref": 3, "force": default_force, "label": "ending_ref", "camera": "slow push-in", "transition": "continuous_motion", "note": "last visual target", "use_guide": True, "use_prompt": False, "relay_prompt": "", "use_relay_modifiers": False, "camera_relay_mode": "off", "transition_relay_mode": "off", "relay_addon_position": "after", "relay_modifier_text": ""}, ] rows = sorted(rows[:MAX_CINE_ITEMS], key=lambda item: (float(item["second"]), int(item["ref"]))) @@ -1138,18 +1300,18 @@ class IAMCCS_CineShotboardTimelinePro: transition = str(row.get("transition", "continuous_motion") or "continuous_motion").strip() if transition == "hard_cut": if include_hard: - parts.append("hard cut staging; do not morph identities inside this single shot") + parts.append("hard cut staging with clean separated shot identity and stable subject form") return parts elif transition == "match_cut": parts.append("match movement continuity through shape and camera direction") elif transition == "soft_morph": - parts.append("single continuous transformation, avoid visible cross dissolve") + parts.append("single continuous transformation with a feathered optical blend") else: - parts.append("continuous physical camera movement, no slideshow, no cross dissolve") + parts.append("continuous physical camera movement with stable parallax and connected spatial motion") if next_row is not None and transition != "hard_cut": nxt = str(next_row.get("label", "next target")).replace("_", " ") - parts.append(f"move toward {nxt} without flashing reference frames") + parts.append(f"move toward {nxt} through one steady camera path") return parts @classmethod @@ -1161,14 +1323,90 @@ class IAMCCS_CineShotboardTimelinePro: parts.extend(cls._transition_modifier_parts(row, next_row, include_hard=True)) return parts + @classmethod + def _option_modifier_parts(cls, row: Dict[str, Any]) -> List[str]: + parts: List[str] = [] + clean = cls._as_bool(row.get("clean_relay", False), False) + if cls._as_bool(row.get("dialogue_pin", False), False): + parts.append("preserve the exact spoken words and their timing as a pinned dialogue cue") + if not clean and cls._as_bool(row.get("image_lock", False), False): + parts.append("preserve the reference image identity, face, wardrobe, composition, lighting, and spatial layout") + if not clean and cls._as_bool(row.get("motion_boost", False), False): + parts.append("prioritize physical camera motion, parallax, and visible subject movement while keeping identity stable") + return parts + + @classmethod + def _step_transition_parts(cls, row: Dict[str, Any], next_row: Optional[Dict[str, Any]] = None) -> List[str]: + if not cls._as_bool(row.get("step_transition_enabled", False), False): + return [] + transition_type = cls._normalise_mode( + row.get("step_transition_type", "slow_dolly_in"), + {"off", "action_beat", "slow_dolly_in", "hold_then_push", "orbit_bridge", "match_move", "rack_focus", "soft_push"}, + "slow_dolly_in", + ) + if transition_type == "off": + return [] + custom = str(row.get("step_transition_prompt", "") or "").strip() + next_label = str((next_row or {}).get("label", "next guide") or "next guide").replace("_", " ") + library = { + "action_beat": "the action continues through this segment toward the next guide, same subject and location, coherent physical motion", + "slow_dolly_in": "same continuous shot, physical camera dolly forward from the current framing toward a closer framing of the same subject, continuous lens movement, stable identity, same location and lighting", + "hold_then_push": "same continuous shot, hold the current framing briefly, then begin a physical camera push forward toward a closer framing of the same subject", + "orbit_bridge": "same continuous shot, physical camera orbit around the same subject, curved parallax, stable identity and location", + "match_move": "same continuous shot, match the existing movement direction, preserve scale continuity, stable identity and spatial relation", + "rack_focus": "same continuous shot, gradual rack focus with stable framing, same subject and same location", + "soft_push": "same continuous shot, gentle physical camera push forward, stable subject identity and coherent scale change", + } + parts = [custom or library.get(transition_type, library["slow_dolly_in"])] + requested_duration = max(0.0, _safe_float(row.get("step_transition_duration", 0.0), 0.0)) + if requested_duration > 0: + parts.append(f"use an intentional {requested_duration:.1f} second camera-move window") + arrival_default = "very_late" if transition_type == "hold_then_push" else "late" if transition_type in {"slow_dolly_in", "orbit_bridge", "soft_push"} else "middle" + arrival = cls._normalise_mode(row.get("step_transition_arrival", "auto"), {"auto", "early", "middle", "late", "very_late"}, "auto") + if arrival == "auto": + arrival = arrival_default + arrival_text = { + "early": "reach the closer target framing early while remaining one physical camera shot", + "middle": "reach the midpoint of the camera move near the middle of the window", + "late": "hold the source framing longer, then reach the closer target framing late by camera movement", + "very_late": "preserve the source framing longest, reaching the closer target framing only in the final beats", + }.get(arrival, "") + if arrival_text: + parts.append(arrival_text) + easing = cls._normalise_mode( + row.get("step_transition_easing", "ease_in_out"), + {"linear", "ease_in", "ease_out", "ease_in_out"}, + "ease_in_out", + ) + easing_text = { + "linear": "keep the camera move evenly paced across the whole segment", + "ease_in": "start gently and gradually accelerate the camera move", + "ease_out": "move early then slow the camera gently into the final closer framing", + "ease_in_out": "ease in and ease out, with a smooth cinematic middle movement", + }.get(easing, "") + if easing_text: + parts.append(easing_text) + force_curve = cls._normalise_mode( + row.get("step_transition_force_curve", "late_target"), + {"balanced", "late_target", "early_source", "free_motion"}, + "late_target", + ) + curve_text = { + "late_target": "use the target guide as the final framing reference near the end of the camera move", + "early_source": "stay close to the current guide at first before changing scale", + "free_motion": "allow more physical camera motion while preserving identity", + "balanced": "balance the current framing and target framing through one camera move", + }.get(force_curve, "") + if curve_text: + parts.append(curve_text) + return parts + @classmethod def _row_prompt(cls, row: Dict[str, Any], next_row: Optional[Dict[str, Any]] = None) -> str: relay_prompt = str(row.get("relay_prompt", "") or "").strip() - if not relay_prompt: - return "" before_parts: List[str] = [] after_parts: List[str] = [] - base_parts: List[str] = [relay_prompt] + base_parts: List[str] = [relay_prompt] if relay_prompt else [] legacy_modifiers = bool(row.get("use_relay_modifiers", False)) camera_mode = cls._normalise_mode(row.get("camera_relay_mode", "before" if legacy_modifiers else "off"), {"off", "before", "after"}, "off") @@ -1183,6 +1421,8 @@ class IAMCCS_CineShotboardTimelinePro: if transition_mode != "off": after_parts.extend(cls._transition_modifier_parts(row, next_row, include_hard=(transition_mode == "append"))) + after_parts.extend(cls._option_modifier_parts(row)) + after_parts.extend(cls._step_transition_parts(row, next_row)) modifier_text = str(row.get("relay_modifier_text", "") or "").strip() if modifier_text: @@ -1204,25 +1444,75 @@ class IAMCCS_CineShotboardTimelinePro: frame_budget = int(max_frames) segments = [] eligible_rows = [row for row in rows if row.get("use_prompt", True)] + all_timed_rows = [ + row for row in rows + if float(row.get("second", 0.0)) < float(duration_seconds) + ] + all_timed_rows = sorted(all_timed_rows, key=lambda item: float(item.get("second", 0.0))) sorted_rows = [] for row in eligible_rows: if float(row.get("second", 0.0)) >= float(duration_seconds): continue - if not str(row.get("relay_prompt", "") or "").strip(): - continue sorted_rows.append(row) if not sorted_rows: return "", "", int(max_frames), [] for idx, row in enumerate(sorted_rows): start = max(0.0, float(row.get("second", 0.0))) - if idx + 1 < len(sorted_rows): + step_enabled = cls._as_bool(row.get("step_transition_enabled", False), False) + step_type = cls._normalise_mode( + row.get("step_transition_type", "off"), + {"off", "action_beat", "slow_dolly_in", "hold_then_push", "orbit_bridge", "match_move", "rack_focus", "soft_push"}, + "off", + ) + next_row = sorted_rows[idx + 1] if idx + 1 < len(sorted_rows) else None + if step_enabled and step_type != "off": + try: + all_idx = all_timed_rows.index(row) + except ValueError: + all_idx = -1 + if 0 <= all_idx < len(all_timed_rows) - 1: + next_row = all_timed_rows[all_idx + 1] + if next_row is not None: + end = max(start + 0.05, float(next_row.get("second", duration_seconds))) + elif idx + 1 < len(sorted_rows): end = max(start + 0.05, float(sorted_rows[idx + 1].get("second", duration_seconds))) else: end = max(start + 0.05, float(duration_seconds)) dur = max(0.05, end - start) - prompt = cls._row_prompt(row, sorted_rows[idx + 1] if idx + 1 < len(sorted_rows) else None) + prompt = cls._row_prompt(row, next_row) if not prompt: continue + if step_enabled and step_type != "off" and next_row is not None and dur >= 0.4: + next_label = str(next_row.get("label", "next guide") or "next guide").replace("_", " ") + requested = max(0.0, _safe_float(row.get("step_transition_duration", 0.0), 0.0)) + arrival = cls._normalise_mode(row.get("step_transition_arrival", "auto"), {"auto", "early", "middle", "late", "very_late"}, "auto") + if arrival == "auto": + arrival = "very_late" if step_type == "hold_then_push" else "late" if step_type in {"slow_dolly_in", "orbit_bridge", "soft_push"} else "middle" + window = min(dur, requested if requested > 0 else dur) + hold = max(0.0, dur - window) + if requested <= 0 and arrival in {"late", "very_late"} and dur >= 1.0: + hold = dur * (0.25 if arrival == "late" else 0.42) + window = max(0.1, dur - hold) + if hold >= 0.18: + segments.append({ + "seconds": hold, + "prompt": "same continuous shot, maintain the current guide composition with stable identity and same location, prepare a physical camera move", + }) + begin = max(0.08, window * (0.35 if arrival in {"late", "very_late"} else 0.45)) + arrive = max(0.08, window - begin) + if begin + arrive > window and window > 0: + scale = window / (begin + arrive) + begin = max(0.05, begin * scale) + arrive = max(0.05, arrive * scale) + segments.append({ + "seconds": begin, + "prompt": f"{prompt}, begin the physical camera move gradually from the current framing, same subject, same space", + }) + segments.append({ + "seconds": arrive, + "prompt": f"{prompt}, continue the same physical camera move, use {next_label} only as the final framing reference near the end", + }) + continue segments.append({"seconds": dur, "prompt": prompt}) total = sum(seg["seconds"] for seg in segments) or float(duration_seconds) @@ -1261,7 +1551,7 @@ class IAMCCS_CineShotboardTimelinePro: active_rows = [ row for row in rows if row.get("use_prompt", True) - and str(row.get("relay_prompt", "") or "").strip() + and (str(row.get("relay_prompt", "") or "").strip() or cls._as_bool(row.get("step_transition_enabled", False), False)) and float(row.get("second", 0.0)) < float(duration_seconds) ] skipped_empty = [ @@ -1275,6 +1565,11 @@ class IAMCCS_CineShotboardTimelinePro: guide_rows = guide_rows or [] local_parts = [part.strip() for part in str(local_prompts or "").split("|") if part.strip()] length_parts = [part for part in re.split(r"[,;\s]+", str(segment_lengths or "")) if part.strip()] + print( + f"[IAMCCS {node_name}] PromptRelay requested={bool(str(local_prompts or '').strip())} " + f"locals={local_count} segments={length_count} rows={len(rows)} guides={len(guide_rows)} " + f"max_frames={int(max_frames or 0)}" + ) _cine_debug( f"[IAMCCS CineDebug:{node_name}] global_hash={cls._log_text_hash(global_prompt)} " f"global_preview={cls._log_text_preview(global_prompt, 260)!r}" @@ -1368,14 +1663,24 @@ class IAMCCS_CineShotboardTimelinePro: ) -> str: keyframes = [] for row in rows: + motion_force = float(row.get("force", row.get("motion_force", 0.0))) + guide_strength = _wdc_image_guide_strength(row, 1.0) item = { "second": float(row.get("second", 0.0)), "ref": int(row.get("ref", 1)), - "strength": float(row.get("force", 0.0)), + "strength": float(guide_strength), + "guide_strength": float(guide_strength), + "image_lock_strength": float(guide_strength), + "motion_force": float(motion_force), + "force": float(motion_force), "label": str(row.get("label", "shot")), "camera": str(row.get("camera", "")), "note": str(row.get("note", "")), "transition": str(row.get("transition", "continuous_motion")), + "step_transition_enabled": _safe_bool(row.get("step_transition_enabled"), False), + "step_transition_type": str(row.get("step_transition_type", "off") or "off"), + "step_transition_duration": _safe_float(row.get("step_transition_duration", 0.0), 0.0), + "step_transition_arrival": str(row.get("step_transition_arrival", "auto") or "auto"), } if row.get("frame") is not None: item["frame"] = int(row.get("frame")) @@ -1455,7 +1760,7 @@ class IAMCCS_CineShotboardTimelinePro: if float(row["second"]) - float(prev["second"]) < float(min_guide_gap_seconds): warnings.append(f"Rows '{prev['label']}' and '{row['label']}' are closer than min_guide_gap_seconds.") if float(row.get("force", 0.0)) > 0.55 and row.get("use_guide", True): - warnings.append(f"Row '{row['label']}' uses a strong WDC-style FLF anchor; keep it intentional and avoid overcrowded guides.") + warnings.append(f"Row '{row['label']}' uses a strong FLFreal anchor; keep it intentional and avoid overcrowded guides.") if str(row.get("transition")) == "hard_cut" and str(guide_policy) != "prompt_only": warnings.append(f"Row '{row['label']}' is marked hard_cut; multigen is usually cleaner than one continuous FLF shot.") if str(row.get("transition")) == "hard_cut" and self._normalise_mode(row.get("transition_relay_mode"), {"off", "safe_only", "append"}, "off") == "append": @@ -1491,7 +1796,7 @@ class IAMCCS_CineShotboardPlannerPro(IAMCCS_CineShotboardTimelinePro): return { "required": { "global_prompt": ("STRING", { - "default": "one continuous cinematic shot, coherent motion, no slideshow, no cross dissolve", + "default": "one continuous cinematic shot with coherent motion and connected camera travel", "multiline": True, }), "timeline_data": ("STRING", { @@ -1514,6 +1819,9 @@ class IAMCCS_CineShotboardPlannerPro(IAMCCS_CineShotboardTimelinePro): }), "image_width": ("INT", {"default": 768, "min": 64, "max": 8192, "step": 32}), "image_height": ("INT", {"default": 432, "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": { "multi_input": ("IMAGE",), @@ -1541,6 +1849,9 @@ class IAMCCS_CineShotboardPlannerPro(IAMCCS_CineShotboardTimelinePro): image_paths: str, image_width: int, image_height: int, + image_resize_method: str, + image_multiple_of: int, + img_compression: int, rows: List[Dict[str, Any]], guide_rows: List[Dict[str, Any]], flf_timeline: str, @@ -1551,13 +1862,56 @@ class IAMCCS_CineShotboardPlannerPro(IAMCCS_CineShotboardTimelinePro): multi_output: Any, image_1: Any, ) -> Dict[str, Any]: + image_resize_method = _iamccs_cine_resize_method(image_resize_method) promptrelay_enabled = bool(str(local_prompts or "").strip()) + sorted_visual_rows = sorted( + [row for row in rows if isinstance(row, dict) and float(row.get("second", 0.0)) < float(duration_seconds)], + key=lambda item: float(item.get("second", 0.0)), + ) + reference_paths = _cine_reference_paths_from_text(image_paths) + visual_segments = [] + for row_index, row in enumerate(sorted_visual_rows): + start_frame = _safe_int( + row.get("frame", int(round(float(row.get("second", 0.0)) * max(1, int(frame_rate))))), + int(round(float(row.get("second", 0.0)) * max(1, int(frame_rate)))), + ) + if row_index + 1 < len(sorted_visual_rows): + next_second = float(sorted_visual_rows[row_index + 1].get("second", duration_seconds)) + else: + next_second = float(duration_seconds) + end_frame = max(start_frame + 1, int(round(next_second * max(1, int(frame_rate))))) + ref_index = int(max(1, _safe_int(row.get("ref", row_index + 1), row_index + 1))) + visual_segments.append({ + "id": f"row_{row_index + 1}", + "type": "image", + "start": int(max(0, start_frame)), + "length": int(max(1, end_frame - start_frame)), + "ref": ref_index, + "imageFile": reference_paths[ref_index - 1] if ref_index <= len(reference_paths) else "", + "label": str(row.get("label", f"ref_{row_index + 1}")), + "prompt": str(row.get("relay_prompt", "")), + "camera": str(row.get("camera", "")), + "transition": str(row.get("transition", "continuous_motion")), + "guideStrength": float(_safe_float(row.get("motion_force", row.get("force", default_force)), default_force)), + "imageLockStrength": float(_safe_float(row.get("motion_force", row.get("force", default_force)), default_force)), + "use_guide": bool(row.get("use_guide", True)), + "use_prompt": bool(row.get("use_prompt", False)), + "step_transition_enabled": bool(row.get("step_transition_enabled", False)), + "step_transition_type": str(row.get("step_transition_type", "off") or "off"), + "step_transition_prompt": str(row.get("step_transition_prompt", "") or ""), + "step_transition_duration": float(_safe_float(row.get("step_transition_duration", 0.0), 0.0)), + "step_transition_arrival": str(row.get("step_transition_arrival", "auto") or "auto"), + "step_transition_auto_fit": bool(row.get("step_transition_auto_fit", True)), + }) + visual_segments_json = json.dumps(visual_segments, ensure_ascii=False) payload = { "backend_id": CINE1_BACKEND_ID, "second_stage_id": CINE1_SECOND_STAGE_ID, "second_stage_aliases": list(CINE1_SECOND_STAGE_ALIASES), "backend_mode": "cine_ltx23_shotboard_promptrelay_flf", "promptrelay_enabled": promptrelay_enabled, + "promptrelay_gate_mode": "toggle_and_non_empty_local_prompt", + "promptrelay_backend": "IAMCCS_CineRelayOrBypass", "global_prompt": str(global_prompt or ""), "timeline_data": str(timeline_data or ""), "flf_timeline": str(flf_timeline or ""), @@ -1575,8 +1929,12 @@ class IAMCCS_CineShotboardPlannerPro(IAMCCS_CineShotboardTimelinePro): "image_paths": str(image_paths or ""), "image_width": int(image_width), "image_height": int(image_height), + "image_resize_method": image_resize_method, + "image_multiple_of": int(image_multiple_of or 32), + "img_compression": int(img_compression or 0), "rows": rows, "guide_rows": guide_rows, + "visual_segments": visual_segments, "promptrelay_pixel_lengths": latent_lengths_preview, "promptrelay_latent_lengths_preview": latent_lengths_preview, } @@ -1594,8 +1952,12 @@ class IAMCCS_CineShotboardPlannerPro(IAMCCS_CineShotboardTimelinePro): "cine_image_paths": str(image_paths or ""), "cine_image_width": int(image_width), "cine_image_height": int(image_height), + "cine_image_resize_method": image_resize_method, + "cine_image_multiple_of": int(image_multiple_of or 32), + "cine_img_compression": int(img_compression or 0), "cine_multi_input": multi_output, "cine_image_1": image_1, + "cine_visual_segments_json": visual_segments_json, } return { "type": SUPERNODE_LINX_TYPE, @@ -1624,6 +1986,9 @@ class IAMCCS_CineShotboardPlannerPro(IAMCCS_CineShotboardTimelinePro): "cine_second_stage": CINE1_SECOND_STAGE_ID, "cine_second_stage_aliases": list(CINE1_SECOND_STAGE_ALIASES), "promptrelay_source": "cine_shotboard_planner", + "promptrelay_gate_mode": "toggle_and_non_empty_local_prompt", + "promptrelay_backend": "IAMCCS_CineRelayOrBypass", + "flfreal_visual_timeline": "rows_compiled_to_visual_segments", "flf_source": "cine_shotboard_planner", }, "outputs": { @@ -1658,6 +2023,9 @@ class IAMCCS_CineShotboardPlannerPro(IAMCCS_CineShotboardTimelinePro): image_paths=None, image_width=None, image_height=None, + image_resize_method=None, + image_multiple_of=None, + img_compression=None, multi_input=None, **kwargs, ): @@ -1677,22 +2045,24 @@ class IAMCCS_CineShotboardPlannerPro(IAMCCS_CineShotboardTimelinePro): "image_signature": _cine_image_path_signature(image_paths), "image_width": int(image_width or 0), "image_height": int(image_height or 0), + "image_resize_method": _iamccs_cine_resize_method(image_resize_method), + "image_multiple_of": int(image_multiple_of or 0), + "img_compression": int(img_compression or 0), }) - 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, multi_input=None): + 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, multi_input=None): fps = max(1, int(frame_rate)) + image_resize_method = _iamccs_cine_resize_method(image_resize_method) if str(image_paths or "").strip(): try: - loaded = IAMCCS_CineReferenceBoard().load_images( + multi_input = IAMCCS_CineReferenceBoard().load_ltx_style_images( image_paths, int(image_width), int(image_height), - "lanczos", - "crop", - 32, - 0, + image_resize_method, + int(image_multiple_of or 32), + int(img_compression or 0), ) - multi_input = loaded[0] except Exception as exc: print(f"IAMCCS Cine Shotboard Planner Pro warning: could not load internal image_paths: {exc}") @@ -1731,6 +2101,9 @@ class IAMCCS_CineShotboardPlannerPro(IAMCCS_CineShotboardTimelinePro): image_paths=str(image_paths or ""), image_width=int(image_width), image_height=int(image_height), + image_resize_method=image_resize_method, + image_multiple_of=int(image_multiple_of or 32), + img_compression=int(img_compression or 0), rows=rows, guide_rows=guide_rows, flf_timeline=flf_timeline, @@ -1758,12 +2131,14 @@ class IAMCCS_CineShotboardPlannerPro(IAMCCS_CineShotboardTimelinePro): "guide_rows_for_flf_engine": guide_rows, "promptrelay_resolved_local_prompts": local_prompts, "promptrelay_enabled": bool(str(local_prompts or "").strip()), + "promptrelay_gate_mode": "toggle_and_non_empty_local_prompt", + "promptrelay_backend": "IAMCCS_CineRelayOrBypass", "promptrelay_segment_lengths": segment_lengths, "promptrelay_pixel_lengths": latent_lengths_preview, "promptrelay_latent_lengths_preview": latent_lengths_preview, "max_frames": int(max_frames), "promptrelay_epsilon": float(promptrelay_epsilon), - "truth": "Planner-only node: connect cine_linx to IAMCCS_CineInfo for classic workflow breakouts. CINE_1 belongs in the SuperNodes Exec Render backend list, not in a separate backend node.", + "truth": "Planner-only node: connect cine_linx to IAMCCS_CineInfo for classic workflow breakouts or to IAMCCS_CineRelayOrBypass for internal Relay/bypass. Relay activates only when a row has use_prompt=True and a non-empty local prompt.", }) cine_linx["outputs"]["report"] = report cine_linx["resources"]["cine_report"] = report @@ -1781,6 +2156,564 @@ class IAMCCS_CineShotboardPlannerProV2(IAMCCS_CineShotboardPlannerPro): CATEGORY = "IAMCCS/Cine/02 Single Generation VIP" + @classmethod + def _parse_rows(cls, timeline_data: str, duration_seconds: float, default_force: float) -> List[Dict[str, Any]]: + rows = super()._parse_rows(timeline_data, duration_seconds, default_force) + for row in rows: + if not isinstance(row, dict): + continue + bridge_text = str(row.get("step_transition_prompt", "") or row.get("note", "") or "").strip() + if not bridge_text: + continue + row["note"] = bridge_text + row["step_transition_prompt"] = bridge_text + row["step_transition_enabled"] = True + if str(row.get("step_transition_type", "off") or "off").strip() == "off": + row["step_transition_type"] = "action_beat" + row["use_prompt"] = True + return rows + + 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, multi_input=None): + (cine_linx,) = super().execute( + 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, + image_multiple_of, + img_compression, + multi_input=multi_input, + ) + if not isinstance(cine_linx, dict): + return (cine_linx,) + + fps = max(1, _safe_int(frame_rate, 24)) + resources = cine_linx.setdefault("resources", {}) + outputs = cine_linx.setdefault("outputs", {}) + payload = resources.get("cine_payload") if isinstance(resources.get("cine_payload"), dict) else {} + visual_segments = _safe_json_loads(str(resources.get("cine_visual_segments_json", "[]") or "[]"), []) + if not isinstance(visual_segments, list): + visual_segments = [] + + promptrelay_requested = False + for seg in visual_segments: + if not isinstance(seg, dict) or str(seg.get("type", "image") or "image").strip().lower() == "audio": + continue + prompt = str(seg.get("prompt", seg.get("local_prompt", seg.get("relay_prompt", ""))) or "").strip() + step_enabled = self._as_bool(seg.get("step_transition_enabled", seg.get("stepTransitionEnabled", False)), False) + if (self._as_bool(seg.get("use_prompt", bool(prompt)), bool(prompt)) and prompt) or step_enabled: + promptrelay_requested = True + break + + flfreal_compile = IAMCCS_CineShotboardPlannerV3._compile_flfreal_timeline( + timeline_data=json.dumps({"segments": visual_segments, "frame_rate": fps}, ensure_ascii=False), + duration_seconds=float(duration_seconds), + frame_rate=fps, + ltx_round_mode=str(ltx_round_mode), + ) + flfreal_report = flfreal_compile.get("report", {}) if isinstance(flfreal_compile, dict) else {} + local_prompts = str(flfreal_compile.get("local_prompts", "") if isinstance(flfreal_compile, dict) else "") + segment_lengths = str(flfreal_compile.get("segment_lengths", "") if isinstance(flfreal_compile, dict) else "") + if promptrelay_requested and local_prompts.strip(): + resources["cine_local_prompts"] = local_prompts + resources["cine_segment_lengths"] = segment_lengths + resources["cine_promptrelay_enabled"] = True + resources["cine_max_frames"] = int(flfreal_compile.get("max_frames", outputs.get("max_frames", 0)) or 0) + outputs["local_prompts"] = local_prompts + outputs["segment_lengths"] = segment_lengths + outputs["max_frames"] = resources["cine_max_frames"] + outputs["promptrelay_enabled"] = True + payload["local_prompts"] = local_prompts + payload["segment_lengths"] = segment_lengths + payload["max_frames"] = resources["cine_max_frames"] + payload["promptrelay_enabled"] = True + elif not promptrelay_requested: + resources["cine_local_prompts"] = "" + resources["cine_segment_lengths"] = "" + resources["cine_promptrelay_enabled"] = False + outputs["local_prompts"] = "" + outputs["segment_lengths"] = "" + outputs["promptrelay_enabled"] = False + payload["local_prompts"] = "" + payload["segment_lengths"] = "" + payload["promptrelay_enabled"] = False + + payload["backend_mode"] = "cine_ltx23_filmmaker_timeline" + payload["filmmaker_schema"] = "iamccs.cine.flfreal_visual_timeline" + payload["flfreal_compile"] = flfreal_report + payload["flfreal_parity"] = "prov2_uses_same_visual_segment_compiler_as_v3" + payload["visual_segments"] = visual_segments + resources["cine_payload"] = payload + cine_linx["mode"] = "cine_ltx23_filmmaker_timeline" + if cine_linx.get("chain") and isinstance(cine_linx["chain"][0], dict): + cine_linx["chain"][0]["name"] = "Cine Shotboard Planner Pro V2" + if cine_linx.get("stages") and isinstance(cine_linx["stages"][0], dict): + cine_linx["stages"][0]["kind"] = "cine_filmmaker_timeline_planner" + policies = cine_linx.setdefault("policies", {}) + policies["flfreal_visual_timeline"] = "prov2_rows_compiled_to_visual_segments_1to1" + policies["promptrelay_source"] = "flfreal_visual_segments" + cine_linx["resource_keys"] = sorted(resources.keys()) + cine_linx["resource_types"] = {key: type(value).__name__ for key, value in resources.items()} + + local_parts = [part.strip() for part in str(resources.get("cine_local_prompts", "") or "").split("|") if part.strip()] + length_parts = [part for part in re.split(r"[,;\s]+", str(resources.get("cine_segment_lengths", "") or "")) if part.strip()] + print( + "[IAMCCS FLFReal] " + "planner=ProV2 " + f"compile={bool(flfreal_report.get('available', False))} " + f"promptrelay_requested={bool(promptrelay_requested)} " + f"locals={len(local_parts)} segments={len(length_parts)} " + f"visual_segments={len(visual_segments)} " + f"action_bridges={int(flfreal_report.get('action_bridges', 0) or 0)} " + "parity=v3_visual_segment_compiler" + ) + return (cine_linx,) + + +class IAMCCS_CineShotboardPlannerV3(IAMCCS_CineShotboardPlannerPro): + """Filmmaker timeline planner. + + V3 keeps the cine_linx contract but stores an editor-grade timeline with + visual segments and multi-track audio metadata, inspired by LTX-style guide + data while remaining fully IAMCCS-owned. + """ + + CATEGORY = "IAMCCS/Cine/02 Single Generation VIP" + + @classmethod + def INPUT_TYPES(cls): + data = super().INPUT_TYPES() + data["required"]["global_prompt"] = ("STRING", { + "default": "one continuous cinematic shot with coherent motion, stable identity and controlled camera movement", + "multiline": True, + }) + data["required"]["timeline_data"] = ("STRING", { + "default": "", + "multiline": True, + "tooltip": "Edited by Shotboard Planner V3. JSON contains segments, rows and audioSegments.", + }) + data["required"]["duration_seconds"] = ("FLOAT", {"default": 20.0, "min": 0.01, "max": 36000.0, "step": 0.01}) + data["required"]["guide_policy"] = (["every_checked_row", "safe_core_guides", "prompt_only"], {"default": "every_checked_row"}) + data["required"]["min_guide_gap_seconds"] = ("FLOAT", {"default": 0.0, "min": 0.0, "max": 60.0, "step": 0.05}) + data["required"]["max_guides"] = ("INT", {"default": 50, "min": 0, "max": 50, "step": 1}) + data["required"]["default_force"] = ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01}) + return data + + @classmethod + def _parse_rows(cls, timeline_data: str, duration_seconds: float, default_force: float) -> List[Dict[str, Any]]: + text = str(timeline_data or "").strip() + if not text: + return super()._parse_rows(timeline_data, duration_seconds, default_force) + try: + data = json.loads(text) + except Exception: + return super()._parse_rows(timeline_data, duration_seconds, default_force) + if not isinstance(data, dict) or not isinstance(data.get("segments"), list): + return super()._parse_rows(timeline_data, duration_seconds, default_force) + + fps = max(1, _safe_int(data.get("frame_rate", data.get("fps", 24)), 24)) + rows: List[Dict[str, Any]] = [] + image_order = 0 + for idx, seg in enumerate(sorted(data.get("segments", []), key=lambda item: _safe_int(item.get("start", 0), 0))): + if not isinstance(seg, dict): + continue + if cls._as_bool(seg.get("placeholder", False), False): + continue + seg_type = str(seg.get("type", "image") or "image").strip().lower() + if seg_type == "audio": + continue + start_frame = max(0, _safe_int(seg.get("start", seg.get("frame", 0)), 0)) + second = start_frame / fps + if second >= float(duration_seconds): + continue + is_text = seg_type == "text" + if not is_text: + image_order += 1 + ref = max(1, _safe_int(seg.get("ref", seg.get("reference_index", seg.get("image_ref", image_order or idx + 1))), image_order or idx + 1)) + strength = _clamp(seg.get("guideStrength", seg.get("guide_strength", seg.get("strength", default_force))), 0.0, 1.0, default_force) + guide_strength = _wdc_image_guide_strength(seg, strength) + prompt = str(seg.get("prompt", seg.get("local_prompt", "")) or "").strip() + label = _normalise_label(str(seg.get("label", seg.get("name", "")) or ""), f"{'text' if is_text else 'shot'}_{idx + 1}") + rows.append({ + "second": max(0.0, second), + "frame": int(start_frame), + "ref": ref, + "force": 0.0 if is_text else float(strength), + "motion_force": 0.0 if is_text else float(strength), + "image_lock_strength": 0.0 if is_text else float(guide_strength), + "guide_strength": 0.0 if is_text else float(guide_strength), + "label": label, + "camera": str(seg.get("camera", seg.get("camera_move", "cinematic motion")) or "cinematic motion").strip(), + "transition": str(seg.get("transition", "continuous_motion") or "continuous_motion").strip(), + "note": str(seg.get("note", "") or "").strip(), + "use_guide": (not is_text) and cls._as_bool(seg.get("use_guide", seg.get("guide", True)), True), + "use_prompt": bool(cls._as_bool(seg.get("use_prompt", bool(prompt)), bool(prompt))), + "relay_prompt": prompt, + "use_relay_modifiers": cls._as_bool(seg.get("use_relay_modifiers", False), False), + "camera_relay_mode": str(seg.get("camera_relay_mode", "off") or "off").strip(), + "transition_relay_mode": str(seg.get("transition_relay_mode", "off") or "off").strip(), + "relay_addon_position": str(seg.get("relay_addon_position", "after") or "after").strip(), + "relay_modifier_text": str(seg.get("relay_modifier_text", "") or "").strip(), + "step_transition_enabled": False, + "step_transition_type": "off", + "step_transition_prompt": "", + "step_transition_easing": str(seg.get("step_transition_easing", seg.get("stepTransitionEasing", "ease_in_out")) or "ease_in_out").strip(), + "step_transition_force_curve": str(seg.get("step_transition_force_curve", seg.get("stepTransitionForceCurve", "late_target")) or "late_target").strip(), + "step_transition_duration": max(0.0, _safe_float(seg.get("step_transition_duration", seg.get("stepTransitionDuration", seg.get("step_seconds", 0.0))), 0.0)), + "step_transition_arrival": str(seg.get("step_transition_arrival", seg.get("stepTransitionArrival", "auto")) or "auto").strip(), + "step_transition_auto_fit": cls._as_bool(seg.get("step_transition_auto_fit", seg.get("stepTransitionAutoFit", True)), True), + }) + + if not rows: + return super()._parse_rows(timeline_data, duration_seconds, default_force) + return sorted(rows[:MAX_CINE_ITEMS], key=lambda item: (float(item["second"]), int(item["ref"]))) + + + @classmethod + def _action_bridge_prompt(cls, seg: Dict[str, Any], next_seg: Optional[Dict[str, Any]] = None) -> str: + bridge_type = str(seg.get("step_transition_type", seg.get("stepTransitionType", "off")) or "off").strip() + custom = str(seg.get("step_transition_prompt", seg.get("stepTransitionPrompt", "")) or "").strip() + next_label = str((next_seg or {}).get("label", (next_seg or {}).get("name", "next frame")) or "next frame").replace("_", " ") + preset = { + "action_beat": f"the action continues toward {next_label}; same subject, same space, coherent temporal development before the next guide", + "slow_dolly_in": f"one continuous physical slow dolly-in toward {next_label}; preserve the same subject and space, arrive on the next guide framing only near the end", + "hold_then_push": f"hold the current guide composition first, then push physically toward {next_label}; continuous camera move, no editorial dissolve", + "orbit_bridge": f"one continuous physical orbit move from the current framing toward {next_label}; preserve identity and spatial continuity", + "match_move": f"match the motion direction into {next_label}; same scene continuity, coherent camera path", + "rack_focus": f"rack focus through the same scene toward {next_label}; preserve composition and identity", + "soft_push": f"soft physical push toward {next_label}; continuous cinematic movement and stable subject identity", + }.get(bridge_type, "") + if custom and preset: + return f"{preset}, {custom}" + return custom or preset + + @classmethod + def _compile_flfreal_timeline( + cls, + *, + timeline_data: Any, + duration_seconds: float, + frame_rate: int, + ltx_round_mode: str, + ) -> Dict[str, Any]: + data = _safe_json_loads(str(timeline_data or "{}"), {}) + if not isinstance(data, dict) or not isinstance(data.get("segments"), list): + return {"local_prompts": "", "segment_lengths": "", "max_frames": 0, "lengths": [], "report": {"available": False}} + fps = max(1, int(frame_rate or data.get("frame_rate", 24) or 24)) + target_frames = max(1, int(round(float(duration_seconds) * fps))) + max_frames = int(cls._round_frames(target_frames, str(ltx_round_mode or "up_8n_plus_1"))) + duration_frames = max_frames + visual_segments = [ + seg for seg in data.get("segments", []) + if isinstance(seg, dict) + and not cls._as_bool(seg.get("placeholder", False), False) + and str(seg.get("type", "image") or "image").strip().lower() != "audio" + ] + visual_segments.sort(key=lambda item: _safe_int(item.get("start", item.get("frame", 0)), 0)) + prompts: List[str] = [] + lengths: List[int] = [] + missing_prompt_labels: List[str] = [] + action_bridge_count = 0 + pending_gap = 0 + cursor = 0 + + def append_segment(prompt: str, length: int) -> None: + prompt = str(prompt or "").strip() + length = max(1, int(round(length))) + if not prompt: + return + prompts.append(prompt) + lengths.append(length) + + for index, seg in enumerate(visual_segments): + start = max(0, _safe_int(seg.get("start", seg.get("frame", 0)), 0)) + if start >= duration_frames: + break + if start > cursor: + gap = min(start, duration_frames) - cursor + if lengths: + lengths[-1] += max(0, gap) + else: + pending_gap += max(0, gap) + seg_len = max(1, _safe_int(seg.get("length", seg.get("len", fps)), fps)) + clipped_end = min(start + seg_len, duration_frames) + clipped_len = max(1, clipped_end - start) + total_len = max(1, clipped_len + pending_gap) + pending_gap = 0 + cursor = start + seg_len + + prompt = str(seg.get("prompt", seg.get("local_prompt", seg.get("relay_prompt", ""))) or "").strip() + use_prompt = cls._as_bool(seg.get("use_prompt", bool(prompt)), bool(prompt)) + step_enabled = False + step_type = "off" + next_seg = visual_segments[index + 1] if index + 1 < len(visual_segments) else None + + if step_enabled and step_type != "off" and next_seg is not None: + bridge_prompt = cls._action_bridge_prompt(seg, next_seg) + requested = max(0.0, _safe_float(seg.get("step_transition_duration", seg.get("stepTransitionDuration", 0.0)), 0.0)) + bridge_len = total_len if requested <= 0 else max(1, min(total_len, int(round(requested * fps)))) + hold_len = max(0, total_len - bridge_len) + if hold_len > 0 and prompt and use_prompt: + append_segment(prompt, hold_len) + elif hold_len > 0 and not prompt: + append_segment("same continuous shot; keep the current guide alive while preparing the next physical camera action", hold_len) + append_segment(bridge_prompt, bridge_len) + action_bridge_count += 1 + elif prompt and use_prompt: + append_segment(prompt, total_len) + elif use_prompt: + missing_prompt_labels.append(str(seg.get("label", seg.get("name", f"segment_{index + 1}")) or f"segment_{index + 1}")) + elif lengths: + lengths[-1] += total_len + else: + pending_gap += total_len + + clamped_cursor = min(cursor, duration_frames) + if lengths and clamped_cursor < duration_frames: + lengths[-1] += duration_frames - clamped_cursor + report = { + "available": True, + "mode": "flfreal_visual_timeline_1to1", + "visual_segments": len(visual_segments), + "relay_segments": len(prompts), + "action_bridges": int(action_bridge_count), + "missing_prompt_labels": missing_prompt_labels, + "max_frames": int(max_frames), + "duration_frames": int(duration_frames), + } + return { + "local_prompts": " | ".join(prompts), + "segment_lengths": ",".join(str(int(length)) for length in lengths), + "max_frames": int(max_frames), + "lengths": lengths, + "report": report, + } + + + 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, multi_input=None): + (cine_linx,) = super().execute( + 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, + image_multiple_of, + img_compression, + multi_input=multi_input, + ) + data = _safe_json_loads(str(timeline_data or "{}"), {}) + audio_segments = data.get("audioSegments", []) if isinstance(data, dict) else [] + visual_segments = data.get("segments", []) if isinstance(data, dict) else [] + if isinstance(visual_segments, list): + reference_paths = _cine_reference_paths_from_text(image_paths) + image_order = 0 + enriched_segments = [] + for seg in visual_segments: + if not isinstance(seg, dict): + enriched_segments.append(seg) + continue + item = dict(seg) + seg_type = str(item.get("type", "image") or "image").strip().lower() + 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): + item["imageFile"] = reference_paths[ref - 1] + 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() + if flfreal_mode not in {"flfreal_parity", "iamccs_enhanced"}: + flfreal_mode = "iamccs_enhanced" + director_local_prompts = str(data.get("director_local_prompts", data.get("local_prompts", "")) if isinstance(data, dict) else "") + director_segment_lengths = str(data.get("director_segment_lengths", data.get("segment_lengths", "")) if isinstance(data, dict) else "") + director_guide_strength = str(data.get("director_guide_strength", data.get("guide_strength", "")) if isinstance(data, dict) else "") + director_audio_data = str(data.get("audio_data", "") if isinstance(data, dict) else "") + 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 + fps = max(1, _safe_int(frame_rate, 24)) + promptrelay_requested = False + if isinstance(data, dict): + promptrelay_requested = self._as_bool(data.get("promptrelay_enabled", data.get("enable_promptrelay", False)), False) + if global_prompt_only: + promptrelay_requested = False + if not global_prompt_only and not promptrelay_requested and flfreal_mode == "flfreal_parity" and director_local_prompts.strip() and director_segment_lengths.strip(): + promptrelay_requested = True + if not global_prompt_only and not promptrelay_requested: + for seg in visual_segments if isinstance(visual_segments, list) else []: + if not isinstance(seg, dict) or str(seg.get("type", "image") or "image").strip().lower() == "audio": + continue + prompt = str(seg.get("prompt", seg.get("local_prompt", seg.get("relay_prompt", ""))) or "").strip() + if self._as_bool(seg.get("use_prompt", bool(prompt)), bool(prompt)) and prompt: + promptrelay_requested = True + break + if not global_prompt_only and not promptrelay_requested: + for row in data.get("rows", []) if isinstance(data.get("rows"), list) else []: + if not isinstance(row, dict): + continue + prompt = str(row.get("relay_prompt", row.get("local_prompt", row.get("prompt", ""))) or "").strip() + if self._as_bool(row.get("use_prompt", False), False) and prompt: + promptrelay_requested = True + break + if isinstance(cine_linx, dict): + resources = cine_linx.setdefault("resources", {}) + outputs = cine_linx.setdefault("outputs", {}) + payload = resources.get("cine_payload") if isinstance(resources.get("cine_payload"), dict) else {} + payload["backend_mode"] = "cine_ltx23_filmmaker_timeline" + payload["filmmaker_schema"] = "iamccs.cine.filmmaker_timeline" + payload["flfreal_mode"] = flfreal_mode + payload["director_local_prompts"] = director_local_prompts + payload["director_segment_lengths"] = director_segment_lengths + payload["director_guide_strength"] = director_guide_strength + payload["director_audio_data"] = director_audio_data + payload["global_prompt_only"] = bool(global_prompt_only) + payload["filmmaker_promptrelay_enabled"] = bool(promptrelay_requested) + payload["visual_segments"] = visual_segments if isinstance(visual_segments, list) else [] + payload["audioSegments"] = audio_segments if isinstance(audio_segments, list) else [] + payload["use_custom_audio"] = bool( + _safe_bool(data.get("use_custom_audio", False), False) + or any(isinstance(seg, dict) and (seg.get("audioFile") or seg.get("audioB64")) for seg in (audio_segments if isinstance(audio_segments, list) else [])) + ) + resources["cine_use_custom_audio"] = payload["use_custom_audio"] + if isinstance(data, dict): + payload["audio_sync_mode"] = str(data.get("audioSyncMode", "timeline_audio") or "timeline_audio") + payload["generation_strategy"] = str(data.get("generationStrategy", "single_timeline") or "single_timeline") + if not promptrelay_requested: + payload["local_prompts"] = "" + payload["segment_lengths"] = "" + resources["cine_local_prompts"] = "" + resources["cine_segment_lengths"] = "" + outputs["local_prompts"] = "" + outputs["segment_lengths"] = "" + resources["cine_payload"] = payload + resources["cine_audio_timeline_json"] = json.dumps(audio_segments if isinstance(audio_segments, list) else [], ensure_ascii=False) + resources["cine_visual_segments_json"] = json.dumps(visual_segments if isinstance(visual_segments, list) else [], ensure_ascii=False) + resources["cine_director_local_prompts"] = director_local_prompts + resources["cine_director_segment_lengths"] = director_segment_lengths + resources["cine_director_guide_strength"] = director_guide_strength + resources["cine_director_audio_data"] = director_audio_data + local_parts = [part.strip() for part in str(resources.get("cine_local_prompts", outputs.get("local_prompts", payload.get("local_prompts", ""))) or "").split("|") if part.strip()] + length_parts = [part for part in re.split(r"[,;\s]+", str(resources.get("cine_segment_lengths", outputs.get("segment_lengths", payload.get("segment_lengths", ""))) or "")) if part.strip()] + rebuilt_step_prompts = False + flfreal_compile = self._compile_flfreal_timeline( + timeline_data=timeline_data, + duration_seconds=float(duration_seconds), + frame_rate=fps, + ltx_round_mode=str(ltx_round_mode), + ) + flfreal_report = flfreal_compile.get("report", {}) if isinstance(flfreal_compile, dict) else {} + has_compiled_action_bridges = int(flfreal_report.get("action_bridges", 0) or 0) > 0 + if ( + flfreal_mode == "flfreal_parity" + and promptrelay_requested + and director_local_prompts.strip() + and director_segment_lengths.strip() + and not has_compiled_action_bridges + ): + resources["cine_local_prompts"] = director_local_prompts + resources["cine_segment_lengths"] = director_segment_lengths + resources["cine_promptrelay_enabled"] = True + resources["cine_max_frames"] = int(flfreal_compile.get("max_frames", outputs.get("max_frames", 0)) or 0) + outputs["local_prompts"] = resources["cine_local_prompts"] + outputs["segment_lengths"] = resources["cine_segment_lengths"] + outputs["max_frames"] = resources["cine_max_frames"] + outputs["promptrelay_enabled"] = True + payload["local_prompts"] = resources["cine_local_prompts"] + payload["segment_lengths"] = resources["cine_segment_lengths"] + payload["max_frames"] = resources["cine_max_frames"] + payload["flfreal_compile"] = {**flfreal_report, "source": "director_style_reflection", "mode": "flfreal_parity"} + payload["promptrelay_enabled"] = True + local_parts = [part.strip() for part in resources["cine_local_prompts"].split("|") if part.strip()] + length_parts = [part for part in re.split(r"[,;\s]+", resources["cine_segment_lengths"]) if part.strip()] + resources["cine_payload"] = payload + elif promptrelay_requested and str(flfreal_compile.get("local_prompts", "") if isinstance(flfreal_compile, dict) else "").strip(): + resources["cine_local_prompts"] = str(flfreal_compile.get("local_prompts", "")) + resources["cine_segment_lengths"] = str(flfreal_compile.get("segment_lengths", "")) + resources["cine_promptrelay_enabled"] = True + resources["cine_max_frames"] = int(flfreal_compile.get("max_frames", outputs.get("max_frames", 0)) or 0) + outputs["local_prompts"] = resources["cine_local_prompts"] + outputs["segment_lengths"] = resources["cine_segment_lengths"] + outputs["max_frames"] = resources["cine_max_frames"] + outputs["promptrelay_enabled"] = True + payload["local_prompts"] = resources["cine_local_prompts"] + payload["segment_lengths"] = resources["cine_segment_lengths"] + payload["max_frames"] = resources["cine_max_frames"] + payload["flfreal_compile"] = { + **flfreal_report, + "source": "compiled_visual_segments", + "mode": flfreal_mode, + "director_reflection_bypassed_for_action_bridge": bool(has_compiled_action_bridges), + } + payload["promptrelay_enabled"] = True + local_parts = [part.strip() for part in resources["cine_local_prompts"].split("|") if part.strip()] + length_parts = [part for part in re.split(r"[,;\s]+", resources["cine_segment_lengths"]) if part.strip()] + resources["cine_payload"] = payload + elif isinstance(payload, dict): + payload["flfreal_compile"] = flfreal_report + resources["cine_payload"] = payload + if promptrelay_requested and not local_parts: + rows = self._parse_rows(timeline_data, float(duration_seconds), float(default_force)) + rebuilt_local_prompts, rebuilt_segment_lengths, rebuilt_max_frames, rebuilt_lengths = self._segments_from_rows( + rows, + float(duration_seconds), + fps, + str(ltx_round_mode), + ) + if str(rebuilt_local_prompts or "").strip(): + rebuilt_step_prompts = True + resources["cine_local_prompts"] = rebuilt_local_prompts + resources["cine_segment_lengths"] = rebuilt_segment_lengths + resources["cine_promptrelay_enabled"] = True + resources["cine_max_frames"] = int(rebuilt_max_frames) + outputs["local_prompts"] = rebuilt_local_prompts + outputs["segment_lengths"] = rebuilt_segment_lengths + outputs["max_frames"] = int(rebuilt_max_frames) + outputs["promptrelay_enabled"] = True + payload["local_prompts"] = rebuilt_local_prompts + payload["segment_lengths"] = rebuilt_segment_lengths + payload["max_frames"] = int(rebuilt_max_frames) + payload["promptrelay_pixel_lengths"] = rebuilt_lengths + payload["promptrelay_latent_lengths_preview"] = rebuilt_lengths + payload["promptrelay_enabled"] = True + local_parts = [part.strip() for part in str(rebuilt_local_prompts or "").split("|") if part.strip()] + length_parts = [part for part in re.split(r"[,;\s]+", str(rebuilt_segment_lengths or "")) if part.strip()] + resources["cine_payload"] = payload + step_count = 0 + flfreal_report_for_log = payload.get("flfreal_compile", {}) if isinstance(payload, dict) else {} + print( + "[IAMCCS FLFReal] " + f"compile={bool(flfreal_report_for_log.get('available', False))} " + f"promptrelay_requested={bool(promptrelay_requested)} " + f"locals={len(local_parts)} segments={len(length_parts)} " + f"visual_segments={len(visual_segments) if isinstance(visual_segments, list) else 0} " + f"action_bridges={int(flfreal_report_for_log.get('action_bridges', step_count) or 0)} " + f"rebuilt_legacy_prompts={rebuilt_step_prompts} " + f"mode={flfreal_mode}" + ) + cine_linx["mode"] = "cine_ltx23_filmmaker_timeline" + cine_linx["chain"][0]["name"] = "Cine Shotboard Planner V3" + cine_linx["stages"][0]["kind"] = "cine_filmmaker_timeline_planner" + outputs["audio_timeline_json"] = resources["cine_audio_timeline_json"] + cine_linx["resource_keys"] = sorted(resources.keys()) + cine_linx["resource_types"] = {key: type(value).__name__ for key, value in resources.items()} + return (cine_linx,) + class IAMCCS_CineShotboardLite(IAMCCS_CineShotboardPlannerPro): """Public/simple FLF-only shotboard. @@ -1795,7 +2728,7 @@ class IAMCCS_CineShotboardLite(IAMCCS_CineShotboardPlannerPro): return { "required": { "global_prompt": ("STRING", { - "default": "one continuous cinematic FLF shot, coherent motion through the image guides, no slideshow, no cross dissolve", + "default": "one continuous cinematic FLF shot with coherent motion through the image guides", "multiline": True, }), "timeline_data": ("STRING", { @@ -1905,6 +2838,9 @@ class IAMCCS_CineShotboardLite(IAMCCS_CineShotboardPlannerPro): image_paths=str(image_paths or ""), image_width=int(image_width), image_height=int(image_height), + image_resize_method="crop", + image_multiple_of=32, + img_compression=0, rows=rows, guide_rows=guide_rows, flf_timeline=flf_timeline, @@ -1951,7 +2887,7 @@ class IAMCCS_CineShotboardPlannerProLegacy(IAMCCS_CineShotboardPlannerPro): """Compatibility clone of Shotboard Planner Pro with classic direct outputs. The primary Shotboard Planner Pro intentionally exposes only cine_linx. - Keep this clone for older hand-wired WDC/PromptRelay workflows that still + Keep this clone for older hand-wired FLFreal/PromptRelay workflows that still expect direct sockets from the planner. """ @@ -2097,6 +3033,10 @@ class IAMCCS_CineInfo: continue return None + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + return float("nan") + def extract(self, cine_linx): resources = self._resources(cine_linx) outputs = self._outputs(cine_linx) @@ -2187,6 +3127,1317 @@ class IAMCCS_CineInfo: ) +class IAMCCS_CineInfoV2(IAMCCS_CineInfo): + """Production breakout: exposes a verified guide plan for the FLF Productor.""" + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "INT", "FLOAT", "STRING", "IMAGE", "IMAGE", "INT", "INT", SUPERNODE_LINX_TYPE, "IMAGE", "INT", "INT", "GUIDE_DATA") + RETURN_NAMES = ("guide_plan_json", "flf_timeline", "global_prompt", "local_prompts", "segment_lengths", "max_frames", "promptrelay_epsilon", "report", "multi_output", "image_1", "duration_seconds_int", "frame_rate_int", "cine_linx", "first_stage_preview", "width", "height", "guide_data") + CATEGORY = "IAMCCS/Cine/00 Utilities" + + @staticmethod + def _safe_shape(value: Any) -> List[int]: + return [int(v) for v in value.shape] if torch.is_tensor(value) else [] + + @staticmethod + def _image_stats(batch: Any, ref_index: int) -> Dict[str, Any]: + if not torch.is_tensor(batch) or ref_index < 1 or ref_index > int(batch.shape[0]): + return {"available": False} + try: + img = batch[ref_index - 1 : ref_index].detach().float().cpu() + return { + "available": True, + "shape": [int(v) for v in img.shape], + "mean": round(float(img.mean().item()), 6), + "std": round(float(img.std().item()), 6), + } + except Exception: + return {"available": True, "stats_error": True} + + @classmethod + def _build_guide_plan( + cls, + *, + flf_timeline: str, + image_paths: str, + multi_output: Any, + duration_seconds: float, + frame_rate: int, + default_force: float = 0.24, + ) -> Tuple[str, str]: + fps = max(1, int(frame_rate)) + reference_paths = _cine_reference_paths_from_text(image_paths) + reference_count = int(multi_output.shape[0]) if torch.is_tensor(multi_output) and multi_output.ndim >= 1 else 0 + keyframes = IAMCCS_CineLTXSequencer._parse_timeline( + flf_timeline, + float(duration_seconds), + fps, + max(1, reference_count), + float(default_force), + ) + guides: List[Dict[str, Any]] = [] + warnings: List[str] = [] + for order, key in enumerate(keyframes): + ref = max(1, int(key.get("reference_index", order + 1))) + frame = int(key.get("frame", int(round(float(key.get("second", 0.0)) * fps)))) + guide_strength = _wdc_image_guide_strength(key, _safe_float(key.get("strength", 1.0), 1.0)) + motion_force = _safe_float(key.get("motion_force", key.get("force", default_force)), default_force) + item = { + "order": int(order + 1), + "label": str(key.get("label", f"guide_{order + 1}")), + "ref": int(ref), + "reference_index": int(ref), + "second": float(key.get("second", 0.0)), + "frame": int(frame), + "strength": float(guide_strength), + "guide_strength": float(guide_strength), + "image_lock_strength": float(guide_strength), + "motion_force": float(motion_force), + "force": float(motion_force), + "camera": str(key.get("camera", "")), + "step_transition_enabled": _safe_bool(key.get("step_transition_enabled"), False), + "step_transition_type": str(key.get("step_transition_type", "off") or "off"), + "step_transition_duration": _safe_float(key.get("step_transition_duration", 0.0), 0.0), + "step_transition_arrival": str(key.get("step_transition_arrival", "auto") or "auto"), + "path": reference_paths[ref - 1] if ref - 1 < len(reference_paths) else "", + "image": cls._image_stats(multi_output, ref), + } + if ref > reference_count: + warnings.append(f"{item['label']}: ref {ref} is not loaded; batch has {reference_count}.") + if frame < 0: + item["frame_policy"] = "tail" + guides.append(item) + + plan = { + "schema": "iamccs.cine.guide_plan", + "schema_version": 2, + "source": "IAMCCS_CineInfoV2", + "duration_seconds": float(duration_seconds), + "frame_rate": fps, + "reference_count": int(reference_count), + "reference_paths": reference_paths, + "multi_output_shape": cls._safe_shape(multi_output), + "guides": guides, + "warnings": warnings, + "truth": "This plan is the explicit contract between ShotPlanner and the FLF Productor.", + } + report = _json_report({ + "node": "IAMCCS_CineInfoV2", + "mode": "guide_plan_breakout", + "duration_seconds": float(duration_seconds), + "frame_rate": fps, + "reference_count": int(reference_count), + "guide_count": len(guides), + "guide_strength_semantics": "ltx_director_single_guide_strength", + "warnings": warnings, + "guide_summary": [ + { + "label": item["label"], + "ref": item["ref"], + "frame": item["frame"], + "second": item["second"], + "strength": item["strength"], + "motion_force": item["motion_force"], + "path": item["path"], + } + for item in guides + ], + }) + return json.dumps(plan, indent=2, ensure_ascii=False), report + + @staticmethod + def _guide_data_from_plan(guide_plan_json: Any, multi_output: Any) -> Dict[str, Any]: + plan = _safe_json_loads(str(guide_plan_json or "{}")) + fps = _safe_int(plan.get("frame_rate", 24), 24) if isinstance(plan, dict) else 24 + guides = plan.get("guides", []) if isinstance(plan, dict) else [] + guide_data: Dict[str, Any] = { + "images": [], + "insert_frames": [], + "strengths": [], + "frame_rate": int(max(1, fps)), + "labels": [], + "reference_indices": [], + "source": "IAMCCS_CineInfoV2", + } + if not torch.is_tensor(multi_output) or multi_output.ndim != 4: + return guide_data + + batch_size = int(multi_output.shape[0]) + for idx, guide in enumerate(guides): + if not isinstance(guide, dict): + continue + ref = max(1, _safe_int(guide.get("ref", guide.get("reference_index", idx + 1)), idx + 1)) + if ref > batch_size: + continue + strength = _wdc_image_guide_strength(guide, _safe_float(guide.get("strength", 1.0), 1.0)) + if strength <= 0: + continue + frame = _safe_int( + guide.get("frame", int(round(_safe_float(guide.get("second", 0.0), 0.0) * max(1, fps)))), + 0, + ) + guide_data["images"].append(multi_output[ref - 1 : ref]) + guide_data["insert_frames"].append(int(frame)) + guide_data["strengths"].append(float(strength)) + guide_data["labels"].append(str(guide.get("label", f"guide_{idx + 1}"))) + guide_data["reference_indices"].append(int(ref)) + guide_data.setdefault("motion_forces", []).append( + float(_safe_float(guide.get("motion_force", guide.get("force", 0.0)), 0.0)) + ) + guide_data.setdefault("image_lock_strengths", []).append(float(strength)) + step_type = str(guide.get("step_transition_type", "off") or "off").strip().lower() + guide_data.setdefault("step_transition_sources", []).append( + bool(_safe_bool(guide.get("step_transition_enabled"), False) and step_type != "off") + ) + return guide_data + + def extract(self, cine_linx): + base = super().extract(cine_linx) + ( + flf_timeline, + global_prompt, + local_prompts, + segment_lengths, + max_frames, + promptrelay_epsilon, + base_report, + multi_output, + image_1, + duration_seconds_int, + frame_rate_int, + cine_linx_out, + first_stage_preview, + width, + height, + ) = base + resources = self._resources(cine_linx) + outputs = self._outputs(cine_linx) + payload = resources.get("cine_payload") if isinstance(resources.get("cine_payload"), dict) else {} + duration_seconds = _safe_float(resources.get("cine_duration_seconds", outputs.get("duration_seconds", payload.get("duration_seconds", duration_seconds_int))), float(duration_seconds_int)) + frame_rate = _safe_int(resources.get("cine_frame_rate", outputs.get("frame_rate", payload.get("frame_rate", frame_rate_int))), int(frame_rate_int)) + default_force = _safe_float(resources.get("cine_default_force", payload.get("default_force", 0.24)), 0.24) + image_paths = str(resources.get("cine_image_paths", payload.get("image_paths", "")) or "") + guide_plan_json, plan_report = self._build_guide_plan( + flf_timeline=str(flf_timeline or ""), + image_paths=image_paths, + multi_output=multi_output, + duration_seconds=float(duration_seconds), + frame_rate=int(frame_rate), + default_force=float(default_force), + ) + guide_data = self._guide_data_from_plan(guide_plan_json, multi_output) + report = _json_report({ + "node": "IAMCCS_CineInfoV2", + "base_report": _safe_json_loads(str(base_report or "{}")), + "guide_plan_report": _safe_json_loads(str(plan_report or "{}")), + "guide_data_report": { + "schema": "iamccs.GUIDE_DATA.compat", + "count": len(guide_data.get("images", [])), + "insert_frames": guide_data.get("insert_frames", []), + "strengths": guide_data.get("strengths", []), + "reference_indices": guide_data.get("reference_indices", []), + }, + }) + return ( + guide_plan_json, + flf_timeline, + global_prompt, + local_prompts, + segment_lengths, + int(max_frames), + float(promptrelay_epsilon), + report, + multi_output, + image_1, + int(round(duration_seconds)), + int(frame_rate), + cine_linx_out, + first_stage_preview, + int(width), + int(height), + guide_data, + ) + + +class IAMCCS_CineFLFProductor: + """Production FLF backend driven by the explicit guide plan from CineInfo V2.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "positive": ("CONDITIONING",), + "negative": ("CONDITIONING",), + "vae": ("VAE",), + "latent": ("LATENT",), + "multi_input": ("IMAGE",), + "guide_plan_json": ("STRING", {"default": "", "multiline": True}), + "strength_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01}), + "tail_safety_frames": ("INT", {"default": 0, "min": 0, "max": 240, "step": 1}), + }, + "optional": { + "timeline_data": ("STRING", {"default": "", "multiline": True}), + "duration_seconds": ("INT", {"default": 20, "min": 1, "max": 36000, "step": 1}), + "frame_rate": ("INT", {"default": 24, "min": 1, "max": 120, "step": 1}), + "guide_data": ("GUIDE_DATA",), + }, + } + + RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT", "STRING") + RETURN_NAMES = ("positive", "negative", "latent", "report") + FUNCTION = "execute" + CATEGORY = "IAMCCS/Cine/02 Single Generation" + + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + return float("nan") + + @staticmethod + def _parse_plan(guide_plan_json: Any, timeline_data: Any, duration_seconds: Any, frame_rate: Any) -> Dict[str, Any]: + text = str(guide_plan_json or "").strip() + if text: + try: + data = json.loads(text) + if isinstance(data, dict): + return data + except Exception: + pass + fps = max(1, _safe_int(frame_rate, 24)) + duration = max(0.1, _safe_float(duration_seconds, 20.0)) + keyframes = IAMCCS_CineLTXSequencer._parse_timeline(str(timeline_data or ""), duration, fps, MAX_CINE_ITEMS, 0.24) + return { + "schema": "iamccs.cine.guide_plan", + "schema_version": 2, + "source": "fallback_timeline_data", + "duration_seconds": duration, + "frame_rate": fps, + "reference_count": MAX_CINE_ITEMS, + "guides": [ + { + "order": idx + 1, + "label": key.get("label", f"guide_{idx + 1}"), + "ref": int(key.get("reference_index", idx + 1)), + "reference_index": int(key.get("reference_index", idx + 1)), + "second": float(key.get("second", 0.0)), + "frame": int(key.get("frame", 0)), + "strength": float(_wdc_image_guide_strength(key, _safe_float(key.get("strength", 1.0), 1.0))), + "motion_force": float(_safe_float(key.get("motion_force", key.get("force", 0.0)), 0.0)), + "camera": str(key.get("camera", "")), + } + for idx, key in enumerate(keyframes) + ], + } + + @staticmethod + def _clean_guides(plan: Dict[str, Any], strength_scale: float) -> List[Dict[str, Any]]: + raw_guides = plan.get("guides") + if not isinstance(raw_guides, list): + raw_guides = plan.get("keyframes") if isinstance(plan.get("keyframes"), list) else [] + guides: List[Dict[str, Any]] = [] + for idx, item in enumerate(raw_guides): + if not isinstance(item, dict): + continue + ref = max(1, _safe_int(item.get("ref", item.get("reference_index", idx + 1)), idx + 1)) + frame = _safe_int(item.get("frame", int(round(_safe_float(item.get("second", 0.0), 0.0) * _safe_int(plan.get("frame_rate", 24), 24)))), 0) + strength = _clamp(_wdc_image_guide_strength(item, _safe_float(item.get("strength", 1.0), 1.0)) * float(strength_scale), 0.0, 1.0, 1.0) + if strength <= 0: + continue + motion_force = _safe_float(item.get("motion_force", item.get("force", 0.0)), 0.0) + guides.append({ + "order": _safe_int(item.get("order", idx + 1), idx + 1), + "label": str(item.get("label", f"guide_{idx + 1}")), + "ref": int(ref), + "frame": int(frame), + "second": _safe_float(item.get("second", frame / max(1, _safe_int(plan.get("frame_rate", 24), 24))), 0.0), + "strength": float(strength), + "guide_strength": float(strength), + "image_lock_strength": float(strength), + "motion_force": float(motion_force), + "force": float(motion_force), + "camera": str(item.get("camera", "")), + "step_transition_enabled": _safe_bool(item.get("step_transition_enabled"), False), + "step_transition_type": str(item.get("step_transition_type", "off") or "off"), + "step_transition_duration": _safe_float(item.get("step_transition_duration", 0.0), 0.0), + "step_transition_arrival": str(item.get("step_transition_arrival", "auto") or "auto"), + "path": str(item.get("path", "")), + }) + return sorted(guides, key=lambda item: ((10**9 if int(item["frame"]) < 0 else int(item["frame"])), int(item["order"]))) + + @staticmethod + def _execute_guide_data(positive, negative, vae, latent, guide_data: Dict[str, Any], strength_scale: float, tail_safety_frames: int): + latent_samples = latent.get("samples") if isinstance(latent, dict) else None + if not torch.is_tensor(latent_samples) or latent_samples.ndim != 5: + return positive, negative, latent, { + "applied_guides": [], + "skipped_guides": [{"reason": "invalid latent samples"}], + "latent_pixel_frames": 0, + } + + scale_factors = vae.downscale_index_formula + latent_image = latent_samples.clone() + if "noise_mask" in latent: + noise_mask = latent["noise_mask"].clone() + else: + batch, _, latent_frames, _, _ = latent_image.shape + noise_mask = torch.ones((batch, 1, latent_frames, 1, 1), dtype=torch.float32, device=latent_image.device) + + _, _, latent_length, latent_height, latent_width = latent_image.shape + time_scale_factor = int(scale_factors[0]) if scale_factors else 8 + latent_pixel_frames = max(1, (int(latent_length) - 1) * max(1, time_scale_factor) + 1) + max_frame = max(0, int(latent_pixel_frames) - 1 - max(0, int(tail_safety_frames))) + images = guide_data.get("images", []) if isinstance(guide_data, dict) else [] + insert_frames = guide_data.get("insert_frames", []) if isinstance(guide_data, dict) else [] + strengths = guide_data.get("strengths", []) if isinstance(guide_data, dict) else [] + labels = guide_data.get("labels", []) if isinstance(guide_data, dict) else [] + refs = guide_data.get("reference_indices", []) if isinstance(guide_data, dict) else [] + + applied = [] + skipped = [] + for idx, img in enumerate(images): + label = str(labels[idx]) if idx < len(labels) else f"guide_{idx + 1}" + ref = _safe_int(refs[idx], idx + 1) if idx < len(refs) else idx + 1 + if not torch.is_tensor(img) or img.ndim != 4 or int(img.shape[0]) <= 0: + skipped.append({"label": label, "reference_index": int(ref), "reason": "empty guide image tensor"}) + continue + requested_frame = _safe_int(insert_frames[idx], 0) if idx < len(insert_frames) else 0 + frame_idx = requested_frame if requested_frame < 0 else min(max(0, requested_frame), max_frame) + strength = _clamp((_safe_float(strengths[idx], 1.0) if idx < len(strengths) else 1.0) * float(strength_scale), 0.0, 1.0, 1.0) + if strength <= 0: + skipped.append({"label": label, "reference_index": int(ref), "frame": int(frame_idx), "reason": "zero strength"}) + continue + try: + image_1, encoded = LTXVAddGuide.encode(vae, latent_width, latent_height, img, scale_factors) + conditioning_frame, latent_idx = LTXVAddGuide.get_latent_index(positive, latent_length, len(image_1), frame_idx, scale_factors) + if latent_idx + encoded.shape[2] > latent_length: + skipped.append({ + "label": label, + "reference_index": int(ref), + "frame": int(frame_idx), + "reason": "guide exceeds latent length", + }) + continue + positive, negative, latent_image, noise_mask = LTXVAddGuide.append_keyframe( + positive, + negative, + conditioning_frame, + latent_image, + noise_mask, + encoded, + float(strength), + scale_factors, + ) + except Exception as exc: + skipped.append({"label": label, "reference_index": int(ref), "frame": int(frame_idx), "reason": f"guide_data apply failed: {exc}"}) + continue + applied.append({ + "label": label, + "reference_index": int(ref), + "requested_frame": int(requested_frame), + "frame": int(frame_idx), + "strength": float(strength), + }) + + return positive, negative, {"samples": latent_image, "noise_mask": noise_mask}, { + "applied_guides": applied, + "skipped_guides": skipped, + "latent_pixel_frames": int(latent_pixel_frames), + } + + @classmethod + def _guide_data_from_plan_and_images(cls, plan: Dict[str, Any], multi_input: Any, strength_scale: float = 1.0) -> Dict[str, Any]: + guides = cls._clean_guides(plan, float(strength_scale)) + guide_data: Dict[str, Any] = { + "images": [], + "insert_frames": [], + "strengths": [], + "frame_rate": max(1, _safe_int(plan.get("frame_rate", 24), 24)), + "labels": [], + "reference_indices": [], + "source": "IAMCCS_CineFLFProductor.clean_plan_adapter", + } + if not torch.is_tensor(multi_input) or multi_input.ndim != 4: + return guide_data + batch_size = int(multi_input.shape[0]) + for idx, guide in enumerate(guides): + ref = int(guide.get("ref", idx + 1)) + if ref < 1 or ref > batch_size: + continue + guide_data["images"].append(multi_input[ref - 1 : ref]) + guide_data["insert_frames"].append(int(guide.get("frame", 0))) + guide_data["strengths"].append(float(guide.get("strength", 0.24))) + guide_data["labels"].append(str(guide.get("label", f"guide_{idx + 1}"))) + guide_data["reference_indices"].append(int(ref)) + guide_data.setdefault("motion_forces", []).append(float(_safe_float(guide.get("motion_force", guide.get("force", 0.0)), 0.0))) + guide_data.setdefault("image_lock_strengths", []).append(float(guide.get("strength", 1.0))) + step_type = str(guide.get("step_transition_type", "off") or "off").strip().lower() + is_step_source = bool(_safe_bool(guide.get("step_transition_enabled"), False) and step_type != "off") + prev_step_type = str(guides[idx - 1].get("step_transition_type", "off") or "off").strip().lower() if idx > 0 else "off" + is_step_target = bool( + idx > 0 + and _safe_bool(guides[idx - 1].get("step_transition_enabled"), False) + and prev_step_type != "off" + ) + guide_data.setdefault("step_transition_sources", []).append(is_step_source) + guide_data.setdefault("step_transition_targets", []).append(is_step_target) + guide_data.setdefault("step_transition_protected", []).append(bool(is_step_source or is_step_target)) + return guide_data + + def execute( + self, + positive, + negative, + vae, + latent, + multi_input, + guide_plan_json, + strength_scale, + tail_safety_frames, + timeline_data="", + duration_seconds=20, + frame_rate=24, + guide_data=None, + ): + if isinstance(guide_data, dict) and guide_data.get("images"): + positive, negative, current_latent, guide_data_report = self._execute_guide_data( + positive, + negative, + vae, + latent, + guide_data, + float(strength_scale), + int(tail_safety_frames), + ) + report = _json_report({ + "node": "IAMCCS_CineFLFProductor", + "mode": "flfreal_guide_data_compatible", + "guide_count": len(guide_data.get("images", [])), + "applied_count": len(guide_data_report.get("applied_guides", [])), + "skipped_count": len(guide_data_report.get("skipped_guides", [])), + "guide_attention_entries": False, + "guide_strength_semantics": "clean_noise_mask_without_attention_attenuation", + **guide_data_report, + "truth": "Consumed GUIDE_DATA directly; image tensors, frames and strengths come from the upstream guide_data contract.", + }) + return positive, negative, current_latent, report + + plan = self._parse_plan(guide_plan_json, timeline_data, duration_seconds, frame_rate) + clean_guide_data = self._guide_data_from_plan_and_images(plan, multi_input, float(strength_scale)) + positive, negative, current_latent, guide_data_report = self._execute_guide_data( + positive, + negative, + vae, + latent, + clean_guide_data, + 1.0, + int(tail_safety_frames), + ) + + report = _json_report({ + "node": "IAMCCS_CineFLFProductor", + "mode": "explicit_clean_guide_productor", + "plan_source": plan.get("source", ""), + "reference_count": int(multi_input.shape[0]) if torch.is_tensor(multi_input) and multi_input.ndim == 4 else 0, + "guide_count": len(clean_guide_data.get("images", [])), + "applied_count": len(guide_data_report.get("applied_guides", [])), + "skipped_count": len(guide_data_report.get("skipped_guides", [])), + "guide_attention_entries": False, + "guide_strength_semantics": "clean_noise_mask_without_attention_attenuation", + **guide_data_report, + "truth": "Each guide is converted to GUIDE_DATA and applied without legacy guide_attention_entries, matching the Filmmaker V3 clean guide behavior.", + }) + return positive, negative, current_latent, report + + +class IAMCCS_CineFilmmaker(IAMCCS_CineInfoV2): + """Filmmaker backend breakout for Shotboard Planner V3.""" + + RETURN_TYPES = IAMCCS_CineInfoV2.RETURN_TYPES + ("STRING", "STRING") + RETURN_NAMES = IAMCCS_CineInfoV2.RETURN_NAMES + ("audio_timeline_json", "visual_segments_json") + CATEGORY = "IAMCCS/Cine/00 Utilities" + + def extract(self, cine_linx): + base = super().extract(cine_linx) + resources = self._resources(cine_linx) + outputs = self._outputs(cine_linx) + audio_timeline_json = str(resources.get("cine_audio_timeline_json", outputs.get("audio_timeline_json", "[]")) or "[]") + visual_segments_json = str(resources.get("cine_visual_segments_json", outputs.get("visual_segments_json", "[]")) or "[]") + return (*base, audio_timeline_json, visual_segments_json) + + +class IAMCCS_CineFilmmakerBackend: + """Filmmaker runtime backend that consumes the FLFreal visual timeline trunk. + + It consumes Shotboard V3 cine_linx and emits the model/conditioning/latent/ + guide_data outputs needed by the LTX sampling chain. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model": ("MODEL",), + "clip": ("CLIP",), + "cine_linx": (SUPERNODE_LINX_TYPE,), + }, + "optional": { + "audio_vae": ("VAE",), + "optional_latent": ("LATENT",), + "use_custom_audio": ("BOOLEAN", {"default": False}), + "promptrelay_safety_budget_enabled": ("BOOLEAN", {"default": True}), + "promptrelay_safety_budget": ("INT", {"default": 24000, "min": 1000, "max": 5000000, "step": 1000}), + }, + } + + RETURN_TYPES = ("MODEL", "CONDITIONING", "LATENT", "LATENT", "GUIDE_DATA", "FLOAT", "AUDIO", "STRING") + RETURN_NAMES = ("model", "positive", "video_latent", "audio_latent", "guide_data", "frame_rate", "combined_audio", "report") + FUNCTION = "execute" + CATEGORY = "IAMCCS/Cine/02 Single Generation" + + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + return float("nan") + + @staticmethod + def _empty_latent(width: int, height: int, pixel_frames: int) -> Dict[str, Any]: + import comfy.model_management + + latent_w = max(32, (int(width) // 32) * 32) + latent_h = max(32, (int(height) // 32) * 32) + latent_t = ((max(1, int(pixel_frames)) - 1) // 8) + 1 + return { + "samples": torch.zeros( + [1, 128, latent_t, latent_h // 32, latent_w // 32], + device=comfy.model_management.intermediate_device(), + ) + } + + @staticmethod + def _empty_audio_latent(audio_vae: Any, pixel_frames: int, frame_rate: float) -> Dict[str, Any]: + if audio_vae is None: + return {} + try: + import comfy.model_management + + z_channels = int(getattr(audio_vae, "latent_channels")) + audio_freq = int(getattr(audio_vae, "latent_frequency_bins")) + num_audio_latents = int(audio_vae.num_of_latents_from_frames(max(1, int(pixel_frames)), int(round(float(frame_rate))))) + audio_latents = torch.zeros( + (1, z_channels, num_audio_latents, audio_freq), + device=comfy.model_management.intermediate_device(), + ) + sample_rate = int(getattr(audio_vae, "sample_rate", 44100)) + return {"samples": audio_latents, "sample_rate": sample_rate, "type": "audio"} + except Exception: + pass + try: + from comfy_extras.nodes_lt_audio import LTXVEmptyLatentAudio + + result = LTXVEmptyLatentAudio.execute(max(1, int(pixel_frames)), int(round(float(frame_rate))), 1, audio_vae) + if isinstance(result, tuple): + result = result[0] + if isinstance(result, dict) and torch.is_tensor(result.get("samples")): + return result + if hasattr(result, "result") and result.result: + candidate = result.result[0] + if isinstance(candidate, dict) and torch.is_tensor(candidate.get("samples")): + return candidate + except Exception: + return {} + return {} + + @staticmethod + def _empty_audio(pixel_frames: int, frame_rate: float) -> Dict[str, Any]: + target_sr = 44100 + safe_fps = max(1.0, float(frame_rate or 24.0)) + total_samples = max(1, int(math.ceil(max(1, int(pixel_frames)) / safe_fps * target_sr))) + return {"waveform": torch.zeros((1, 2, total_samples), dtype=torch.float32), "sample_rate": target_sr} + + @staticmethod + def _has_custom_audio(audio_timeline_json: str) -> bool: + data = _safe_json_loads(str(audio_timeline_json or "[]"), []) + 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) + + @classmethod + def _build_combined_audio(cls, audio_timeline_json: str, pixel_frames: int, frame_rate: float) -> Dict[str, Any]: + target_sr = 44100 + audio_out = cls._empty_audio(pixel_frames, frame_rate) + data = _safe_json_loads(str(audio_timeline_json or "[]"), []) + audio_segments = data if isinstance(data, list) else data.get("audioSegments", []) if isinstance(data, dict) else [] + if not isinstance(audio_segments, list) or not audio_segments: + return audio_out + + try: + import av + except Exception: + return audio_out + + safe_fps = max(1.0, float(frame_rate or 24.0)) + out_waveform = torch.zeros((2, audio_out["waveform"].shape[-1]), dtype=torch.float32) + input_root = folder_paths.get_input_directory() + + for seg in audio_segments: + if not isinstance(seg, dict): + continue + buffer = None + audio_file = str(seg.get("audioFile") or "") + if audio_file: + file_path = os.path.abspath(os.path.join(input_root, audio_file)) + input_abs = os.path.abspath(input_root) + try: + inside_input = os.path.commonpath([input_abs, file_path]) == input_abs + except ValueError: + inside_input = False + if inside_input and os.path.exists(file_path): + with open(file_path, "rb") as handle: + buffer = io.BytesIO(handle.read()) + if buffer is None and seg.get("audioB64"): + encoded = str(seg.get("audioB64") or "") + if "," in encoded: + encoded = encoded.split(",", 1)[1] + try: + buffer = io.BytesIO(base64.b64decode(encoded)) + except Exception: + buffer = None + if buffer is None: + continue + + try: + clip_frames = [] + with av.open(buffer) as container: + audio_streams = list(container.streams.audio) + if not audio_streams: + continue + resampler = av.AudioResampler(format="fltp", layout="stereo", rate=target_sr) + for frame in container.decode(audio_streams[0]): + for resampled_frame in resampler.resample(frame): + clip_frames.append(torch.from_numpy(resampled_frame.to_ndarray())) + for resampled_frame in resampler.resample(None): + clip_frames.append(torch.from_numpy(resampled_frame.to_ndarray())) + if not clip_frames: + continue + + waveform = torch.cat(clip_frames, dim=1).to(torch.float32) + trim_start_frames = max(0.0, float(seg.get("trimStart", 0) or 0)) + start_frames = max(0.0, float(seg.get("start", 0) or 0)) + length_frames = max(1.0, float(seg.get("length", 1) or 1)) + src_start = max(0, int(trim_start_frames / safe_fps * target_sr)) + src_end = min(waveform.shape[1], src_start + int(length_frames / safe_fps * target_sr)) + if src_end <= src_start: + continue + clip_waveform = waveform[:, src_start:src_end] + gain = _clamp(seg.get("gain", seg.get("volume", 1.0)), 0.0, 2.0, 1.0) + if abs(float(gain) - 1.0) > 0.0001: + clip_waveform = clip_waveform * float(gain) + dst_start = int(start_frames / safe_fps * target_sr) + if dst_start >= out_waveform.shape[1]: + continue + dst_end = min(out_waveform.shape[1], dst_start + clip_waveform.shape[1]) + actual = dst_end - dst_start + if actual <= 0: + continue + out_waveform[:, dst_start:dst_end] += clip_waveform[:, :actual] + except Exception: + continue + + return {"waveform": out_waveform.unsqueeze(0), "sample_rate": target_sr} + + @staticmethod + def _encode_audio_latent(audio_vae: Any, audio_out: Dict[str, Any]) -> Dict[str, Any]: + if audio_vae is None or not isinstance(audio_out, dict) or "waveform" not in audio_out: + return {} + try: + import comfy.model_management + + latent_samples = audio_vae.encode(audio_out["waveform"].movedim(1, -1)) + if not torch.is_tensor(latent_samples) or latent_samples.numel() == 0: + return {} + mask = torch.full( + (1, latent_samples.shape[-2], latent_samples.shape[-1]), + 0.0, + dtype=torch.float32, + device=comfy.model_management.intermediate_device(), + ) + return { + "samples": latent_samples, + "sample_rate": int(audio_out.get("sample_rate", getattr(audio_vae, "sample_rate", 44100))), + "type": "audio", + "noise_mask": mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1])), + } + except Exception: + return {} + + + @staticmethod + def _guide_data_from_visual_segments( + visual_segments_json: Any, + multi_output: Any, + frame_rate: int, + image_paths: str = "", + image_width: int = 768, + image_height: int = 432, + image_resize_method: str = "crop", + image_multiple_of: int = 32, + img_compression: int = 0, + ) -> Dict[str, Any]: + raw = _safe_json_loads(str(visual_segments_json or "[]"), []) + if isinstance(raw, dict): + raw = raw.get("segments", []) + guide_data: Dict[str, Any] = { + "images": [], + "insert_frames": [], + "strengths": [], + "frame_rate": int(max(1, frame_rate)), + "labels": [], + "reference_indices": [], + "source": "IAMCCS_FLFReal_visual_timeline", + } + if not isinstance(raw, list): + return guide_data + has_batch = torch.is_tensor(multi_output) and multi_output.ndim == 4 + batch_size = int(multi_output.shape[0]) if has_batch else 0 + reference_paths = _cine_reference_paths_from_text(image_paths) + loader = IAMCCS_CineReferenceBoard() + image_order = 0 + for idx, seg in enumerate(sorted([s for s in raw if isinstance(s, dict)], key=lambda item: _safe_int(item.get("start", item.get("frame", 0)), 0))): + seg_type = str(seg.get("type", "image") or "image").strip().lower() + if seg_type in {"text", "audio"} or _safe_bool(seg.get("placeholder", False), False): + continue + image_order += 1 + if _safe_bool(seg.get("use_guide", seg.get("guide", True)), True) is False: + continue + ref = max(1, _safe_int(seg.get("ref", seg.get("reference_index", seg.get("image_ref", image_order))), image_order)) + fallback_strength = _safe_float(seg.get("guideStrength", seg.get("guide_strength", seg.get("strength", 1.0))), 1.0) + strength = _wdc_image_guide_strength(seg, fallback_strength) + if strength <= 0: + continue + img_tensor = None + source = dict(seg) + if not str(source.get("imageFile", source.get("image_file", "")) 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(): + try: + img_tensor = loader.load_ltx_style_image( + source, + int(image_width or 0), + int(image_height or 0), + _iamccs_cine_resize_method(image_resize_method), + int(image_multiple_of or 32), + int(img_compression or 0), + ) + except Exception as exc: + print(f"IAMCCS FilmmakerBackend warning: could not load LTX-style guide image for {source.get('label', idx + 1)}: {exc}") + if img_tensor is None: + if not has_batch or ref > batch_size: + continue + img_tensor = multi_output[ref - 1 : ref] + frame = max(0, _safe_int(seg.get("start", seg.get("frame", 0)), 0)) + guide_data["images"].append(img_tensor) + guide_data["insert_frames"].append(int(frame)) + guide_data["strengths"].append(float(strength)) + guide_data["labels"].append(str(seg.get("label", f"guide_{idx + 1}"))) + 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}]") + return guide_data + + @staticmethod + def _encode_basic(clip: Any, text: str) -> Any: + result = comfy_nodes.CLIPTextEncode().encode(clip, str(text or "")) + return result[0] if isinstance(result, tuple) else result + + def execute( + self, + model, + clip, + cine_linx, + audio_vae=None, + optional_latent=None, + use_custom_audio=False, + promptrelay_safety_budget_enabled=True, + promptrelay_safety_budget=24000, + ): + resources = IAMCCS_CineInfo._resources(cine_linx) + outputs = IAMCCS_CineInfo._outputs(cine_linx) + payload = resources.get("cine_payload") if isinstance(resources.get("cine_payload"), dict) else {} + + global_prompt = str(resources.get("cine_global_prompt", outputs.get("global_prompt", payload.get("global_prompt", ""))) or "") + local_prompts = str(resources.get("cine_local_prompts", outputs.get("local_prompts", payload.get("local_prompts", ""))) or "") + segment_lengths = str(resources.get("cine_segment_lengths", outputs.get("segment_lengths", payload.get("segment_lengths", ""))) or "") + 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 "") + 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) + height = _safe_int(resources.get("cine_image_height", outputs.get("height", payload.get("image_height", 432))), 432) + image_resize_method = _iamccs_cine_resize_method(resources.get("cine_image_resize_method", payload.get("image_resize_method", "crop"))) + image_multiple_of = _safe_int(resources.get("cine_image_multiple_of", payload.get("image_multiple_of", 32)), 32) + img_compression = _safe_int(resources.get("cine_img_compression", payload.get("img_compression", 0)), 0) + 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"))) + 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) + promptrelay_requested = _safe_bool( + payload.get( + "filmmaker_promptrelay_enabled", + resources.get( + "cine_filmmaker_promptrelay_enabled", + payload.get( + "promptrelay_enabled", + resources.get("cine_promptrelay_enabled", outputs.get("promptrelay_enabled", False)), + ), + ), + ), + False, + ) + safety_budget_enabled = _safe_bool( + payload.get("promptrelay_safety_budget_enabled", resources.get("cine_promptrelay_safety_budget_enabled", promptrelay_safety_budget_enabled)), + True, + ) + safety_budget = max(0, _safe_int( + payload.get("promptrelay_safety_budget", resources.get("cine_promptrelay_safety_budget", promptrelay_safety_budget)), + 24000, + )) + promptrelay_enabled = bool(promptrelay_requested and local_prompts.strip() and segment_lengths.strip()) + local_parts = [part.strip() for part in str(local_prompts or "").split("|") if part.strip()] + length_parts = [part for part in re.split(r"[,;\s]+", str(segment_lengths or "")) if part.strip()] + local_hash = hashlib.sha1(str(local_prompts or "").encode("utf-8", errors="ignore")).hexdigest()[:12] + print( + "[IAMCCS FilmmakerBackend] " + f"PromptRelay requested={bool(promptrelay_requested)} " + f"enabled_candidate={bool(promptrelay_enabled)} " + f"locals={len(local_parts)} segments={len(length_parts)} " + f"epsilon={float(epsilon):.3f} max_frames={int(max_frames)} " + f"local_hash={local_hash}" + ) + relay_error = "" + if promptrelay_enabled: + try: + lengths = [_safe_int(part, 0) for part in re.split(r"[,;\s]+", str(segment_lengths or "")) if part.strip()] + prompt_budget = sum(max(0, length) for length in lengths) * max(1, len(str(local_prompts or "").split())) + if safety_budget_enabled and safety_budget > 0 and prompt_budget > safety_budget: + raise RuntimeError(f"PromptRelay disabled by optional safety budget: estimated matrix load {prompt_budget} > {safety_budget}") + promptrelay_nodes = _load_original_promptrelay_module() + patched_model, positive = promptrelay_nodes._encode_relay( + model, + clip, + latent, + global_prompt, + local_prompts, + segment_lengths, + float(epsilon), + ) + print( + "[IAMCCS FilmmakerBackend] " + f"PromptRelay APPLIED locals={len(local_parts)} segments={len(length_parts)} " + f"budget_estimate={prompt_budget}" + ) + except Exception as exc: + relay_error = str(exc) + patched_model = model + positive = self._encode_basic(clip, global_prompt) + print(f"[IAMCCS FilmmakerBackend] PromptRelay ERROR, falling back to global encode: {relay_error}") + else: + patched_model = model + positive = self._encode_basic(clip, global_prompt) + reason = "not_requested" if not promptrelay_requested else "missing_local_prompts_or_segment_lengths" + print(f"[IAMCCS FilmmakerBackend] PromptRelay BYPASS reason={reason}") + + multi_output = resources.get("cine_multi_input") + 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, + int(frame_rate), + image_paths=image_paths, + image_width=int(width), + image_height=int(height), + image_resize_method=image_resize_method, + image_multiple_of=int(image_multiple_of), + img_compression=int(img_compression), + ) + guide_data_source = "flfreal_visual_timeline_1to1" if guide_data.get("images") else "cineinfo_v2_fallback" + guide_plan_json, plan_report = IAMCCS_CineInfoV2._build_guide_plan( + flf_timeline=flf_timeline, + image_paths=image_paths, + multi_output=multi_output, + duration_seconds=float(duration_seconds), + frame_rate=int(frame_rate), + default_force=float(default_force), + ) + if not guide_data.get("images"): + guide_data = IAMCCS_CineInfoV2._guide_data_from_plan(guide_plan_json, multi_output) + has_timeline_audio = self._has_custom_audio(audio_timeline_json) + custom_audio_requested = bool(_safe_bool(payload.get("use_custom_audio", resources.get("cine_use_custom_audio", use_custom_audio)), False) or has_timeline_audio) + audio_out = self._build_combined_audio(audio_timeline_json, int(max_frames), float(frame_rate)) + audio_latent = self._encode_audio_latent(audio_vae, audio_out) if custom_audio_requested and has_timeline_audio else {} + if not audio_latent: + audio_latent = self._empty_audio_latent(audio_vae, int(max_frames), float(frame_rate)) + report = _json_report({ + "node": "IAMCCS_CineFilmmakerBackend", + "mode": "shotboard_v3_runtime_backend", + "promptrelay_requested": bool(promptrelay_requested), + "promptrelay_enabled": bool(promptrelay_enabled and not relay_error), + "promptrelay_error": relay_error, + "promptrelay_safety_budget_enabled": bool(safety_budget_enabled), + "promptrelay_safety_budget": int(safety_budget), + "duration_seconds": float(duration_seconds), + "frame_rate": int(frame_rate), + "max_frames": int(max_frames), + "width": int(width), + "height": int(height), + "guide_data_count": len(guide_data.get("images", [])), + "guide_data_source": guide_data_source, + "image_loading": { + "mode": "ltx_director_style_internal_load_resize_compress", + "resize_method": image_resize_method, + "multiple_of": int(image_multiple_of), + "img_compression": int(img_compression), + "sources": guide_data.get("image_sources", []), + }, + "flfreal_mode": str(payload.get("flfreal_mode", "iamccs_enhanced") if isinstance(payload, dict) else "iamccs_enhanced"), + "director_style_fields": { + "local_prompts": bool(str(payload.get("director_local_prompts", "") if isinstance(payload, dict) else "").strip()), + "segment_lengths": bool(str(payload.get("director_segment_lengths", "") if isinstance(payload, dict) else "").strip()), + "guide_strength": str(payload.get("director_guide_strength", "") if isinstance(payload, dict) else ""), + "audio_data": bool(str(payload.get("director_audio_data", "") if isinstance(payload, dict) else "").strip()), + "use_custom_audio": bool(custom_audio_requested), + }, + "flfreal_compile": payload.get("flfreal_compile", {}) if isinstance(payload, dict) else {}, + "audio_segments": len(_safe_json_loads(audio_timeline_json, [])) if isinstance(_safe_json_loads(audio_timeline_json, []), list) else len(_safe_json_loads(audio_timeline_json, {}).get("audioSegments", [])) if isinstance(_safe_json_loads(audio_timeline_json, {}), dict) else 0, + "custom_audio_requested": bool(custom_audio_requested), + "custom_audio_encoded": bool(custom_audio_requested and has_timeline_audio), + "audio_latent_valid": bool(isinstance(audio_latent, dict) and torch.is_tensor(audio_latent.get("samples"))), + "guide_plan_report": _safe_json_loads(plan_report, {}), + "truth": "This node consumes the FLFreal visual timeline contract from Shotboard Planner V3 cine_linx.", + }) + return patched_model, positive, latent, audio_latent, guide_data, float(frame_rate), audio_out, report + + +class IAMCCS_CineShotboardBackendPro: + """Internal prompt backend for Shotboard Pro / ProV2 cine_linx.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model": ("MODEL",), + "clip": ("CLIP",), + "cine_linx": (SUPERNODE_LINX_TYPE,), + }, + "optional": { + "optional_latent": ("LATENT",), + "negative_prompt": ("STRING", {"default": "", "multiline": True}), + "relay_options": ("RELAY_OPTIONS",), + "promptrelay_safety_budget_enabled": ("BOOLEAN", {"default": True}), + "promptrelay_safety_budget": ("INT", {"default": 24000, "min": 1000, "max": 5000000, "step": 1000}), + }, + } + + RETURN_TYPES = ("MODEL", "CONDITIONING", "CONDITIONING", "LATENT", "BOOLEAN", "STRING") + RETURN_NAMES = ("model", "positive", "negative", "latent", "promptrelay_enabled", "report") + FUNCTION = "execute" + CATEGORY = "IAMCCS/Cine/02 Single Generation" + + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + return float("nan") + + @staticmethod + def _encode_basic(clip: Any, text: str) -> Any: + result = comfy_nodes.CLIPTextEncode().encode(clip, str(text or "")) + return result[0] if isinstance(result, tuple) else result + + @staticmethod + def _latent_shape(latent: Any) -> List[int]: + try: + samples = latent.get("samples") if isinstance(latent, dict) else None + if torch.is_tensor(samples): + return [int(v) for v in samples.shape] + except Exception: + pass + return [] + + def execute( + self, + model, + clip, + cine_linx, + optional_latent=None, + negative_prompt="", + relay_options=None, + promptrelay_safety_budget_enabled=True, + promptrelay_safety_budget=24000, + ): + resources = IAMCCS_CineInfo._resources(cine_linx) + outputs = IAMCCS_CineInfo._outputs(cine_linx) + payload = resources.get("cine_payload") if isinstance(resources.get("cine_payload"), dict) else {} + + global_prompt = str(resources.get("cine_global_prompt", outputs.get("global_prompt", payload.get("global_prompt", ""))) or "") + local_prompts = str(resources.get("cine_local_prompts", outputs.get("local_prompts", payload.get("local_prompts", ""))) or "") + segment_lengths = str(resources.get("cine_segment_lengths", outputs.get("segment_lengths", payload.get("segment_lengths", ""))) or "") + epsilon = _safe_float(resources.get("cine_promptrelay_epsilon", outputs.get("promptrelay_epsilon", payload.get("promptrelay_epsilon", 0.65))), 0.65) + duration_seconds = _safe_float(resources.get("cine_duration_seconds", outputs.get("duration_seconds", payload.get("duration_seconds", 8.0))), 8.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) + height = _safe_int(resources.get("cine_image_height", outputs.get("height", payload.get("image_height", 432))), 432) + 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(float(duration_seconds) * max(1, int(frame_rate)))), + str(payload.get("ltx_round_mode", "up_8n_plus_1")), + ) + + latent = optional_latent if isinstance(optional_latent, dict) else IAMCCS_CineFilmmakerBackend._empty_latent(width, height, max_frames) + negative = self._encode_basic(clip, str(negative_prompt or "")) + + locals_list = [part.strip() for part in str(local_prompts or "").split("|") if part.strip()] + length_parts = [part for part in re.split(r"[,;\s]+", str(segment_lengths or "")) if part.strip()] + relay_requested = bool(locals_list and length_parts) + safety_budget_enabled = _safe_bool( + payload.get("promptrelay_safety_budget_enabled", resources.get("cine_promptrelay_safety_budget_enabled", promptrelay_safety_budget_enabled)), + True, + ) + safety_budget = max(0, _safe_int( + payload.get("promptrelay_safety_budget", resources.get("cine_promptrelay_safety_budget", promptrelay_safety_budget)), + 24000, + )) + + promptrelay_enabled = False + relay_error = "" + patched_model = model + if relay_requested: + try: + lengths = [_safe_int(part, 0) for part in length_parts] + prompt_budget = sum(max(0, length) for length in lengths) * max(1, len(locals_list)) + if safety_budget_enabled and safety_budget > 0 and prompt_budget > safety_budget: + raise RuntimeError(f"PromptRelay disabled by optional safety budget: estimated matrix load {prompt_budget} > {safety_budget}") + promptrelay_nodes = _load_original_promptrelay_module() + patched_model, positive = promptrelay_nodes._encode_relay( + model, + clip, + latent, + global_prompt, + local_prompts, + segment_lengths, + float(epsilon), + relay_options, + ) + promptrelay_enabled = True + except Exception as exc: + relay_error = str(exc) + patched_model = model + positive = self._encode_basic(clip, global_prompt) + else: + positive = self._encode_basic(clip, global_prompt) + + global_hash = hashlib.sha1(str(global_prompt or "").encode("utf-8", errors="ignore")).hexdigest()[:12] + local_hash = hashlib.sha1(str(local_prompts or "").encode("utf-8", errors="ignore")).hexdigest()[:12] + report = _json_report({ + "node": "IAMCCS_CineShotboardBackendPro", + "mode": "INTERNAL_PROMPT_BACKEND", + "promptrelay_requested": bool(relay_requested), + "promptrelay_enabled": bool(promptrelay_enabled), + "promptrelay_error": relay_error, + "local_prompt_count": len(locals_list), + "segment_count": len(length_parts), + "segment_lengths": segment_lengths, + "epsilon": float(epsilon), + "latent_shape": self._latent_shape(latent), + "duration_seconds": float(duration_seconds), + "frame_rate": int(frame_rate), + "max_frames": int(max_frames), + "width": int(width), + "height": int(height), + "global_hash": global_hash, + "local_hash": local_hash, + "promptrelay_safety_budget_enabled": bool(safety_budget_enabled), + "promptrelay_safety_budget": int(safety_budget), + "truth": ( + "Shotboard Pro/ProV2 encoding is handled inside this IAMCCS backend. " + "Relay OFF returns normal global CLIP conditioning. Relay ON calls the original PromptRelay _encode_relay internally. " + "No external IAMCCS_CinePromptRelaySafeEncode node is required in new workflows." + ), + }) + _cine_debug( + "[IAMCCS CineShotboardBackendPro] " + f"mode={'PROMPT_RELAY_ORIGINAL_1TO1' if promptrelay_enabled else 'BASIC_TEXT_GLOBAL_ONLY'} " + f"local_prompts={len(locals_list)} segments={len(length_parts)} " + f"global_hash={global_hash} local_hash={local_hash} latent_shape={self._latent_shape(latent)}" + ) + return patched_model, positive, negative, latent, bool(promptrelay_enabled), report + + +class IAMCCS_CineFilmmakerGuide(IAMCCS_CineFLFProductor): + """Guide applicator for the Filmmaker timeline GUIDE_DATA contract.""" + + CATEGORY = "IAMCCS/Cine/02 Single Generation" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "positive": ("CONDITIONING",), + "negative": ("CONDITIONING",), + "vae": ("VAE",), + "latent": ("LATENT",), + "guide_data": ("GUIDE_DATA",), + "strength_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01}), + "tail_safety_frames": ("INT", {"default": 0, "min": 0, "max": 240, "step": 1}), + }, + } + + def execute(self, positive, negative, vae, latent, guide_data, strength_scale, tail_safety_frames): + positive, negative, out_latent, guide_data_report = self._execute_guide_data( + positive, + negative, + vae, + latent, + guide_data if isinstance(guide_data, dict) else {}, + float(strength_scale), + int(tail_safety_frames), + ) + report = _json_report({ + "node": "IAMCCS_CineFilmmakerGuide", + "mode": "pure_guide_data_applicator", + "guide_count": len(guide_data.get("images", [])) if isinstance(guide_data, dict) else 0, + "applied_count": len(guide_data_report.get("applied_guides", [])), + "skipped_count": len(guide_data_report.get("skipped_guides", [])), + **guide_data_report, + "truth": "This guide node is driven only by GUIDE_DATA from the Filmmaker backend.", + }) + return positive, negative, out_latent, report + + +class IAMCCS_CineFilmmakerGuide1to1: + """IAMCCS guide applicator matching the LTX Director Guide behavior 1:1. + + This node intentionally does not call or wrap the WhatDreamsCost node. It + implements the same guide-data contract directly inside IAMCCS so subgraph + backends can stay fully IAMCCS-owned while preserving LTX Director guide + semantics. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "positive": ("CONDITIONING",), + "negative": ("CONDITIONING",), + "vae": ("VAE",), + "latent": ("LATENT",), + "guide_data": ("GUIDE_DATA",), + "scale_by": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 8.0, "step": 0.01}), + "upscale_method": (["nearest-exact", "bilinear", "area", "bicubic", "bislerp"], {"default": "bicubic"}), + }, + } + + RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT") + RETURN_NAMES = ("positive", "negative", "latent") + FUNCTION = "execute" + CATEGORY = "IAMCCS/Cine/02 Single Generation" + + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + return float("nan") + + @classmethod + def execute(cls, positive, negative, vae, latent, guide_data, scale_by=1.0, upscale_method="bicubic"): + scale_factors = vae.downscale_index_formula + + latent_image = latent["samples"].clone() + + if "noise_mask" in latent: + noise_mask = latent["noise_mask"].clone() + else: + batch, _, latent_frames, _, _ = latent_image.shape + noise_mask = torch.ones( + (batch, 1, latent_frames, 1, 1), + dtype=torch.float32, + device=latent_image.device, + ) + + if scale_by != 1.0: + batch, channels, frames, height, width = latent_image.shape + scaled_width = round(width * scale_by) + scaled_height = round(height * scale_by) + + latent_4d = latent_image.permute(0, 2, 1, 3, 4).reshape(batch * frames, channels, height, width) + latent_resized_4d = comfy.utils.common_upscale( + latent_4d, + scaled_width, + scaled_height, + upscale_method, + "disabled", + ) + latent_image = latent_resized_4d.reshape( + batch, + frames, + channels, + scaled_height, + scaled_width, + ).permute(0, 2, 1, 3, 4) + + if noise_mask.shape[-1] > 1 or noise_mask.shape[-2] > 1: + mask_4d = noise_mask.permute(0, 2, 1, 3, 4).reshape(batch * frames, 1, height, width) + mask_resized_4d = comfy.utils.common_upscale( + mask_4d, + scaled_width, + scaled_height, + upscale_method, + "disabled", + ) + noise_mask = mask_resized_4d.reshape( + batch, + frames, + 1, + scaled_height, + scaled_width, + ).permute(0, 2, 1, 3, 4) + + _, _, latent_length, latent_height, latent_width = latent_image.shape + + images = guide_data.get("images", []) if isinstance(guide_data, dict) else [] + insert_frames = guide_data.get("insert_frames", []) if isinstance(guide_data, dict) else [] + strengths = guide_data.get("strengths", []) if isinstance(guide_data, dict) else [] + + for idx, img_tensor in enumerate(images): + frame = insert_frames[idx] if idx < len(insert_frames) else 0 + strength = strengths[idx] if idx < len(strengths) else 1.0 + + image_1, encoded = LTXVAddGuide.encode(vae, latent_width, latent_height, img_tensor, scale_factors) + frame_idx, latent_idx = LTXVAddGuide.get_latent_index( + positive, + latent_length, + len(image_1), + frame, + scale_factors, + ) + + assert latent_idx + encoded.shape[2] <= latent_length, ( + f"Guide image {idx + 1}: conditioning frames exceed the length of the latent sequence." + ) + + positive, negative, latent_image, noise_mask = LTXVAddGuide.append_keyframe( + positive, + negative, + frame_idx, + latent_image, + noise_mask, + encoded, + strength, + scale_factors, + ) + + return positive, negative, {"samples": latent_image, "noise_mask": noise_mask} + + class IAMCCS_CineSwitch: """Lazy switch between the stable FLF basic prompt path and original PromptRelay. @@ -2890,6 +5141,502 @@ class IAMCCS_CineRelayOrBypass: return patched, conditioning, True, report +class IAMCCS_CinePromptArchitect: + """Creative prompt architect for LTX 2.3 / PromptRelay / Shotboard V3.""" + + DEFAULT_BEATS = json.dumps({ + "beats": [ + { + "label": "opening_contract", + "duration": 3.0, + "image_role": "opening anchor", + "action": "the camera establishes the subject and begins one continuous physical movement", + "bridge": "carry the same camera movement into the next beat without a hard cut", + }, + { + "label": "development", + "duration": 3.0, + "image_role": "motion checkpoint", + "action": "the movement deepens; environment details react physically as the camera advances", + "bridge": "arrive toward the next visual target only near the end of the beat", + }, + { + "label": "arrival", + "duration": 3.0, + "image_role": "target keyframe", + "action": "the shot arrives at the strongest visual target with coherent identity and space", + "bridge": "", + }, + ] + }, indent=2) + + TEMPLATE_RULES = { + "continuous_dolly": { + "camera": "one continuous controlled dolly movement", + "continuity": "no hard cuts, coherent spatial continuity, stable subject identity", + }, + "future_keyframe": { + "camera": "movement toward a future visual target", + "continuity": "arrive at the target framing only near the end, no early reveal", + }, + "image_text_image": { + "camera": "visual anchor, timed prompt beat, visual target", + "continuity": "preserve identity and scene geometry between image guides", + }, + "dialogue_lipsync": { + "camera": "performance-aware camera framing", + "continuity": "natural lip sync, clear diction, expressive face, stable identity", + }, + "reveal": { + "camera": "controlled reveal with delayed arrival", + "continuity": "hide the reveal until the correct timed beat, keep the camera physically motivated", + }, + "environmental_transition": { + "camera": "continuous camera movement through a changing environment", + "continuity": "environment changes physically across time without editorial cuts", + }, + } + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "template": ([ + "continuous_dolly", + "future_keyframe", + "image_text_image", + "dialogue_lipsync", + "reveal", + "environmental_transition", + ], {"default": "continuous_dolly"}), + "subject_identity": ("STRING", {"default": "the same main subject, stable identity", "multiline": True}), + "environment": ("STRING", {"default": "a cinematic physical environment with readable depth", "multiline": True}), + "lighting_weather": ("STRING", {"default": "natural realistic lighting, atmospheric depth", "multiline": True}), + "visual_style": ("STRING", {"default": "cinematic realism, grounded camera physics, detailed texture", "multiline": True}), + "camera_language": ("STRING", {"default": "slow controlled camera movement", "multiline": True}), + "continuity_rules": ("STRING", {"default": "no hard cuts, coherent spatial continuity, stable subject identity", "multiline": True}), + "shot_goal": ("STRING", {"default": "direct one continuous cinematic shot with clear temporal progression", "multiline": True}), + "movement_path": ("STRING", {"default": "the camera moves forward through the scene with physical parallax", "multiline": True}), + "target_reveal": ("STRING", {"default": "", "multiline": True}), + "performance_or_emotion": ("STRING", {"default": "", "multiline": True}), + "audio_or_dialogue": ("STRING", {"default": "", "multiline": True}), + "avoid": ("STRING", {"default": "hard cuts, identity drift, duplicated subjects, frozen motion, early reveal", "multiline": True}), + "duration_seconds": ("FLOAT", {"default": 9.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}), + "beat_data": ("STRING", { + "default": cls.DEFAULT_BEATS, + "multiline": True, + "tooltip": "Edited by the CinePrompt Architect UI. JSON with beats: label, duration, image_role, action, bridge.", + }), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("global_prompt", "local_prompts", "segment_lengths", "shotboard_timeline_json", "board_json", "markdown_preview", "report") + FUNCTION = "build" + CATEGORY = "IAMCCS/Cine/01 Prompting" + + @staticmethod + def _clean_parts(parts: List[Any]) -> List[str]: + out: List[str] = [] + seen = set() + for part in parts: + text = re.sub(r"\s+", " ", str(part or "").strip(" ,.;\n\t")) + if not text: + continue + key = text.lower() + if key in seen: + continue + seen.add(key) + out.append(text) + return out + + @classmethod + def _join_sentence(cls, parts: List[Any]) -> str: + clean = cls._clean_parts(parts) + if not clean: + return "" + text = ", ".join(clean) + return text[:1].lower() + text[1:] if text else "" + + @classmethod + def _parse_beats(cls, beat_data: Any, beat_count: int, duration_seconds: float) -> List[Dict[str, Any]]: + data = _safe_json_loads(str(beat_data or ""), {}) + raw = data if isinstance(data, list) else data.get("beats", []) if isinstance(data, dict) else [] + if not isinstance(raw, list): + raw = [] + beat_count = max(1, min(8, int(beat_count or 1))) + fallback_duration = max(0.1, float(duration_seconds) / float(beat_count)) + beats: List[Dict[str, Any]] = [] + for index in range(beat_count): + item = raw[index] if index < len(raw) and isinstance(raw[index], dict) else {} + duration = max(0.1, _safe_float(item.get("duration", item.get("seconds", fallback_duration)), fallback_duration)) + label = _normalise_label(str(item.get("label", item.get("name", "")) or ""), f"beat_{index + 1}") + action = str(item.get("action", item.get("local_prompt", item.get("prompt", ""))) or "").strip() + if not action: + action = "the camera continues one clear cinematic action through this timed beat" + bridge = str(item.get("bridge", item.get("action_to_next", item.get("step_transition_prompt", ""))) or "").strip() + beats.append({ + "label": label, + "duration": duration, + "image_role": str(item.get("image_role", item.get("image_hint", "")) or "").strip(), + "action": action, + "bridge": bridge, + }) + return beats + + @classmethod + def _global_prompt(cls, template: str, subject_identity: str, environment: str, lighting_weather: str, visual_style: str, camera_language: str, continuity_rules: str, shot_goal: str, movement_path: str, target_reveal: str, performance_or_emotion: str, audio_or_dialogue: str) -> str: + rules = cls.TEMPLATE_RULES.get(str(template), cls.TEMPLATE_RULES["continuous_dolly"]) + return cls._join_sentence([ + shot_goal, + subject_identity, + environment, + lighting_weather, + visual_style, + camera_language or rules.get("camera"), + movement_path, + target_reveal, + performance_or_emotion, + audio_or_dialogue, + continuity_rules or rules.get("continuity"), + ]) + + @classmethod + def _local_prompt(cls, template: str, beat: Dict[str, Any], index: int, total: int, target_reveal: str, audio_or_dialogue: str) -> str: + action = str(beat.get("action", "") or "").strip() + bridge = str(beat.get("bridge", "") or "").strip() + parts: List[str] = [action] + if bridge and index < total - 1: + parts.append(bridge) + if str(template) in {"future_keyframe", "reveal"} and index < total - 1: + parts.append("arrive at the next visual target only near the end of this beat") + if str(template) == "dialogue_lipsync" and audio_or_dialogue: + parts.append("lips sync naturally to the dialogue, diction is clear, facial expression remains alive and precise") + if target_reveal and index == total - 1: + parts.append(target_reveal) + return cls._join_sentence(parts) + + @classmethod + def _build_rows(cls, beats: List[Dict[str, Any]], local_prompts: List[str]) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + cursor = 0.0 + for index, beat in enumerate(beats): + duration = max(0.1, float(beat.get("duration", 0.1))) + prompt = local_prompts[index] if index < len(local_prompts) else str(beat.get("action", "")) + bridge = str(beat.get("bridge", "") or "").strip() + rows.append({ + "second": round(cursor, 3), + "ref": index + 1, + "force": 0.0, + "image_lock_strength": 0.0, + "use_guide": False, + "use_prompt": bool(prompt), + "label": str(beat.get("label", f"beat_{index + 1}")), + "camera": "prompt relay text", + "transition": "prompt_relay_text", + "note": str(beat.get("image_role", "")), + "relay_prompt": prompt, + "camera_relay_mode": "off", + "transition_relay_mode": "off", + "relay_addon_position": "after", + "relay_modifier_text": "", + "step_transition_enabled": bool(bridge and index < len(beats) - 1), + "step_transition_type": "action_beat" if bridge and index < len(beats) - 1 else "off", + "step_transition_prompt": bridge if index < len(beats) - 1 else "", + "step_transition_easing": "ease_in_out", + "step_transition_force_curve": "balanced", + "step_transition_duration": duration if bridge and index < len(beats) - 1 else 0.0, + "step_transition_arrival": "auto", + "step_transition_auto_fit": True, + }) + cursor += duration + return rows + + @classmethod + def _markdown(cls, global_prompt: str, local_prompts: List[str], segment_lengths: List[int], beats: List[Dict[str, Any]], avoid: str) -> str: + lines = [ + "# CinePrompt Architect Preview", + "", + "## Global Prompt", + "", + "```text", + global_prompt, + "```", + "", + "## PromptRelay Beats", + "", + "| Beat | Duration | Frames | Image role | Local prompt |", + "|---:|---:|---:|---|---|", + ] + for index, beat in enumerate(beats): + frames = segment_lengths[index] if index < len(segment_lengths) else 0 + prompt = local_prompts[index] if index < len(local_prompts) else "" + lines.append( + f"| {index + 1} | {float(beat.get('duration', 0.0)):.2f}s | {frames} | " + f"{str(beat.get('image_role', '') or '').replace('|', '/')} | {prompt.replace('|', '/')} |" + ) + lines.extend([ + "", + "## Local Prompts", + "", + "```text", + " | ".join(local_prompts), + "```", + "", + "## Segment Lengths", + "", + "```text", + ",".join(str(int(x)) for x in segment_lengths), + "```", + ]) + if str(avoid or "").strip(): + lines.extend(["", "## Avoid", "", str(avoid).strip()]) + return "\n".join(lines) + + def build(self, template, subject_identity, environment, lighting_weather, visual_style, camera_language, continuity_rules, shot_goal, movement_path, target_reveal, performance_or_emotion, audio_or_dialogue, avoid, duration_seconds, frame_rate, beat_count, beat_data): + duration = max(0.1, float(duration_seconds)) + fps = max(1.0, float(frame_rate)) + beats = self._parse_beats(beat_data, int(beat_count), duration) + total_beat_duration = sum(max(0.1, float(beat.get("duration", 0.1))) for beat in beats) or duration + scale = duration / total_beat_duration + segment_lengths = [max(1, int(round(max(0.1, float(beat.get("duration", 0.1))) * scale * fps))) for beat in beats] + target_frames = max(1, int(round(duration * fps))) + if segment_lengths: + segment_lengths[-1] = max(1, segment_lengths[-1] + target_frames - sum(segment_lengths)) + + global_prompt = self._global_prompt(str(template), subject_identity, environment, lighting_weather, visual_style, camera_language, continuity_rules, shot_goal, movement_path, target_reveal, performance_or_emotion, audio_or_dialogue) + local_prompts = [self._local_prompt(str(template), beat, index, len(beats), str(target_reveal or ""), str(audio_or_dialogue or "")) for index, beat in enumerate(beats)] + rows = self._build_rows(beats, local_prompts) + timeline = {"rows": rows} + board = { + "metadata": { + "schema": "iamccs.cine.prompt_architect.board", + "schema_version": 1, + "node_type": "IAMCCS_CinePromptArchitect", + "image_storage": "manual_after_import", + "notes": "Creative prompt/relay board only. Add images, resolution and backend settings inside Shotboard Planner V3/V2.", + }, + "global_prompt": global_prompt, + "prompt": global_prompt, + "timeline_data": json.dumps(timeline, ensure_ascii=False, indent=2), + "rows": rows, + "duration_seconds": duration, + "frame_rate": fps, + "image_paths": "", + "images": [], + } + markdown = self._markdown(global_prompt, local_prompts, segment_lengths, beats, str(avoid or "")) + report = _json_report({ + "node": "IAMCCS_CinePromptArchitect", + "template": str(template), + "beat_count": len(beats), + "duration_seconds": duration, + "frame_rate": fps, + "local_prompt_count": len([p for p in local_prompts if p.strip()]), + "segment_lengths": segment_lengths, + "truth": "Creative prompt architecture node: outputs global/local prompts and Shotboard-importable timing only. Technical generation settings stay in Shotboard/Backend.", + }) + return ( + global_prompt, + " | ".join(local_prompts), + ",".join(str(int(x)) for x in segment_lengths), + json.dumps(timeline, ensure_ascii=False, indent=2), + json.dumps(board, ensure_ascii=False, indent=2), + markdown, + report, + ) + + +class IAMCCS_BoardMaker: + """Planner assistant that exports Shotboard-importable board JSON without images.""" + + DEFAULT_ROWS = json.dumps({ + "rows": [ + { + "label": "box_1", + "duration": 3.0, + "local_prompt": "opening beat, establish the subject and camera direction", + "bridge": "camera continues into the next beat through one connected movement", + "camera": "slow push-in", + "transition": "continuous_motion", + "force": 0.28, + "use_guide": True, + "use_prompt": True, + }, + { + "label": "box_2", + "duration": 3.0, + "local_prompt": "second beat, deepen the action and maintain visual continuity", + "bridge": "movement carries the viewer toward the final beat", + "camera": "continuous dolly-in", + "transition": "continuous_motion", + "force": 0.24, + "use_guide": True, + "use_prompt": True, + }, + { + "label": "box_3", + "duration": 3.0, + "local_prompt": "final beat, arrive at the strongest visual target", + "bridge": "", + "camera": "macro push-in", + "transition": "continuous_motion", + "force": 0.32, + "use_guide": True, + "use_prompt": True, + }, + ] + }, indent=2) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "global_prompt": ("STRING", { + "default": "One continuous cinematic shot with coherent camera movement, stable identity, physical parallax and connected visual progression.", + "multiline": True, + }), + "duration_seconds": ("FLOAT", {"default": 9.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}), + "image_width": ("INT", {"default": 768, "min": 64, "max": 8192, "step": 32}), + "image_height": ("INT", {"default": 432, "min": 64, "max": 8192, "step": 32}), + "default_force": ("FLOAT", {"default": 0.28, "min": 0.0, "max": 1.0, "step": 0.01}), + "guide_policy": (["every_checked_row", "first_last", "all", "none"], {"default": "every_checked_row"}), + "board_name": ("STRING", {"default": "iamccs_boardmaker_board"}), + "board_data": ("STRING", { + "default": cls.DEFAULT_ROWS, + "multiline": True, + "tooltip": "Edited by the IAMCCS_BoardMaker UI. JSON with rows: duration, local_prompt, bridge, camera, transition, force.", + }), + } + } + + RETURN_TYPES = ("STRING", "STRING", "INT", "FLOAT", "STRING") + RETURN_NAMES = ("board_json", "timeline_data", "row_count", "duration_seconds", "report") + FUNCTION = "make_board" + CATEGORY = "IAMCCS/Cinema" + + @staticmethod + def _rows_from_data(board_data: Any, default_force: float) -> List[Dict[str, Any]]: + data = _safe_json_loads(str(board_data or ""), {}) + if isinstance(data, list): + raw_rows = data + elif isinstance(data, dict): + raw_rows = data.get("rows") if isinstance(data.get("rows"), list) else [] + else: + raw_rows = [] + + rows: List[Dict[str, Any]] = [] + cursor = 0.0 + for idx, item in enumerate(raw_rows): + if not isinstance(item, dict): + continue + duration = max(0.1, _safe_float(item.get("duration", item.get("seconds", item.get("len", 3.0))), 3.0)) + second = _safe_float(item.get("second", item.get("start", cursor)), cursor) + force = _clamp(item.get("force", item.get("guide_strength", default_force)), 0.0, 1.0, default_force) + label = _normalise_label(str(item.get("label", item.get("name", "")) or ""), f"box_{idx + 1}") + local_prompt = str(item.get("relay_prompt", item.get("local_prompt", item.get("prompt", ""))) or "").strip() + bridge = str(item.get("step_transition_prompt", item.get("bridge", item.get("action_to_next", item.get("note", "")))) or "").strip() + transition_type = str(item.get("step_transition_type", "action_beat" if bridge else "off") or "off").strip() + rows.append({ + "_ui_id": str(item.get("_ui_id", f"boardmaker_{idx + 1:02d}_{label}")), + "second": round(max(0.0, second), 3), + "ref": max(1, _safe_int(item.get("ref", item.get("image_ref", idx + 1)), idx + 1)), + "force": force, + "image_lock_strength": force, + "use_guide": IAMCCS_CineShotboardTimelinePro._as_bool(item.get("use_guide", item.get("guide", True)), True), + "use_prompt": IAMCCS_CineShotboardTimelinePro._as_bool(item.get("use_prompt", item.get("relay", bool(local_prompt or bridge))), bool(local_prompt or bridge)), + "label": label, + "camera": str(item.get("camera", item.get("camera_move", "continuous dolly-in")) or "continuous dolly-in").strip(), + "transition": str(item.get("transition", "continuous_motion") or "continuous_motion").strip(), + "note": bridge, + "relay_prompt": local_prompt, + "use_relay_modifiers": False, + "camera_relay_mode": str(item.get("camera_relay_mode", "off") or "off").strip(), + "transition_relay_mode": str(item.get("transition_relay_mode", "off") or "off").strip(), + "relay_addon_position": str(item.get("relay_addon_position", "after") or "after").strip(), + "relay_modifier_text": str(item.get("relay_modifier_text", "") or "").strip(), + "step_transition_enabled": bool(bridge and transition_type != "off"), + "step_transition_type": transition_type, + "step_transition_prompt": bridge, + "step_transition_easing": str(item.get("step_transition_easing", "ease_in_out") or "ease_in_out").strip(), + "step_transition_force_curve": str(item.get("step_transition_force_curve", "balanced") or "balanced").strip(), + "step_transition_duration": max(0.0, _safe_float(item.get("step_transition_duration", item.get("bridge_duration", 0.0)), 0.0)), + "step_transition_arrival": str(item.get("step_transition_arrival", "auto") or "auto").strip(), + "step_transition_auto_fit": IAMCCS_CineShotboardTimelinePro._as_bool(item.get("step_transition_auto_fit", True), True), + "duration": duration, + }) + cursor = max(cursor, second + duration) + return rows + + @classmethod + def _build_board(cls, global_prompt: str, duration_seconds: float, frame_rate: float, image_width: int, image_height: int, default_force: float, guide_policy: str, board_name: str, board_data: str) -> Dict[str, Any]: + rows = cls._rows_from_data(board_data, float(default_force)) + width = max(64, _safe_int(image_width, 768)) + height = max(64, _safe_int(image_height, 432)) + if rows: + last_end = max( + float(row.get("second", 0.0)) + max(0.1, _safe_float(row.get("duration", 0.1), 0.1)) + for row in rows + ) + duration = max(float(duration_seconds), float(last_end)) + else: + duration = max(0.1, float(duration_seconds)) + timeline_data = json.dumps({"rows": rows}, ensure_ascii=False, indent=2) + return { + "metadata": { + "schema": "iamccs.cine.shotboard.board", + "schema_version": 1, + "cine_ui_version": "2026-05-19-boardmaker-1", + "saved_at": datetime.datetime.now().isoformat(), + "node_type": "IAMCCS_BoardMaker", + "board_name": str(board_name or "iamccs_boardmaker_board"), + "image_storage": "manual_after_import", + "notes": "BoardMaker creates rows and prompts only. Add/import images manually in Shotboard V2 or V3 after importing this board.", + }, + "global_prompt": str(global_prompt or ""), + "prompt": str(global_prompt or ""), + "timeline_data": timeline_data, + "rows": rows, + "settings": { + "duration_seconds": duration, + "frame_rate": float(frame_rate), + "guide_policy": str(guide_policy or "every_checked_row"), + "min_guide_gap_seconds": 0.0, + "max_guides": max(1, len(rows)), + "default_force": float(default_force), + "promptrelay_epsilon": 0.6, + "ltx_round_mode": "up_8n_plus_1", + "image_width": width, + "image_height": height, + "image_resize_method": "crop", + "image_multiple_of": 32, + "img_compression": 0, + }, + "duration_seconds": duration, + "frame_rate": float(frame_rate), + "guide_policy": str(guide_policy or "every_checked_row"), + "default_force": float(default_force), + "image_width": width, + "image_height": height, + "image_paths": "", + "images": [], + } + + def make_board(self, global_prompt, duration_seconds, frame_rate, image_width, image_height, default_force, guide_policy, board_name, board_data): + board = self._build_board(global_prompt, duration_seconds, frame_rate, image_width, image_height, default_force, guide_policy, board_name, board_data) + report = _json_report({ + "node": "IAMCCS_BoardMaker", + "row_count": len(board.get("rows", [])), + "duration_seconds": board.get("duration_seconds"), + "image_policy": "manual_after_import", + "truth": "Exported JSON can be dragged onto Shotboard V2 or V3. Images are intentionally empty and should be added manually after import.", + }) + return json.dumps(board, ensure_ascii=False, indent=2), str(board.get("timeline_data", "")), len(board.get("rows", [])), float(board.get("duration_seconds", 0.0)), report + + class IAMCCS_CineMusicVideoPlanner: """Videoclip-maker planner inspired by audio/music sequencer workflows.""" @@ -2974,7 +5721,7 @@ class IAMCCS_CineMusicVideoPlanner: "placeholder": "Optional: connect/paste output from a music-video sequencer. If empty, manual timing is used.", }), "global_video_prompt": ("STRING", { - "default": "cinematic music video, rhythm-driven camera movement, coherent subject identity, no slideshow, no random flashes", + "default": "cinematic music video with rhythm-driven camera movement, coherent subject identity and continuous visual energy", "multiline": True, }), "image_style_modifier": ("STRING", { diff --git a/iamccs_cine_resolution_parity.py b/iamccs_cine_resolution_parity.py new file mode 100644 index 0000000..d021cf0 --- /dev/null +++ b/iamccs_cine_resolution_parity.py @@ -0,0 +1,419 @@ +from __future__ import annotations + +import base64 +import copy +import hashlib +import io +import json +import math +import os +import re +import time +from typing import Any, Dict, Iterable, List, Optional, Tuple + +import folder_paths +from PIL import Image, ImageOps + + +SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX" +_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tif", ".tiff", ".avif"} +_MOTION_WORDS = { + "sea", "ocean", "water", "wave", "waves", "foam", "surface", "underwater", + "bubble", "bubbles", "particle", "particles", "caustic", "caustics", + "dolly", "push", "push-in", "camera", "glide", "travel", "travels", "move", + "moves", "moving", "motion", "parallax", "drift", "drifts", "roll", "rolls", + "slide", "slides", "flow", "flows", +} +_IDENTITY_WORDS = { + "face", "siren", "sirena", "mermaid", "creature", "character", "eyes", "mouth", + "skin", "portrait", "closeup", "close-up", "macro", "head", "woman", +} + + +def _clamp(value: Any, lo: float, hi: float, default: float = 0.0) -> float: + try: + number = float(value) + except Exception: + number = float(default) + return max(float(lo), min(float(hi), number)) + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(round(float(value))) + except Exception: + return int(default) + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except Exception: + return float(default) + + +def _safe_json_loads(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 _sanitize(value: Any, fallback: str = "cine_resolution_parity") -> str: + clean = re.sub(r'[<>:"/\\|?*\x00-\x1F]+', "_", str(value or fallback).strip()) + clean = re.sub(r"\s+", "_", clean).strip("._") + return (clean[:80] or fallback) + + +def _split_paths(value: Any) -> List[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item or "").strip()] + raw = str(value or "").strip() + if not raw: + return [] + parsed = _safe_json_loads(raw, None) + if isinstance(parsed, list): + return [str(item).strip() for item in parsed if str(item or "").strip()] + if "\n" in raw or "\r" in raw: + return [item.strip() for item in raw.splitlines() if item.strip()] + parts = [item.strip() for item in raw.split(",") if item.strip()] + return parts if len(parts) > 1 else [raw] + + +def _join_paths(paths: Iterable[str]) -> str: + return "\n".join(str(path or "").strip() for path in paths if str(path or "").strip()) + + +def _text_for_segment(seg: Dict[str, Any]) -> str: + parts = [ + seg.get("label", ""), + seg.get("prompt", ""), + seg.get("camera", ""), + seg.get("transition", ""), + seg.get("note", ""), + seg.get("relay_prompt", ""), + seg.get("step_transition_prompt", ""), + ] + return " ".join(str(part or "").lower() for part in parts) + + +def _classify_segment(seg: Dict[str, Any]) -> str: + text = _text_for_segment(seg) + identity_hits = sum(1 for word in _IDENTITY_WORDS if word in text) + motion_hits = sum(1 for word in _MOTION_WORDS if word in text) + if identity_hits and identity_hits >= motion_hits: + return "identity" + if motion_hits: + return "motion" + return "neutral" + + +def _strength_from_segment(seg: Dict[str, Any], default: float = 1.0) -> float: + for key in ("guide_strength", "guideStrength", "strength", "force", "motion_force"): + if key in seg and seg.get(key) is not None: + return _clamp(seg.get(key), 0.0, 1.0, default) + return _clamp(default, 0.0, 1.0, 1.0) + + +def _set_strength(seg: Dict[str, Any], value: float) -> None: + strength = float(_clamp(value, 0.0, 1.0, 1.0)) + for key in ("guideStrength", "guide_strength", "strength", "force", "imageLockStrength", "image_lock_strength"): + if key in seg: + seg[key] = strength + if not any(key in seg for key in ("guideStrength", "guide_strength", "strength", "force")): + seg["guideStrength"] = strength + + +def _resolve_image_path(path: str) -> Optional[str]: + clean = str(path or "").strip() + if not clean or clean.startswith("data:"): + return None + if os.path.isabs(clean): + return os.path.abspath(os.path.expanduser(clean)) + return os.path.abspath(os.path.join(folder_paths.get_input_directory(), clean.replace("/", os.sep))) + + +def _open_image(path_or_data: str) -> Image.Image: + clean = str(path_or_data or "").strip() + if clean.startswith("data:image"): + _, _, payload = clean.partition(",") + if not payload: + raise ValueError("Invalid image data URL") + return Image.open(io.BytesIO(base64.b64decode(payload))) + source = _resolve_image_path(clean) + if not source or not os.path.isfile(source): + raise FileNotFoundError(f"Reference image not found: {clean}") + return Image.open(source) + + +def _resize_cover(image: Image.Image, width: int, height: int, resample: int) -> Image.Image: + src_w, src_h = image.size + if src_w <= 0 or src_h <= 0: + return image.resize((width, height), resample) + target_ratio = width / float(height) + src_ratio = src_w / float(src_h) + if src_ratio >= target_ratio: + crop_h = src_h + crop_w = int(round(crop_h * target_ratio)) + else: + crop_w = src_w + crop_h = int(round(crop_w / target_ratio)) + left = max(0, int(round((src_w - crop_w) / 2.0))) + top = max(0, int(round((src_h - crop_h) / 2.0))) + return image.crop((left, top, min(src_w, left + crop_w), min(src_h, top + crop_h))).resize((width, height), resample) + + +def _prefilter_image_to_target( + source: str, + target_width: int, + target_height: int, + reference_width: int, + reference_height: int, + method: str, + package_tag: str, +) -> str: + resample = { + "nearest": Image.Resampling.NEAREST, + "bilinear": Image.Resampling.BILINEAR, + "bicubic": Image.Resampling.BICUBIC, + "lanczos": Image.Resampling.LANCZOS, + }.get(str(method or "bicubic").lower(), Image.Resampling.BICUBIC) + + with _open_image(source) as raw: + image = ImageOps.exif_transpose(raw).convert("RGB") + semantic = _resize_cover(image, max(64, reference_width), max(64, reference_height), resample) + target = semantic.resize((max(64, target_width), max(64, target_height)), resample) + + out_root = os.path.join(folder_paths.get_input_directory(), "IAMCCS_resolution_parity") + os.makedirs(out_root, exist_ok=True) + source_name = os.path.basename(_resolve_image_path(source) or "reference.png") + stem = _sanitize(os.path.splitext(source_name)[0], "ref")[:48] + digest = hashlib.sha1(str(source).encode("utf-8", errors="ignore")).hexdigest()[:10] + filename = f"{package_tag}_{stem}_{digest}_{target_width}x{target_height}.png" + out_path = os.path.join(out_root, filename) + target.save(out_path, "PNG", optimize=True) + return "IAMCCS_resolution_parity/" + filename + + +def _rewrite_image_refs(obj: Any, path_map: Dict[str, str]) -> None: + if isinstance(obj, dict): + for key, value in list(obj.items()): + if key in {"imageFile", "image_file", "path"} and isinstance(value, str) and value in path_map: + obj[key] = path_map[value] + else: + _rewrite_image_refs(value, path_map) + elif isinstance(obj, list): + for item in obj: + _rewrite_image_refs(item, path_map) + + +def _apply_strength_translation(segments: List[Dict[str, Any]], motion_multiplier: float, identity_multiplier: float) -> Dict[str, Any]: + counts = {"motion": 0, "identity": 0, "neutral": 0} + values = [] + for seg in segments: + if not isinstance(seg, dict): + continue + role = _classify_segment(seg) + counts[role] = counts.get(role, 0) + 1 + old = _strength_from_segment(seg, 1.0) + if role == "motion": + new = old * motion_multiplier + elif role == "identity": + new = old * identity_multiplier + else: + new = old + new = _clamp(new, 0.0, 1.0, old) + _set_strength(seg, new) + seg["resolution_parity_role"] = role + seg["resolution_parity_original_strength"] = float(old) + seg["resolution_parity_strength"] = float(new) + values.append({"label": seg.get("label", ""), "role": role, "from": float(old), "to": float(new)}) + return {"counts": counts, "values": values} + + +class IAMCCS_CineResolutionParityTranslator: + """Translate a proven lower-resolution shotboard into a higher-resolution run.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "cine_linx": (SUPERNODE_LINX_TYPE,), + "enabled": ("BOOLEAN", {"default": True}), + "reference_width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 32}), + "reference_height": ("INT", {"default": 576, "min": 64, "max": 8192, "step": 32}), + "guide_prefilter": ("BOOLEAN", {"default": True}), + "prefilter_method": (["bicubic", "lanczos", "bilinear", "nearest"], {"default": "bicubic"}), + "motion_multiplier": ("FLOAT", { + "default": 0.0, + "min": 0.0, + "max": 2.0, + "step": 0.01, + "tooltip": "0 = auto, computed as reference/target linear scale.", + }), + "identity_multiplier": ("FLOAT", {"default": 1.05, "min": 0.0, "max": 2.0, "step": 0.01}), + }, + } + + RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING") + RETURN_NAMES = ("cine_linx", "report") + FUNCTION = "translate" + CATEGORY = "IAMCCS/Cine/02 Single Generation" + + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + return float("nan") + + def translate( + self, + cine_linx, + enabled=True, + reference_width=1024, + reference_height=576, + guide_prefilter=True, + prefilter_method="bicubic", + motion_multiplier=0.0, + identity_multiplier=1.05, + ): + if not isinstance(cine_linx, dict): + return cine_linx, json.dumps({"ok": False, "error": "cine_linx is not a dictionary"}, indent=2) + if not enabled: + return cine_linx, json.dumps({"ok": True, "enabled": False, "note": "Resolution parity translator bypassed."}, indent=2) + + out = dict(cine_linx) + original_resources = cine_linx.get("resources") if isinstance(cine_linx.get("resources"), dict) else {} + resources = dict(original_resources) + out["resources"] = resources + outputs = dict(cine_linx.get("outputs") if isinstance(cine_linx.get("outputs"), dict) else {}) + out["outputs"] = outputs + payload = resources.get("cine_payload") + if not isinstance(payload, dict): + payload = {} + else: + payload = copy.deepcopy(payload) + resources["cine_payload"] = payload + + target_width = _safe_int(resources.get("cine_image_width", outputs.get("width", payload.get("image_width", 0))), 0) + target_height = _safe_int(resources.get("cine_image_height", outputs.get("height", payload.get("image_height", 0))), 0) + if target_width <= 0: + target_width = _safe_int(payload.get("image_width"), 768) + if target_height <= 0: + target_height = _safe_int(payload.get("image_height"), 432) + + resources["cine_image_width"] = int(target_width) + resources["cine_image_height"] = int(target_height) + payload["image_width"] = int(target_width) + payload["image_height"] = int(target_height) + outputs["width"] = int(target_width) + outputs["height"] = int(target_height) + + ref_w = max(64, _safe_int(reference_width, 1024)) + ref_h = max(64, _safe_int(reference_height, 576)) + scale_x = target_width / float(ref_w) + scale_y = target_height / float(ref_h) + linear_scale = math.sqrt(max(0.0001, scale_x * scale_y)) + auto_motion = 1.0 / linear_scale if linear_scale > 1.0 else 1.0 + motion_mul = float(auto_motion if _safe_float(motion_multiplier, 0.0) <= 0 else motion_multiplier) + identity_mul = float(identity_multiplier) + + visual_segments = _safe_json_loads(resources.get("cine_visual_segments_json", payload.get("visual_segments", [])), []) + if isinstance(visual_segments, dict): + visual_segments = visual_segments.get("segments", []) + if not isinstance(visual_segments, list): + visual_segments = [] + + strength_report = _apply_strength_translation(visual_segments, motion_mul, identity_mul) + + original_paths = _split_paths(resources.get("cine_image_paths", payload.get("image_paths", ""))) + path_map: Dict[str, str] = {} + image_errors = [] + package_tag = f"parity_{int(time.time() * 1000)}" + + if guide_prefilter and target_width > 0 and target_height > 0: + for path in original_paths: + try: + path_map[path] = _prefilter_image_to_target( + path, + target_width, + target_height, + ref_w, + ref_h, + str(prefilter_method), + package_tag, + ) + except Exception as exc: + image_errors.append({"path": path, "error": str(exc)}) + for seg in visual_segments: + if not isinstance(seg, dict): + continue + image_file = str(seg.get("imageFile", seg.get("image_file", "")) or "").strip() + if image_file and image_file not in path_map: + try: + path_map[image_file] = _prefilter_image_to_target( + image_file, + target_width, + target_height, + ref_w, + ref_h, + str(prefilter_method), + package_tag, + ) + except Exception as exc: + image_errors.append({"path": image_file, "error": str(exc)}) + + if path_map: + translated_paths = [path_map.get(path, path) for path in original_paths] + resources["cine_image_paths"] = _join_paths(translated_paths) + payload["image_paths"] = resources["cine_image_paths"] + _rewrite_image_refs(visual_segments, path_map) + _rewrite_image_refs(payload.get("rows"), path_map) + _rewrite_image_refs(payload.get("guide_rows"), path_map) + + resources["cine_visual_segments_json"] = json.dumps(visual_segments, ensure_ascii=False) + payload["visual_segments"] = visual_segments + payload["resolution_parity"] = { + "enabled": True, + "reference_width": int(ref_w), + "reference_height": int(ref_h), + "target_width": int(target_width), + "target_height": int(target_height), + "linear_scale": float(linear_scale), + "motion_multiplier": float(motion_mul), + "identity_multiplier": float(identity_mul), + "guide_prefilter": bool(guide_prefilter), + "prefilter_method": str(prefilter_method), + "prefiltered_images": len(path_map), + } + resources["cine_resolution_parity"] = payload["resolution_parity"] + + stages = [] + for stage in out.get("stages", []) if isinstance(out.get("stages"), list) else []: + if isinstance(stage, dict) and isinstance(stage.get("payload"), dict): + stage_copy = dict(stage) + stage_copy["payload"] = payload + stages.append(stage_copy) + else: + stages.append(stage) + if stages: + out["stages"] = stages + + resources["cine_payload"] = payload + out["resource_keys"] = sorted(resources.keys()) + out["resource_types"] = {key: type(value).__name__ for key, value in resources.items()} + + report = { + "ok": True, + "node": "IAMCCS_CineResolutionParityTranslator", + "reference": [int(ref_w), int(ref_h)], + "target": [int(target_width), int(target_height)], + "linear_scale": round(float(linear_scale), 4), + "motion_multiplier": round(float(motion_mul), 4), + "identity_multiplier": round(float(identity_mul), 4), + "guide_prefilter": bool(guide_prefilter), + "prefiltered_images": len(path_map), + "image_errors": image_errors, + "strength_translation": strength_report, + } + return out, json.dumps(report, indent=2, ensure_ascii=False) diff --git a/iamccs_cine_stage2_preview_toggle.py b/iamccs_cine_stage2_preview_toggle.py new file mode 100644 index 0000000..d5accdf --- /dev/null +++ b/iamccs_cine_stage2_preview_toggle.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +from typing import Any + + +class IAMCCS_CineStage2PreviewToggle: + """Gate KJ/LTX sampling previews on the model branch that feeds Stage 2.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model": ("MODEL",), + "stage2_preview_enabled": ("BOOLEAN", { + "default": False, + "tooltip": ( + "OFF removes the KJ LTX2 sampling_preview wrapper from this model branch. " + "Use it before Stage 2 to avoid TAELTX preview OOM while keeping the render active." + ), + }), + }, + } + + RETURN_TYPES = ("MODEL", "STRING") + RETURN_NAMES = ("model", "report") + FUNCTION = "toggle" + CATEGORY = "IAMCCS/Cine/02 Single Generation" + + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + return float("nan") + + @staticmethod + def _count_sampling_preview_wrappers(model: Any) -> int: + try: + import comfy.patcher_extension as patcher_extension + + wrappers = model.get_wrappers(patcher_extension.WrappersMP.OUTER_SAMPLE, "sampling_preview") + return len(wrappers or []) + except Exception: + return 0 + + def toggle(self, model: Any, stage2_preview_enabled: bool = False): + before = self._count_sampling_preview_wrappers(model) + selected_model = model + removed = 0 + if not bool(stage2_preview_enabled): + try: + import comfy.patcher_extension as patcher_extension + + selected_model = model.clone() + selected_model.remove_wrappers_with_key( + patcher_extension.WrappersMP.OUTER_SAMPLE, + "sampling_preview", + ) + after = self._count_sampling_preview_wrappers(selected_model) + removed = max(0, int(before) - int(after)) + except Exception as exc: + report = { + "node": "IAMCCS_CineStage2PreviewToggle", + "stage2_preview_enabled": bool(stage2_preview_enabled), + "status": "failed_to_remove_wrapper", + "sampling_preview_wrappers_before": int(before), + "error": str(exc), + } + return model, json.dumps(report, indent=2) + else: + after = before + + report = { + "node": "IAMCCS_CineStage2PreviewToggle", + "stage2_preview_enabled": bool(stage2_preview_enabled), + "status": "preview_passthrough" if bool(stage2_preview_enabled) else "stage2_preview_wrapper_removed", + "sampling_preview_wrappers_before": int(before), + "sampling_preview_wrappers_after": int(after), + "sampling_preview_wrappers_removed": int(removed), + "truth": ( + "Place this node only on the Stage 2 model branch. OFF disables the KJ/TAELTX sampling " + "preview callback for Stage 2 without disabling PromptRelay, guide data, or Stage 2 sampling." + ), + } + return selected_model, json.dumps(report, indent=2) + diff --git a/iamccs_cine_stage_switch.py b/iamccs_cine_stage_switch.py new file mode 100644 index 0000000..a5c0a27 --- /dev/null +++ b/iamccs_cine_stage_switch.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import json +from typing import Any + + +class IAMCCS_CineStage2BypassSwitch: + """Lazy switch for choosing Stage 1 or Stage 2 latents before decode.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "use_second_stage": ("BOOLEAN", { + "default": True, + "tooltip": "True: decode Stage 2 output. False: bypass Stage 2 and decode Stage 1 output.", + }), + }, + "optional": { + "stage1_audio_latent": ("LATENT", {"lazy": True}), + "stage1_video_latent": ("LATENT", {"lazy": True}), + "stage2_audio_latent": ("LATENT", {"lazy": True}), + "stage2_video_latent": ("LATENT", {"lazy": True}), + }, + } + + RETURN_TYPES = ("LATENT", "LATENT", "STRING") + RETURN_NAMES = ("audio_latent", "video_latent", "report") + FUNCTION = "switch" + CATEGORY = "IAMCCS/Cine/02 Single Generation" + + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + return float("nan") + + def check_lazy_status( + self, + use_second_stage: bool, + stage1_audio_latent: Any = None, + stage1_video_latent: Any = None, + stage2_audio_latent: Any = None, + stage2_video_latent: Any = None, + **kwargs, + ): + if bool(use_second_stage): + needed = [] + if stage2_audio_latent is None: + needed.append("stage2_audio_latent") + if stage2_video_latent is None: + needed.append("stage2_video_latent") + return needed + needed = [] + if stage1_audio_latent is None: + needed.append("stage1_audio_latent") + if stage1_video_latent is None: + needed.append("stage1_video_latent") + return needed + + def switch( + self, + use_second_stage: bool, + stage1_audio_latent: Any = None, + stage1_video_latent: Any = None, + stage2_audio_latent: Any = None, + stage2_video_latent: Any = None, + ): + if bool(use_second_stage): + if stage2_audio_latent is None or stage2_video_latent is None: + raise ValueError("Stage 2 is enabled, but Stage 2 latents are not connected.") + report = { + "node": "IAMCCS_CineStage2BypassSwitch", + "use_second_stage": True, + "selected": "stage2", + } + return stage2_audio_latent, stage2_video_latent, json.dumps(report, indent=2) + if stage1_audio_latent is None or stage1_video_latent is None: + raise ValueError("Stage 2 bypass is enabled, but Stage 1 latents are not connected.") + report = { + "node": "IAMCCS_CineStage2BypassSwitch", + "use_second_stage": False, + "selected": "stage1_bypass", + } + return stage1_audio_latent, stage1_video_latent, json.dumps(report, indent=2) diff --git a/iamccs_detail_atelier.py b/iamccs_detail_atelier.py new file mode 100644 index 0000000..698f228 --- /dev/null +++ b/iamccs_detail_atelier.py @@ -0,0 +1,835 @@ +from __future__ import annotations + +import json +import math +import importlib.util +import sys +import types +from dataclasses import dataclass +from pathlib import Path + + +VRAM_PRESETS = ("8GB", "12GB", "16GB", "24GB") +QUALITY_PRESETS = ("preview", "balanced", "quality") +DETAIL_ATELIER_LINX_TYPE = "IAMCCS_DETAIL_ATELIER_LINX" +HARDWARE_MODES = ("auto", "manual") + + +@dataclass(frozen=True) +class DetailAtelierPreset: + target_long_edge: int + temporal_tile_size: int + temporal_overlap: int + temporal_overlap_cond_strength: float + guiding_strength: float + cond_image_strength: float + horizontal_tiles: int + vertical_tiles: int + spatial_overlap: int + vae_tile_size: int + vae_overlap: int + vae_temporal_size: int + vae_temporal_overlap: int + reserved_vram_gb: float + cleanup_before_decode: bool + run_final_upscale: bool + run_interpolation: bool + final_upscale_mode: str + notes: str + + +_PRESETS: dict[str, dict[str, DetailAtelierPreset]] = { + "8GB": { + "preview": DetailAtelierPreset(768, 32, 16, 0.45, 0.85, 1.0, 1, 1, 1, 256, 32, 96, 16, 1.5, True, False, False, "off", "Fastest safe pass for 8GB cards."), + "balanced": DetailAtelierPreset(960, 40, 16, 0.50, 0.90, 1.0, 1, 1, 1, 256, 32, 128, 16, 1.5, True, True, False, "rtx_x2_optional", "8GB default: detail modestly, upscale after."), + "quality": DetailAtelierPreset(1280, 40, 16, 0.55, 0.95, 1.0, 1, 1, 1, 256, 32, 128, 16, 2.0, True, True, False, "rtx_x2", "Slow but still conservative for 8GB."), + }, + "12GB": { + "preview": DetailAtelierPreset(960, 40, 16, 0.45, 0.90, 1.0, 1, 1, 1, 384, 48, 128, 16, 2.0, True, False, False, "off", "Preview for 12GB cards."), + "balanced": DetailAtelierPreset(1280, 48, 16, 0.50, 1.00, 1.0, 1, 1, 1, 384, 48, 128, 16, 2.0, True, True, False, "rtx_x2", "Recommended 12GB daily preset."), + "quality": DetailAtelierPreset(1536, 56, 24, 0.55, 1.00, 1.0, 1, 1, 1, 384, 48, 128, 16, 2.5, True, True, True, "rtx_x2", "Final-ish 12GB preset; interpolation is optional."), + }, + "16GB": { + "preview": DetailAtelierPreset(1280, 48, 16, 0.45, 0.95, 1.0, 1, 1, 1, 384, 48, 128, 16, 2.5, True, False, False, "off", "Quick check for 16GB cards."), + "balanced": DetailAtelierPreset(1536, 56, 24, 0.50, 1.00, 1.0, 1, 1, 1, 512, 64, 192, 24, 3.0, False, True, False, "rtx_x2", "Recommended 16GB default."), + "quality": DetailAtelierPreset(1920, 64, 24, 0.60, 1.00, 1.0, 1, 1, 1, 512, 64, 192, 24, 3.0, False, True, True, "rtx_x2", "High detail before final upscale."), + }, + "24GB": { + "preview": DetailAtelierPreset(1536, 56, 24, 0.45, 1.00, 1.0, 1, 1, 1, 512, 64, 192, 24, 3.0, False, False, False, "off", "Fast preview on high VRAM."), + "balanced": DetailAtelierPreset(1920, 64, 24, 0.55, 1.00, 1.0, 1, 1, 1, 512, 64, 256, 32, 3.5, False, True, False, "rtx_x2", "24GB default for clean final passes."), + "quality": DetailAtelierPreset(2160, 80, 32, 0.60, 1.00, 1.0, 2, 1, 2, 512, 64, 256, 32, 4.0, False, True, True, "rtx_x2", "Experimental high-end pass; spatial tiling may be slow."), + }, +} + + +def _round_to_multiple(value: float, multiple: int, mode: str = "nearest") -> int: + value = max(float(multiple), float(value)) + if mode == "down": + rounded = math.floor(value / multiple) * multiple + elif mode == "up": + rounded = math.ceil(value / multiple) * multiple + else: + rounded = round(value / multiple) * multiple + return max(multiple, int(rounded)) + + +def _scaled_dims(width: int, height: int, long_edge: int, multiple: int) -> tuple[int, int]: + width = int(width) + height = int(height) + long_edge = int(long_edge) + if width <= 0 or height <= 0: + return (0, 0) + scale = float(long_edge) / float(max(width, height)) + out_w = _round_to_multiple(width * scale, multiple) + out_h = _round_to_multiple(height * scale, multiple) + return (out_w, out_h) + + +def _preset_for(vram_preset: str, quality_mode: str) -> tuple[str, str, DetailAtelierPreset]: + vram = str(vram_preset if vram_preset in _PRESETS else "12GB") + quality = str(quality_mode if quality_mode in _PRESETS[vram] else "balanced") + return vram, quality, _PRESETS[vram][quality] + + +def _safe_hardware_info() -> dict: + info = { + "cuda_available": False, + "cuda_device_name": None, + "cuda_total_vram_gb": None, + "system_ram_gb": None, + "warnings": [], + } + + try: + import torch + + if torch.cuda.is_available(): + idx = torch.cuda.current_device() + prop = torch.cuda.get_device_properties(idx) + total = getattr(prop, "total_memory", None) + info["cuda_available"] = True + info["cuda_device_name"] = getattr(prop, "name", None) + if total: + info["cuda_total_vram_gb"] = float(total) / (1024.0**3) + except Exception as e: + info["warnings"].append(f"cuda_probe_failed={e!r}") + + try: + import psutil # type: ignore + + info["system_ram_gb"] = float(psutil.virtual_memory().total) / (1024.0**3) + except Exception as e: + info["warnings"].append(f"ram_probe_failed={e!r}") + + return info + + +def _vram_preset_from_gb(vram_gb: float | None, fallback: str) -> str: + if vram_gb is None: + return fallback if fallback in VRAM_PRESETS else "12GB" + if vram_gb <= 8.5: + return "8GB" + if vram_gb <= 12.5: + return "12GB" + if vram_gb <= 16.5: + return "16GB" + return "24GB" + + +def _latent_shape_info(latents, vae=None) -> dict: + out = { + "latent_frames": None, + "latent_height": None, + "latent_width": None, + "time_scale_factor": 8, + "estimated_frames": None, + } + try: + samples = latents.get("samples") if isinstance(latents, dict) else None + shape = getattr(samples, "shape", None) + if shape is not None and len(shape) >= 5: + out["latent_frames"] = int(shape[2]) + out["latent_height"] = int(shape[3]) + out["latent_width"] = int(shape[4]) + except Exception: + pass + + try: + formula = getattr(vae, "downscale_index_formula", None) + if formula and len(formula) >= 1: + out["time_scale_factor"] = max(1, int(formula[0])) + except Exception: + pass + + if out["latent_frames"] is not None: + out["estimated_frames"] = int(out["latent_frames"]) * int(out["time_scale_factor"]) + return out + + +def _estimate_temporal_chunks(latent_frames: int | None, temporal_tile_size: int, temporal_overlap: int, time_scale_factor: int) -> dict: + if not latent_frames or latent_frames <= 0: + return {"chunks": None, "latent_tile": None, "latent_overlap": None, "latent_step": None} + scale = max(1, int(time_scale_factor or 8)) + latent_tile = max(1, int(temporal_tile_size) // scale) + latent_overlap = max(0, int(temporal_overlap) // scale) + if latent_overlap >= latent_tile: + latent_overlap = max(0, latent_tile - 1) + latent_step = max(1, latent_tile - latent_overlap) + if int(latent_frames) <= latent_tile: + chunks = 1 + else: + chunks = int(math.ceil((int(latent_frames) - latent_tile) / float(latent_step))) + 1 + return { + "chunks": int(chunks), + "latent_tile": int(latent_tile), + "latent_overlap": int(latent_overlap), + "latent_step": int(latent_step), + } + + +def _auto_adjust_loop_values( + *, + selected_vram: str, + quality: str, + temporal_tile_size: int, + temporal_overlap: int, + horizontal_tiles: int, + vertical_tiles: int, + spatial_overlap: int, + prefer_low_ram: bool, + hardware: dict, + latent_info: dict, +) -> tuple[str, dict, list[str]]: + decisions: list[str] = [] + detected_vram = _vram_preset_from_gb(hardware.get("cuda_total_vram_gb"), selected_vram) + effective_vram = detected_vram + if detected_vram != selected_vram: + decisions.append(f"vram_preset:auto {selected_vram}->{detected_vram}") + + ram_gb = hardware.get("system_ram_gb") + low_ram = bool(prefer_low_ram) + if isinstance(ram_gb, (int, float)) and ram_gb <= 40.0 and effective_vram in {"8GB", "12GB"}: + low_ram = True + decisions.append("low_ram:auto_enabled") + + max_tile_by_vram = { + "8GB": 40, + "12GB": 48, + "16GB": 64, + "24GB": 80, + } + min_tile_by_vram = { + "8GB": 32, + "12GB": 40, + "16GB": 48, + "24GB": 56, + } + if low_ram: + max_tile_by_vram["8GB"] = 32 + max_tile_by_vram["12GB"] = 48 + + max_tile = max_tile_by_vram.get(effective_vram, 48) + min_tile = min_tile_by_vram.get(effective_vram, 40) + if int(temporal_tile_size) > max_tile: + decisions.append(f"temporal_tile_size:cap {temporal_tile_size}->{max_tile}") + temporal_tile_size = max_tile + if int(temporal_tile_size) < min_tile and quality != "preview": + decisions.append(f"temporal_tile_size:floor {temporal_tile_size}->{min_tile}") + temporal_tile_size = min_tile + + max_overlap = 16 if effective_vram in {"8GB", "12GB"} else 24 + if int(temporal_overlap) > max_overlap: + decisions.append(f"temporal_overlap:cap {temporal_overlap}->{max_overlap}") + temporal_overlap = max_overlap + if int(temporal_overlap) >= int(temporal_tile_size): + new_overlap = max(0, int(temporal_tile_size) // 2) + decisions.append(f"temporal_overlap:repair {temporal_overlap}->{new_overlap}") + temporal_overlap = new_overlap + + latent_area = None + if latent_info.get("latent_width") and latent_info.get("latent_height"): + latent_area = int(latent_info["latent_width"]) * int(latent_info["latent_height"]) + if low_ram or effective_vram in {"8GB", "12GB"}: + if horizontal_tiles != 1 or vertical_tiles != 1 or spatial_overlap != 1: + decisions.append("spatial_tiling:low_ram_force_1x1") + horizontal_tiles = 1 + vertical_tiles = 1 + spatial_overlap = 1 + elif latent_area is not None and latent_area > 4096 and effective_vram == "16GB": + horizontal_tiles = max(1, int(horizontal_tiles)) + vertical_tiles = max(1, int(vertical_tiles)) + spatial_overlap = max(1, int(spatial_overlap)) + + chunk_info = _estimate_temporal_chunks( + latent_info.get("latent_frames"), + int(temporal_tile_size), + int(temporal_overlap), + int(latent_info.get("time_scale_factor") or 8), + ) + if chunk_info.get("chunks") and chunk_info["chunks"] > 12 and effective_vram in {"8GB", "12GB"}: + decisions.append(f"long_clip:estimated_chunks={chunk_info['chunks']}") + + values = { + "temporal_tile_size": int(temporal_tile_size), + "temporal_overlap": int(temporal_overlap), + "horizontal_tiles": int(horizontal_tiles), + "vertical_tiles": int(vertical_tiles), + "spatial_overlap": int(spatial_overlap), + "low_ram_effective": bool(low_ram), + "chunk_info": chunk_info, + } + return effective_vram, values, decisions + + +def _load_ltxv_looping_sampler_class(): + module_name = "_iamccs_ltxvideo_runtime.looping_sampler" + cached = sys.modules.get(module_name) + if cached is not None and hasattr(cached, "LTXVLoopingSampler"): + return cached.LTXVLoopingSampler + + custom_nodes_dir = Path(__file__).resolve().parent.parent + ltx_dir = custom_nodes_dir / "ComfyUI-LTXVideo" + looping_path = ltx_dir / "looping_sampler.py" + if not looping_path.exists(): + raise ImportError(f"ComfyUI-LTXVideo looping_sampler.py not found at {looping_path}") + + package_name = "_iamccs_ltxvideo_runtime" + if package_name not in sys.modules: + package = types.ModuleType(package_name) + package.__path__ = [str(ltx_dir)] + package.__file__ = str(ltx_dir / "__init__.py") + package.__package__ = package_name + sys.modules[package_name] = package + + spec = importlib.util.spec_from_file_location(module_name, looping_path) + if spec is None or spec.loader is None: + raise ImportError(f"Unable to load ComfyUI-LTXVideo looping sampler from {looping_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module.LTXVLoopingSampler + + +class IAMCCS_DetailAtelier: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "vram_preset": (VRAM_PRESETS, {"default": "12GB"}), + "quality_mode": (QUALITY_PRESETS, {"default": "balanced"}), + "source_width": ("INT", {"default": 0, "min": 0, "max": 16384, "step": 8}), + "source_height": ("INT", {"default": 0, "min": 0, "max": 16384, "step": 8}), + "target_override_long_edge": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 8}), + "dimension_multiple": ("INT", {"default": 16, "min": 8, "max": 128, "step": 8}), + "prefer_low_ram": ("BOOLEAN", {"default": True}), + "allow_interpolation": ("BOOLEAN", {"default": True}), + "allow_final_upscale": ("BOOLEAN", {"default": True}), + "pretty_json": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = ( + "INT", + "INT", + "INT", + "INT", + "INT", + "FLOAT", + "FLOAT", + "FLOAT", + "INT", + "INT", + "INT", + "INT", + "INT", + "INT", + "INT", + "FLOAT", + "BOOLEAN", + "BOOLEAN", + "BOOLEAN", + "STRING", + "STRING", + "STRING", + ) + RETURN_NAMES = ( + "target_long_edge", + "target_width", + "target_height", + "temporal_tile_size", + "temporal_overlap", + "temporal_overlap_cond_strength", + "guiding_strength", + "cond_image_strength", + "horizontal_tiles", + "vertical_tiles", + "spatial_overlap", + "vae_tile_size", + "vae_overlap", + "vae_temporal_size", + "vae_temporal_overlap", + "reserved_vram_gb", + "cleanup_before_decode", + "run_final_upscale", + "run_interpolation", + "final_upscale_mode", + "report_json", + "report", + ) + FUNCTION = "plan" + CATEGORY = "IAMCCS/LTX-2/Detail Atelier" + + def plan( + self, + vram_preset: str, + quality_mode: str, + source_width: int, + source_height: int, + target_override_long_edge: int, + dimension_multiple: int, + prefer_low_ram: bool, + allow_interpolation: bool, + allow_final_upscale: bool, + pretty_json: bool, + ): + vram = str(vram_preset if vram_preset in _PRESETS else "12GB") + quality = str(quality_mode if quality_mode in _PRESETS[vram] else "balanced") + preset = _PRESETS[vram][quality] + + target_long_edge = int(target_override_long_edge) if int(target_override_long_edge) > 0 else preset.target_long_edge + target_width, target_height = _scaled_dims( + int(source_width), + int(source_height), + target_long_edge, + max(8, int(dimension_multiple)), + ) + + vae_tile_size = preset.vae_tile_size + vae_overlap = preset.vae_overlap + vae_temporal_size = preset.vae_temporal_size + vae_temporal_overlap = preset.vae_temporal_overlap + cleanup_before_decode = bool(preset.cleanup_before_decode) + + if bool(prefer_low_ram): + if vram == "8GB": + vae_tile_size = min(vae_tile_size, 256) + vae_overlap = min(vae_overlap, 32) + cleanup_before_decode = True + elif vram == "12GB": + vae_tile_size = min(vae_tile_size, 384) + vae_overlap = min(vae_overlap, 48) + cleanup_before_decode = True + + run_final_upscale = bool(allow_final_upscale and preset.run_final_upscale) + run_interpolation = bool(allow_interpolation and preset.run_interpolation) + final_upscale_mode = preset.final_upscale_mode if run_final_upscale else "off" + + data = { + "node": "IAMCCS_DetailAtelier", + "vram_preset": vram, + "quality_mode": quality, + "source": { + "width": int(source_width), + "height": int(source_height), + }, + "target": { + "long_edge": int(target_long_edge), + "width": int(target_width), + "height": int(target_height), + "dimension_multiple": int(dimension_multiple), + }, + "looper": { + "temporal_tile_size": preset.temporal_tile_size, + "temporal_overlap": preset.temporal_overlap, + "temporal_overlap_cond_strength": preset.temporal_overlap_cond_strength, + "guiding_strength": preset.guiding_strength, + "cond_image_strength": preset.cond_image_strength, + "horizontal_tiles": preset.horizontal_tiles, + "vertical_tiles": preset.vertical_tiles, + "spatial_overlap": preset.spatial_overlap, + }, + "decode": { + "vae_tile_size": int(vae_tile_size), + "vae_overlap": int(vae_overlap), + "vae_temporal_size": int(vae_temporal_size), + "vae_temporal_overlap": int(vae_temporal_overlap), + "cleanup_before_decode": bool(cleanup_before_decode), + }, + "post": { + "reserved_vram_gb": preset.reserved_vram_gb, + "run_final_upscale": run_final_upscale, + "run_interpolation": run_interpolation, + "final_upscale_mode": final_upscale_mode, + }, + "strategy": "detail first, upscale after", + "notes": preset.notes, + } + report_json = json.dumps(data, ensure_ascii=False, indent=2 if bool(pretty_json) else None) + report = ( + f"{vram} {quality}: target_edge={target_long_edge}" + f"{f' -> {target_width}x{target_height}' if target_width and target_height else ''}, " + f"looper={preset.temporal_tile_size}/{preset.temporal_overlap}, " + f"decode={vae_tile_size}/{vae_overlap}/{vae_temporal_size}/{vae_temporal_overlap}, " + f"cleanup={cleanup_before_decode}, upscale={final_upscale_mode}, rife={run_interpolation}. " + "Strategy: detail first, upscale after." + ) + + return ( + int(target_long_edge), + int(target_width), + int(target_height), + int(preset.temporal_tile_size), + int(preset.temporal_overlap), + float(preset.temporal_overlap_cond_strength), + float(preset.guiding_strength), + float(preset.cond_image_strength), + int(preset.horizontal_tiles), + int(preset.vertical_tiles), + int(preset.spatial_overlap), + int(vae_tile_size), + int(vae_overlap), + int(vae_temporal_size), + int(vae_temporal_overlap), + float(preset.reserved_vram_gb), + bool(cleanup_before_decode), + bool(run_final_upscale), + bool(run_interpolation), + final_upscale_mode, + report_json, + report, + ) + + +class IAMCCS_DetailAtelierSampler: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model": ("MODEL", {"tooltip": "Diffusion model used for the detail pass."}), + "vae": ("VAE", {"tooltip": "Video VAE used by LTXVideo."}), + "noise": ("NOISE", {"tooltip": "Noise object; the seed is reused internally."}), + "sampler": ("SAMPLER", {"tooltip": "Sampler selected for the detail pass."}), + "sigmas": ("SIGMAS", {"tooltip": "Sigma schedule for the detail pass."}), + "guider": ("GUIDER", {"tooltip": "CFG/STG guider for the detail pass."}), + "latents": ("LATENT", {"tooltip": "Input video latents to detail."}), + "vram_preset": (VRAM_PRESETS, {"default": "12GB"}), + "quality_mode": (QUALITY_PRESETS, {"default": "balanced"}), + "prefer_low_ram": ("BOOLEAN", {"default": True}), + "allow_spatial_tiling": ("BOOLEAN", {"default": True}), + "adain_factor": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "guiding_start_step": ("INT", {"default": 0, "min": 0, "max": 1000}), + "guiding_end_step": ("INT", {"default": 1000, "min": 0, "max": 1000}), + "optional_cond_image_indices": ("STRING", {"default": "0"}), + "per_tile_seed_offsets": ("STRING", {"default": "0"}), + "pretty_json": ("BOOLEAN", {"default": True}), + "hardware_mode": (HARDWARE_MODES, {"default": "auto"}), + "oom_retry": ("BOOLEAN", {"default": True}), + }, + "optional": { + "atelier_advanced": (DETAIL_ATELIER_LINX_TYPE, {}), + "optional_cond_images": ("IMAGE", {}), + "optional_guiding_latents": ("LATENT", {}), + "optional_positive_conditionings": ("CONDITIONING", {}), + "optional_negative_index_latents": ("LATENT", {}), + "optional_normalizing_latents": ("LATENT", {}), + }, + } + + RETURN_TYPES = ("LATENT", "STRING", "STRING") + RETURN_NAMES = ("denoised_output", "report_json", "report") + FUNCTION = "detail" + CATEGORY = "IAMCCS/LTX-2/Detail Atelier" + + def detail( + self, + model, + vae, + noise, + sampler, + sigmas, + guider, + latents, + vram_preset: str, + quality_mode: str, + prefer_low_ram: bool, + allow_spatial_tiling: bool, + adain_factor: float, + guiding_start_step: int, + guiding_end_step: int, + optional_cond_image_indices: str, + per_tile_seed_offsets: str, + pretty_json: bool, + hardware_mode: str = "auto", + oom_retry: bool = True, + atelier_advanced=None, + optional_cond_images=None, + optional_guiding_latents=None, + optional_positive_conditionings=None, + optional_negative_index_latents=None, + optional_normalizing_latents=None, + ): + requested_vram, quality, requested_preset = _preset_for(vram_preset, quality_mode) + hardware = _safe_hardware_info() + if str(hardware_mode or "auto") == "auto": + vram = _vram_preset_from_gb(hardware.get("cuda_total_vram_gb"), requested_vram) + _, _, preset = _preset_for(vram, quality) + else: + vram, _, preset = requested_vram, quality, requested_preset + + advanced = atelier_advanced if isinstance(atelier_advanced, dict) and atelier_advanced.get("enabled", True) else {} + latent_info = _latent_shape_info(latents, vae) + + def adv_int(name: str, default: int) -> int: + try: + value = int(advanced.get(name, 0)) + except Exception: + value = 0 + return int(value if value > 0 else default) + + def adv_float(name: str, default: float) -> float: + try: + value = float(advanced.get(name, -1.0)) + except Exception: + value = -1.0 + return float(value if value >= 0.0 else default) + + temporal_tile_size = adv_int("temporal_tile_size", preset.temporal_tile_size) + temporal_overlap = adv_int("temporal_overlap", preset.temporal_overlap) + guiding_strength = adv_float("guiding_strength", preset.guiding_strength) + temporal_overlap_cond_strength = adv_float( + "temporal_overlap_cond_strength", + preset.temporal_overlap_cond_strength, + ) + cond_image_strength = adv_float("cond_image_strength", preset.cond_image_strength) + + horizontal_tiles = adv_int("horizontal_tiles", preset.horizontal_tiles) + vertical_tiles = adv_int("vertical_tiles", preset.vertical_tiles) + spatial_overlap = adv_int("spatial_overlap", preset.spatial_overlap) + if not bool(allow_spatial_tiling): + horizontal_tiles = 1 + vertical_tiles = 1 + spatial_overlap = 1 + spatial_mode = str(advanced.get("spatial_tiling_mode", "inherit") or "inherit") + if spatial_mode == "force_off": + horizontal_tiles = 1 + vertical_tiles = 1 + spatial_overlap = 1 + if bool(prefer_low_ram) and vram in {"8GB", "12GB"}: + horizontal_tiles = 1 + vertical_tiles = 1 + spatial_overlap = min(spatial_overlap, 1) + + auto_decisions: list[str] = [] + auto_values = { + "chunk_info": _estimate_temporal_chunks( + latent_info.get("latent_frames"), + int(temporal_tile_size), + int(temporal_overlap), + int(latent_info.get("time_scale_factor") or 8), + ) + } + if str(hardware_mode or "auto") == "auto": + vram, auto_values, auto_decisions = _auto_adjust_loop_values( + selected_vram=requested_vram, + quality=quality, + temporal_tile_size=int(temporal_tile_size), + temporal_overlap=int(temporal_overlap), + horizontal_tiles=int(horizontal_tiles), + vertical_tiles=int(vertical_tiles), + spatial_overlap=int(spatial_overlap), + prefer_low_ram=bool(prefer_low_ram), + hardware=hardware, + latent_info=latent_info, + ) + temporal_tile_size = int(auto_values["temporal_tile_size"]) + temporal_overlap = int(auto_values["temporal_overlap"]) + horizontal_tiles = int(auto_values["horizontal_tiles"]) + vertical_tiles = int(auto_values["vertical_tiles"]) + spatial_overlap = int(auto_values["spatial_overlap"]) + + adain = adv_float("adain_factor", float(adain_factor)) + guide_start = adv_int("guiding_start_step", int(guiding_start_step)) + guide_end = adv_int("guiding_end_step", int(guiding_end_step)) + cond_indices = str(advanced.get("optional_cond_image_indices") or optional_cond_image_indices or "0") + seed_offsets = str(advanced.get("per_tile_seed_offsets") or per_tile_seed_offsets or "0") + + looping_sampler_cls = _load_ltxv_looping_sampler_class() + looping_sampler = looping_sampler_cls() + retry_applied = False + + def run_loop(ts: int, ov: int, ht: int, vt: int, so: int): + return looping_sampler.sample( + model=model, + vae=vae, + noise=noise, + sampler=sampler, + sigmas=sigmas, + guider=guider, + latents=latents, + guiding_strength=float(guiding_strength), + adain_factor=float(adain), + temporal_tile_size=int(ts), + temporal_overlap=int(ov), + temporal_overlap_cond_strength=float(temporal_overlap_cond_strength), + horizontal_tiles=int(ht), + vertical_tiles=int(vt), + spatial_overlap=int(so), + optional_cond_images=optional_cond_images, + cond_image_strength=float(cond_image_strength), + optional_guiding_latents=optional_guiding_latents, + optional_negative_index_latents=optional_negative_index_latents, + optional_negative_index_strength=1.0, + optional_positive_conditionings=optional_positive_conditionings, + guiding_start_step=int(guide_start), + guiding_end_step=int(guide_end), + optional_cond_image_indices=str(cond_indices), + optional_normalizing_latents=optional_normalizing_latents, + per_tile_seed_offsets=str(seed_offsets), + ) + + try: + result = run_loop(temporal_tile_size, temporal_overlap, horizontal_tiles, vertical_tiles, spatial_overlap) + except RuntimeError as e: + is_oom = "out of memory" in str(e).lower() or "cuda oom" in str(e).lower() + if not (bool(oom_retry) and str(hardware_mode or "auto") == "auto" and is_oom): + raise + try: + import torch + + if torch.cuda.is_available(): + torch.cuda.empty_cache() + try: + torch.cuda.ipc_collect() + except Exception: + pass + except Exception: + pass + retry_applied = True + old_ts, old_ov = int(temporal_tile_size), int(temporal_overlap) + temporal_tile_size = max(32, int(temporal_tile_size) - 16) + temporal_overlap = min(16, max(8, int(temporal_tile_size) // 3)) + horizontal_tiles = 1 + vertical_tiles = 1 + spatial_overlap = 1 + auto_decisions.append(f"oom_retry:{old_ts}/{old_ov}-> {temporal_tile_size}/{temporal_overlap}, tiles=1x1") + auto_values["chunk_info"] = _estimate_temporal_chunks( + latent_info.get("latent_frames"), + int(temporal_tile_size), + int(temporal_overlap), + int(latent_info.get("time_scale_factor") or 8), + ) + result = run_loop(temporal_tile_size, temporal_overlap, horizontal_tiles, vertical_tiles, spatial_overlap) + + denoised = result[0] if isinstance(result, tuple) else result + + data = { + "node": "IAMCCS_DetailAtelierSampler", + "display": "IAMCCS Detail Atelier", + "hardware_mode": str(hardware_mode or "auto"), + "requested_vram_preset": requested_vram, + "effective_vram_preset": vram, + "quality_mode": quality, + "strategy": "orchestrated internal LTXV loop; detail only, no decode/upscale unification", + "advanced_linx_enabled": bool(advanced), + "hardware": hardware, + "latent": latent_info, + "estimated_chunks": auto_values.get("chunk_info"), + "auto_decisions": auto_decisions, + "oom_retry_applied": bool(retry_applied), + "looper": { + "temporal_tile_size": int(temporal_tile_size), + "temporal_overlap": int(temporal_overlap), + "guiding_strength": float(guiding_strength), + "temporal_overlap_cond_strength": float(temporal_overlap_cond_strength), + "cond_image_strength": float(cond_image_strength), + "horizontal_tiles": int(horizontal_tiles), + "vertical_tiles": int(vertical_tiles), + "spatial_overlap": int(spatial_overlap), + "adain_factor": float(adain), + "guiding_start_step": int(guide_start), + "guiding_end_step": int(guide_end), + "optional_cond_image_indices": str(cond_indices), + "per_tile_seed_offsets": str(seed_offsets), + }, + "notes": preset.notes, + } + report_json = json.dumps(data, ensure_ascii=False, indent=2 if bool(pretty_json) else None) + chunks = auto_values.get("chunk_info", {}).get("chunks") if isinstance(auto_values.get("chunk_info"), dict) else None + report = ( + f"IAMCCS Detail Atelier {vram}/{quality} ({hardware_mode}): internal looper " + f"{temporal_tile_size}/{temporal_overlap}, " + f"tiles={horizontal_tiles}x{vertical_tiles}, overlap={spatial_overlap}" + f"{f', chunksโ‰ˆ{chunks}' if chunks else ''}." + ) + return (denoised, report_json, report) + + +class IAMCCS_DetailAtelierAdvanced: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "enabled": ("BOOLEAN", {"default": True}), + "temporal_tile_size": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 8}), + "temporal_overlap": ("INT", {"default": 0, "min": 0, "max": 80, "step": 8}), + "guiding_strength": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "temporal_overlap_cond_strength": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "cond_image_strength": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "horizontal_tiles": ("INT", {"default": 0, "min": 0, "max": 6}), + "vertical_tiles": ("INT", {"default": 0, "min": 0, "max": 6}), + "spatial_overlap": ("INT", {"default": 0, "min": 0, "max": 8}), + "spatial_tiling_mode": (["inherit", "force_off"], {"default": "inherit"}), + "adain_factor": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "guiding_start_step": ("INT", {"default": 0, "min": 0, "max": 1000}), + "guiding_end_step": ("INT", {"default": 0, "min": 0, "max": 1000}), + "optional_cond_image_indices": ("STRING", {"default": ""}), + "per_tile_seed_offsets": ("STRING", {"default": ""}), + "pretty_json": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = (DETAIL_ATELIER_LINX_TYPE, "STRING", "STRING") + RETURN_NAMES = ("atelier_advanced", "report_json", "report") + FUNCTION = "build" + CATEGORY = "IAMCCS/LTX-2/Detail Atelier" + + def build( + self, + enabled: bool, + temporal_tile_size: int, + temporal_overlap: int, + guiding_strength: float, + temporal_overlap_cond_strength: float, + cond_image_strength: float, + horizontal_tiles: int, + vertical_tiles: int, + spatial_overlap: int, + spatial_tiling_mode: str, + adain_factor: float, + guiding_start_step: int, + guiding_end_step: int, + optional_cond_image_indices: str, + per_tile_seed_offsets: str, + pretty_json: bool, + ): + cfg = { + "enabled": bool(enabled), + "temporal_tile_size": int(temporal_tile_size), + "temporal_overlap": int(temporal_overlap), + "guiding_strength": float(guiding_strength), + "temporal_overlap_cond_strength": float(temporal_overlap_cond_strength), + "cond_image_strength": float(cond_image_strength), + "horizontal_tiles": int(horizontal_tiles), + "vertical_tiles": int(vertical_tiles), + "spatial_overlap": int(spatial_overlap), + "spatial_tiling_mode": str(spatial_tiling_mode or "inherit"), + "adain_factor": float(adain_factor), + "guiding_start_step": int(guiding_start_step), + "guiding_end_step": int(guiding_end_step), + "optional_cond_image_indices": str(optional_cond_image_indices or ""), + "per_tile_seed_offsets": str(per_tile_seed_offsets or ""), + } + report_json = json.dumps(cfg, ensure_ascii=False, indent=2 if bool(pretty_json) else None) + active = [k for k, v in cfg.items() if k != "enabled" and v not in (0, -1.0, "", "inherit")] + report = "Detail Atelier Advanced: " + ("disabled" if not enabled else ("overrides " + ", ".join(active) if active else "enabled, no overrides")) + return (cfg, report_json, report) diff --git a/iamccs_wdc_ltx_port.py b/iamccs_wdc_ltx_port.py index 031d239..c1cee40 100644 --- a/iamccs_wdc_ltx_port.py +++ b/iamccs_wdc_ltx_port.py @@ -9,10 +9,6 @@ import torch from comfy_api.latest import io as comfy_io from comfy_extras.nodes_lt import LTXVAddGuide -try: - from comfy_extras.nodes_lt import _append_guide_attention_entry -except Exception: - _append_guide_attention_entry = None from PIL import Image, ImageOps @@ -324,16 +320,6 @@ class IAMCCS_WDC_LTXSequencer(LTXVAddGuide): float(strength), scale_factors, ) - if _append_guide_attention_entry is not None: - pre_filter_count = t.shape[2] * t.shape[3] * t.shape[4] - guide_latent_shape = list(t.shape[2:]) - positive, negative = _append_guide_attention_entry( - positive, - negative, - pre_filter_count, - guide_latent_shape, - strength=float(strength), - ) return comfy_io.NodeOutput(positive, negative, {"samples": latent_image, "noise_mask": noise_mask}) @@ -469,21 +455,19 @@ class IAMCCS_CineLTXSequencerExact(LTXVAddGuide): float(strength), scale_factors, ) - if _append_guide_attention_entry is not None: - pre_filter_count = t.shape[2] * t.shape[3] * t.shape[4] - guide_latent_shape = list(t.shape[2:]) - positive, negative = _append_guide_attention_entry( - positive, - negative, - pre_filter_count, - guide_latent_shape, - strength=float(strength), - ) return comfy_io.NodeOutput(positive, negative, {"samples": latent_image, "noise_mask": noise_mask}) class IAMCCS_CineFLFEngineSimple(LTXVAddGuide): + @classmethod + def fingerprint_inputs(cls, *args, **kwargs): + return float("nan") + + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + return float("nan") + @classmethod def define_schema(cls): inputs = [ @@ -735,16 +719,6 @@ class IAMCCS_CineFLFEngineSimple(LTXVAddGuide): strength, scale_factors, ) - if _append_guide_attention_entry is not None: - pre_filter_count = t.shape[2] * t.shape[3] * t.shape[4] - guide_latent_shape = list(t.shape[2:]) - positive, negative = _append_guide_attention_entry( - positive, - negative, - pre_filter_count, - guide_latent_shape, - strength=strength, - ) return comfy_io.NodeOutput(positive, negative, {"samples": latent_image, "noise_mask": noise_mask}) @@ -846,16 +820,6 @@ class IAMCCS_WDC_LTXSequencerFixed5: float(strength), scale_factors, ) - if _append_guide_attention_entry is not None: - pre_filter_count = t.shape[2] * t.shape[3] * t.shape[4] - guide_latent_shape = list(t.shape[2:]) - positive, negative = _append_guide_attention_entry( - positive, - negative, - pre_filter_count, - guide_latent_shape, - strength=float(strength), - ) applied += 1 ops.append(f"image_{idx}:f{frame_idx}->t{latent_idx},s={float(strength):.2f}") diff --git a/version.json b/version.json index 45fb4b5..68920ff 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "name": "iamccs-nodes", - "version": "1.4.5", + "version": "1.4.6", "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_timeline_ui.js b/web/iamccs_cine_timeline_ui.js index a928db7..e9fe61d 100644 --- a/web/iamccs_cine_timeline_ui.js +++ b/web/iamccs_cine_timeline_ui.js @@ -1,15 +1,23 @@ -๏ปฟimport { app } from "../../scripts/app.js"; +import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; -const CINE_VERSION = "2026-05-10-shotboard-lite-v1"; +const CINE_VERSION = "2026-05-19-shotboard-package-self-contained-import-1"; +const SHOTBOARD_V3_RIGID_WIDTH = 1920; +const SHOTBOARD_V3_OPEN_HEIGHT = 760; +const SHOTBOARD_V3_COLLAPSED_HEIGHT = 560; const SHOTBOARD_NODE_MIN_SIZE = [1500, 760]; const SHOTBOARD_NODE_DEFAULT_SIZE = [1500, 780]; -const SHOTBOARD_ROW_GRID = "24px 92px 182px 188px 44px 44px minmax(350px,410px) minmax(360px,1fr) 28px"; +const SHOTBOARD_ROW_GRID = "24px 108px 164px 188px 42px 42px minmax(430px,500px) minmax(280px,1fr) 28px"; +const SHOTBOARD_ROW_GRID_V2 = "24px 108px 164px 188px 42px 42px minmax(520px,640px) minmax(260px,1fr) 28px"; const SHOTBOARD_LITE_NODE_MIN_SIZE = [1120, 610]; const SHOTBOARD_LITE_NODE_DEFAULT_SIZE = [1180, 660]; -const SHOTBOARD_LITE_ROW_GRID = "24px 92px minmax(152px,180px) minmax(138px,170px) 44px minmax(130px,170px) minmax(300px,1fr) 28px"; -const FLF_SEQUENCER_NODE_MIN_SIZE = [760, 730]; +const SHOTBOARD_LITE_ROW_GRID = "24px 108px minmax(152px,180px) minmax(138px,170px) 44px minmax(130px,170px) minmax(280px,1fr) 28px"; +const FLF_SEQUENCER_NODE_MIN_SIZE = [760, 980]; const FLF_SEQUENCER_TOP_CLEARANCE = 32; +const FLF_SEQUENCER_ROW_HEIGHT = 68; +const FLF_SEQUENCER_BASE_HEIGHT = 168; +const CINE_FULLSCREEN_Z_INDEX = 2147483000; +const CINE_REF_EDITOR_Z_INDEX = 2147483647; const CINE_FILM_LAB = { header: "#123E3A", nodeBg: "#07191D", @@ -43,6 +51,13 @@ const CINE_NODE_CHROME = { border: "#2D7A92", glow: "rgba(111,182,210,.24)", }, + shotboardV3: { + header: "#3A342D", + nodeBg: "#1E2022", + box: "#D89B45", + border: "#6B6258", + glow: "rgba(216,155,69,.20)", + }, shotboardLite: { header: "#4A3720", nodeBg: "#16110B", @@ -205,6 +220,7 @@ function lockNodeMinimumSize(node, minSize, options = {}) { const minWidth = Number(minSize[0]) || 0; const minHeight = Number(minSize[1]) || 0; node._iamccsCineMinSize = [minWidth, minHeight]; + node._iamccsCineMinOptions = { ...options }; if (options.lockResize) { node.resizable = false; node.resizeable = false; @@ -216,9 +232,12 @@ function lockNodeMinimumSize(node, minSize, options = {}) { const preferred = Array.isArray(options.preferredSize) ? options.preferredSize : null; const preferredWidth = Number(preferred?.[0] || 0); const preferredHeight = Number(preferred?.[1] || 0); + const nextWidth = options.lockWidth + ? Math.max(minWidth, preferredWidth || minWidth) + : Math.max(minWidth, preferredWidth || currentWidth); const next = preferred - ? [Math.max(minWidth, preferredWidth), Math.max(minHeight, preferredHeight)] - : [Math.max(minWidth, currentWidth), Math.max(minHeight, currentHeight)]; + ? [nextWidth, Math.max(minHeight, preferredHeight)] + : [nextWidth, Math.max(minHeight, currentHeight)]; if (currentWidth !== next[0] || currentHeight !== next[1]) { if (typeof node.setSize === "function") node.setSize(next); else node.size = next; @@ -230,12 +249,13 @@ function lockNodeMinimumSize(node, minSize, options = {}) { const originalOnResize = node.onResize; node.onResize = function (size) { const min = this._iamccsCineMinSize || minSize; + const activeOptions = this._iamccsCineMinOptions || options || {}; if (Array.isArray(size)) { - size[0] = Math.max(Number(min?.[0] || 0), Number(size[0] || 0)); + size[0] = activeOptions.lockWidth ? Number(min?.[0] || 0) : Math.max(Number(min?.[0] || 0), Number(size[0] || 0)); size[1] = Math.max(Number(min?.[1] || 0), Number(size[1] || 0)); } const result = originalOnResize ? originalOnResize.apply(this, arguments) : undefined; - const width = Math.max(Number(min?.[0] || 0), Number(this.size?.[0] || 0)); + const width = activeOptions.lockWidth ? Number(min?.[0] || 0) : Math.max(Number(min?.[0] || 0), Number(this.size?.[0] || 0)); const height = Math.max(Number(min?.[1] || 0), Number(this.size?.[1] || 0)); if (this.size?.[0] !== width || this.size?.[1] !== height) { if (typeof this.setSize === "function") this.setSize([width, height]); @@ -281,9 +301,65 @@ function getLinkedOriginNode(node, inputName) { return getOriginNodeFromLink(input?.link); } +function getLinkedInputSourceInfo(node, inputName) { + const input = node?.inputs?.find((item) => item?.name === inputName); + const link = input?.link && app?.graph?.links ? app.graph.links[input.link] : null; + if (!link) return null; + const originId = link.origin_id ?? link.source_id; + const origin = originId != null ? app.graph?.getNodeById(originId) : null; + const originSlot = Number(link.origin_slot ?? link.source_slot ?? -1); + const output = origin?.outputs?.[originSlot] || null; + return { + node: origin, + nodeClass: nodeClassName(origin), + outputName: String(output?.name || output?.label || ""), + outputType: String(output?.type || ""), + originSlot, + }; +} + +function findOutputSlotByName(node, names) { + const wanted = new Set((Array.isArray(names) ? names : [names]).map((item) => String(item || "").toLowerCase())); + return (node?.outputs || []).findIndex((output) => { + const label = String(output?.name || output?.label || "").toLowerCase(); + return wanted.has(label); + }); +} + +function findInputSlotByName(node, names) { + const wanted = new Set((Array.isArray(names) ? names : [names]).map((item) => String(item || "").toLowerCase())); + return (node?.inputs || []).findIndex((input) => wanted.has(String(input?.name || input?.label || "").toLowerCase())); +} + +function reconnectInputToOutput(sourceNode, outputSlot, targetNode, inputSlot) { + if (!sourceNode || !targetNode || outputSlot < 0 || inputSlot < 0) return false; + try { + targetNode.disconnectInput?.(inputSlot); + } catch {} + try { + sourceNode.connect(outputSlot, targetNode, inputSlot); + try { targetNode.setDirtyCanvas?.(true, true); app.graph?.setDirtyCanvas?.(true, true); } catch {} + return true; + } catch (err) { + console.warn("[IAMCCS Cine UI] could not reconnect image batch output", err); + return false; + } +} + +function healCineInfoImageBatchLink(node, state) { + const source = state?.multiInputSource; + if (!source?.node || source.nodeClass !== "IAMCCS_CineInfo") return false; + if (Number(state?.refs || 0) <= 1) return false; + const outputName = String(source.outputName || "").replace(/\s+/g, "_").toLowerCase(); + if (outputName !== "image_1" && outputName !== "image1") return false; + const multiOutputSlot = findOutputSlotByName(source.node, ["multi_output"]); + const multiInputSlot = findInputSlotByName(node, ["multi_input"]); + return reconnectInputToOutput(source.node, multiOutputSlot, node, multiInputSlot); +} + function traceLinkedCinePlanner(node) { const startInputs = ["timeline_data", "multi_input", "duration_seconds"]; - const accepted = new Set(["IAMCCS_CineShotboardLite", "IAMCCS_CineShotboardPlannerPro", "IAMCCS_CineShotboardPlannerProV2", "IAMCCS_CineShotboardPlannerProLegacy", "IAMCCS_CineShotboardTimelinePro", "IAMCCS_CineReferenceBoard"]); + const accepted = new Set(["IAMCCS_CineShotboardLite", "IAMCCS_CineShotboardPlannerPro", "IAMCCS_CineShotboardPlannerProV2", "IAMCCS_CineShotboardPlannerV3", "IAMCCS_CineShotboardPlannerProLegacy", "IAMCCS_CineShotboardTimelinePro", "IAMCCS_CineReferenceBoard"]); for (const inputName of startInputs) { let current = getLinkedOriginNode(node, inputName); const visited = new Set(); @@ -331,12 +407,14 @@ function readPlannerLinkedFLFState(node) { const duration = Number(getWidget(planner, "duration_seconds")?.value || 0); const fps = Number(getWidget(planner, "frame_rate")?.value || 0); const refs = splitReferencePaths(getWidget(planner, "image_paths")?.value).length; + const multiInputSource = getLinkedInputSourceInfo(node, "multi_input"); return { planner, rows: rowsForFLF, duration: Number.isFinite(duration) && duration > 0 ? duration : null, fps: Number.isFinite(fps) && fps > 0 ? fps : null, refs, + multiInputSource, }; } @@ -358,6 +436,19 @@ function inputBase() { `; } +function styleValueControls(element) { + if (element?.matches?.("input,select")) { + element.style.background = CINE_FILM_LAB.valueBg; + element.style.color = CINE_FILM_LAB.valueText; + element.style.borderColor = CINE_FILM_LAB.border; + } + element?.querySelectorAll?.("input,select").forEach((child) => { + child.style.background = CINE_FILM_LAB.valueBg; + child.style.color = CINE_FILM_LAB.valueText; + child.style.borderColor = CINE_FILM_LAB.border; + }); +} + function button(label, tone = "neutral", palette = null) { const btn = document.createElement("button"); btn.textContent = label; @@ -391,6 +482,8 @@ function protectControlDrag(element) { element.setAttribute?.("draggable", "false"); for (const eventName of ["pointerdown", "pointermove", "mousedown", "mousemove", "touchstart", "touchmove", "wheel", "drag", "dragover", "drop"]) { element.addEventListener(eventName, (event) => { + if ((eventName === "dragover" || eventName === "drop") && hasJsonBoardDrag(event)) return; + if ((eventName === "pointerdown" || eventName === "pointermove" || eventName === "mousedown" || eventName === "mousemove") && isNumericStepDragTarget(event)) return; event.stopPropagation(); }, { passive: false, capture: true }); } @@ -401,6 +494,22 @@ function protectControlDrag(element) { return element; } +function isNumericStepDragTarget(event) { + return Boolean(event?.target?.closest?.("[data-iamccs-step-drag='true']")); +} + +function hasJsonBoardDrag(event) { + const transfer = event?.dataTransfer; + if (!transfer) return false; + const files = Array.from(transfer.files || []); + if (files.some((file) => /\.json$/i.test(String(file?.name || "")) || String(file?.type || "").includes("json"))) return true; + return Array.from(transfer.items || []).some((item) => { + const name = String(item?.name || ""); + const type = String(item?.type || ""); + return item?.kind === "file" && (/\.json$/i.test(name) || type.includes("json")); + }); +} + function protectDragHandle(element) { if (!element) return element; for (const eventName of ["pointerdown", "mousedown", "touchstart"]) { @@ -450,13 +559,13 @@ function tableShell(title, subtitle) { function defaultKeyframes() { return [ - { second: 0.0, ref: 1, strength: 0.78, label: "opening_anchor", camera: "first reference starts alive; avoid a static slide" }, - { second: 2.2, ref: 2, strength: 0.08, label: "micro_motion", camera: "subtle motion only; avoid hard still-frame anchor" }, + { second: 0.0, ref: 1, strength: 0.78, label: "opening_anchor", camera: "first reference starts alive with subtle natural motion" }, + { second: 2.2, ref: 2, strength: 0.08, label: "micro_motion", camera: "subtle motion only with a soft anchor" }, { second: 4.8, ref: 3, strength: 0.24, label: "approach_anchor", camera: "smooth camera approach before the next visual beat" }, - { second: 6.9, ref: 4, strength: 0.24, label: "detail_anchor", camera: "macro detail stays sharp, no jitter" }, + { second: 6.9, ref: 4, strength: 0.24, label: "detail_anchor", camera: "macro detail stays sharp with stable focus" }, { second: 8.9, ref: 5, strength: 0.26, label: "transition_anchor", camera: "visual transition opens gradually through physical travel" }, - { second: 10.9, ref: 6, strength: 0.18, label: "environment_entry", camera: "new space enters as physical travel, no overlay" }, - { second: 12.9, ref: 7, strength: 0.18, label: "environment_motion", camera: "descending or tracking motion, no cross dissolve" }, + { second: 10.9, ref: 6, strength: 0.18, label: "environment_entry", camera: "new space enters as physical travel and parallax" }, + { second: 12.9, ref: 7, strength: 0.18, label: "environment_motion", camera: "descending or tracking motion with connected parallax" }, { second: 14.7, ref: 8, strength: 0.18, label: "texture_anchor", camera: "surface detail grows through parallax" }, { second: 16.6, ref: 10, strength: 0.26, label: "final_motion", camera: "final subject or environment stays moving" }, { second: 18.7, ref: 11, strength: 0.18, label: "end_anchor", camera: "camera keeps moving until the end" }, @@ -465,9 +574,9 @@ function defaultKeyframes() { function defaultSegments() { return [ - { seconds: 4.0, prompt: "opening subject or place stays alive with subtle natural motion, not frozen", camera: "slow push-in" }, + { seconds: 4.0, prompt: "opening subject or place stays alive with subtle natural motion", camera: "slow push-in" }, { seconds: 3.0, prompt: "camera approaches the next detail with parallax and stable focus", camera: "easy-in push" }, - { seconds: 3.0, prompt: "visual transition develops gradually through physical travel, no dissolve", camera: "macro push-in" }, + { seconds: 3.0, prompt: "visual transition develops gradually through physical travel", camera: "macro push-in" }, { seconds: 3.0, prompt: "new environment or object emerges coherently through motion", camera: "continuous dolly-in" }, { seconds: 3.0, prompt: "camera continues through space, texture and scale grow naturally", camera: "tracking shot" }, { seconds: 2.5, prompt: "final beat keeps real motion until the end", camera: "continuous dolly-in" }, @@ -552,6 +661,8 @@ function renderKeyframeEditor(node) { "Single generation. Add reference keyframes by seconds: face, eye, object, environment, waves." ); root.style.marginTop = `${FLF_SEQUENCER_TOP_CLEARANCE}px`; + root.style.overflow = "visible"; + table.style.overflow = "visible"; const addBtn = button("Add Key", "primary"); const presetFaceBtn = button("Preset Continuous Path"); @@ -596,7 +707,24 @@ function renderKeyframeEditor(node) { if (state.fps != null) setWidgetValue(node, "frame_rate", Math.round(state.fps)); if (state.refs > 0) setWidgetValue(node, "fallback_num_images", state.refs); sync(); - linkedStatus.textContent = `Synced from ShotPlanner: ${rows.length} FLF guides, ${state.refs || rows.length} refs, ${state.duration ?? "?"}s @ ${state.fps ?? "?"}fps.`; + let imageSource = state.multiInputSource; + let outputName = String(imageSource?.outputName || ""); + let imageSourceText = imageSource ? `${imageSource.nodeClass || "node"}.${outputName || `slot_${imageSource.originSlot}`}` : "unlinked multi_input"; + const fixedSingleImageOutput = healCineInfoImageBatchLink(node, state); + if (fixedSingleImageOutput) { + const refreshedState = readPlannerLinkedFLFState(node); + imageSource = refreshedState?.multiInputSource || imageSource; + outputName = String(imageSource?.outputName || outputName); + imageSourceText = imageSource ? `${imageSource.nodeClass || "node"}.${outputName || `slot_${imageSource.originSlot}`}` : imageSourceText; + } + const wrongSingleImageOutput = state.refs > 1 && /(^|_)image_?1$/i.test(outputName.replace(/\s+/g, "_")); + linkedStatus.textContent = fixedSingleImageOutput + ? `Fixed image link: multi_input now uses ${imageSourceText}; all ${state.refs} references can reach FLF.` + : wrongSingleImageOutput + ? `Warning: synced ${rows.length} guides / ${state.refs} refs, but multi_input is linked to ${imageSourceText}. Connect CineInfo.multi_output to use all images.` + : `Synced from ShotPlanner: ${rows.length} FLF guides, ${state.refs || rows.length} refs, ${state.duration ?? "?"}s @ ${state.fps ?? "?"}fps. Images: ${imageSourceText}.`; + linkedStatus.style.borderColor = (wrongSingleImageOutput && !fixedSingleImageOutput) ? CINE_FILM_LAB.warn : chrome.border; + linkedStatus.style.color = (wrongSingleImageOutput && !fixedSingleImageOutput) ? "#FFE8A3" : CINE_FILM_LAB.muted; if (redraw) draw(); return true; } @@ -608,15 +736,20 @@ function renderKeyframeEditor(node) { function draw() { table.innerHTML = ""; + const desiredHeight = FLF_SEQUENCER_TOP_CLEARANCE + FLF_SEQUENCER_BASE_HEIGHT + Math.max(1, rows.length) * FLF_SEQUENCER_ROW_HEIGHT; + node.size = [ + Math.max(node.size?.[0] || FLF_SEQUENCER_NODE_MIN_SIZE[0], FLF_SEQUENCER_NODE_MIN_SIZE[0]), + Math.max(node.size?.[1] || FLF_SEQUENCER_NODE_MIN_SIZE[1], desiredHeight), + ]; const header = document.createElement("div"); - header.style.cssText = "display:grid; grid-template-columns:88px 46px 64px 92px 1fr 28px; gap:6px; color:#9fb0bd; font-size:10px; padding:0 2px;"; + header.style.cssText = "display:grid; grid-template-columns:94px 46px 64px 92px minmax(220px,1fr) 28px; gap:6px; color:#9fb0bd; font-size:10px; padding:0 2px;"; header.innerHTML = "
Sec
Ref
Force
Label
Camera / note
"; table.appendChild(header); rows.forEach((row, index) => { const r = normalizeKeyframe(row, index); const line = document.createElement("div"); - line.style.cssText = "display:grid; grid-template-columns:88px 46px 64px 92px 1fr 28px; gap:6px; align-items:center;"; + line.style.cssText = `display:grid; grid-template-columns:94px 46px 64px 92px minmax(220px,1fr) 28px; gap:6px; align-items:start; min-height:${FLF_SEQUENCER_ROW_HEIGHT - 8}px;`; const sec = timeControl(r.second, (value) => { rows[index].second = value; sync(); }); @@ -657,6 +790,7 @@ function renderKeyframeEditor(node) { list.appendChild(item); }); table.appendChild(list); + try { node.setDirtyCanvas?.(true, true); app.graph?.setDirtyCanvas?.(true, true); } catch {} } addBtn.onclick = () => { rows.push({ second: rows.length ? Number(rows[rows.length - 1].second) + 2 : 0, ref: rows.length + 1, strength: 0.78, label: `key_${rows.length + 1}`, camera: "continuous dolly-in" }); sync(); draw(); }; @@ -684,7 +818,10 @@ function renderKeyframeEditor(node) { }, 750); } const widget = node.addDOMWidget("Cine FLF Timeline", "iamccs_cine_flf_timeline", root, { serialize: false }); - widget.computeSize = (width) => [width, Math.max(320, FLF_SEQUENCER_TOP_CLEARANCE + 104 + rows.length * 38)]; + widget.computeSize = (width) => [ + width, + Math.max(520, FLF_SEQUENCER_TOP_CLEARANCE + FLF_SEQUENCER_BASE_HEIGHT + Math.max(1, rows.length) * FLF_SEQUENCER_ROW_HEIGHT), + ]; } function renderPromptRelayEditor(node) { @@ -787,7 +924,10 @@ const RELAY_ADDON_POSITION_OPTIONS = ["after", "before"]; function normalizeOption(value, options, fallback) { const raw = String(value || "").trim(); - return options.includes(raw) ? raw : fallback; + const values = (Array.isArray(options) ? options : []).map((option) => ( + option && typeof option === "object" ? String(option.value ?? "") : String(option ?? "") + )); + return values.includes(raw) ? raw : fallback; } function cameraRelayText(row) { @@ -820,23 +960,149 @@ function transitionRelayParts(row, nextRow, mode = "safe_only") { const transition = String(row.transition || "continuous_motion").trim(); const parts = []; if (transition === "hard_cut") { - if (mode === "append") parts.push("hard cut staging; do not morph identities inside this single shot"); + if (mode === "append") parts.push("hard cut staging with a clean identity handoff inside this single shot"); return parts; } if (transition === "match_cut") { parts.push("match movement continuity through shape and camera direction"); } else if (transition === "soft_morph") { - parts.push("single continuous transformation, avoid visible cross dissolve"); + parts.push("single continuous transformation with a feathered optical blend"); } else { - parts.push("continuous physical camera movement, no slideshow, no cross dissolve"); + parts.push("continuous physical camera movement with stable parallax and connected spatial motion"); } if (nextRow && transition !== "hard_cut") { const nextLabel = String(nextRow.label || "next target").replace(/_/g, " "); - parts.push(`move toward ${nextLabel} without flashing reference frames`); + parts.push(`move toward ${nextLabel} through one steady camera path`); } return parts; } +const STEP_TRANSITION_OPTIONS = [ + { value: "off", label: "Off" }, + { value: "action_beat", label: "Action beat" }, + { value: "slow_dolly_in", label: "Slow dolly" }, + { value: "hold_then_push", label: "Hold + push" }, + { value: "orbit_bridge", label: "Orbit bridge" }, + { value: "match_move", label: "Match move" }, + { value: "rack_focus", label: "Rack focus" }, + { value: "soft_push", label: "Soft push" }, +]; + +const STEP_TRANSITION_ARRIVAL_OPTIONS = [ + { value: "auto", label: "Auto" }, + { value: "early", label: "Early" }, + { value: "middle", label: "Middle" }, + { value: "late", label: "Late" }, + { value: "very_late", label: "Very late" }, +]; + +function stepTransitionLabel(value) { + const found = STEP_TRANSITION_OPTIONS.find((item) => item.value === String(value || "off")); + return found ? found.label : "Slow dolly"; +} + +function stepTransitionArrivalLabel(value) { + const found = STEP_TRANSITION_ARRIVAL_OPTIONS.find((item) => item.value === String(value || "auto")); + return found ? found.label : "Auto"; +} + +function defaultStepTransitionSeconds(type, availableSeconds = 0) { + const available = Math.max(0, Number(availableSeconds || 0)); + const fallback = { + action_beat: 2.5, + slow_dolly_in: 3.2, + hold_then_push: 3.8, + orbit_bridge: 3.6, + match_move: 2.6, + rack_focus: 2.4, + soft_push: 2.8, + }[String(type || "slow_dolly_in")] || 3.0; + if (available >= 2.5 && available <= 5.0) return Number(available.toFixed(1)); + return fallback; +} + +function defaultStepTransitionArrival(type) { + return { + action_beat: "middle", + slow_dolly_in: "late", + hold_then_push: "very_late", + orbit_bridge: "late", + match_move: "middle", + rack_focus: "middle", + soft_push: "late", + }[String(type || "slow_dolly_in")] || "late"; +} + +function makeStepTransitionHeader(active, labelText = "Step transition", detailText = "to next shot") { + const header = document.createElement("div"); + header.style.cssText = [ + "display:grid", + "grid-template-columns:42px 1fr", + "gap:8px", + "align-items:center", + "min-width:0", + "padding:3px 2px 5px", + ].join(";"); + const icon = document.createElement("div"); + icon.style.cssText = [ + "width:42px", + "height:24px", + "border-radius:999px", + `background:${active ? "linear-gradient(135deg, rgba(223,164,81,.24), rgba(123,45,36,.28))" : "rgba(255,255,255,.045)"}`, + `border:1px solid ${active ? "rgba(223,164,81,.72)" : "rgba(139,128,110,.36)"}`, + "display:grid", + "place-items:center", + "box-shadow:inset 0 1px 0 rgba(255,255,255,.10), 0 3px 10px rgba(0,0,0,.18)", + ].join(";"); + icon.innerHTML = ` + `; + const copy = document.createElement("div"); + copy.style.cssText = "display:flex;flex-direction:column;gap:1px;min-width:0;text-align:left;"; + const title = document.createElement("span"); + title.textContent = labelText; + title.style.cssText = `color:${active ? "#F4E5C4" : "#CFC6B6"};font-size:10px;font-weight:900;line-height:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`; + const detail = document.createElement("span"); + detail.textContent = detailText; + detail.style.cssText = `color:${active ? "#DFA451" : "#928879"};font-size:8px;font-weight:800;line-height:1;text-transform:uppercase;letter-spacing:.03em;`; + copy.append(title, detail); + header.append(icon, copy); + return header; +} + +function makeStepBridgeMarker({ active = true, label = "", compact = false } = {}) { + const marker = document.createElement("div"); + marker.style.cssText = [ + "position:relative", + `width:${compact ? 44 : 72}px`, + `height:${compact ? 56 : 46}px`, + "flex:0 0 auto", + "display:flex", + "align-items:center", + "justify-content:center", + "pointer-events:none", + "filter:drop-shadow(0 2px 5px rgba(0,0,0,.48))", + ].join(";"); + marker.innerHTML = ` + `; + if (label && !compact) { + const chip = document.createElement("div"); + chip.textContent = label; + chip.style.cssText = "position:absolute;left:50%;bottom:0;transform:translateX(-50%);max-width:70px;padding:2px 6px;border:1px solid rgba(223,164,81,.65);border-radius:999px;background:rgba(30,25,20,.94);color:#F4E5C4;font:8px/1.1 monospace;font-weight:900;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"; + marker.appendChild(chip); + } + return marker; +} + function composeRelayPromptPreview(row, nextRow) { const normalized = normalizeShotboardRow(row, 0); const base = String(normalized.relay_prompt || "").trim(); @@ -855,14 +1121,20 @@ function composeRelayPromptPreview(row, nextRow) { return [...before, base, ...after].filter(Boolean).join(", "); } +function shotboardTemplateRows() { + return [ + { second: 0.0, ref: 1, force: 0.78, label: "opening_anchor", camera: "continuous dolly-in", transition: "continuous_motion", note: "Opening image breathes with subtle natural motion while the camera begins one connected move.", use_guide: true, use_prompt: false, relay_prompt: "opening image breathes with subtle natural motion while the camera begins one connected move" }, + { second: 2.5, ref: 2, force: 0.24, label: "path_checkpoint", camera: "continuous dolly-in", transition: "continuous_motion", note: "Soft spatial checkpoint; keep parallax and focus stable through the same camera path.", use_guide: true, use_prompt: false, relay_prompt: "soft spatial checkpoint with stable focus and connected parallax" }, + { second: 5.0, ref: 3, force: 0.26, label: "macro_detail", camera: "macro push-in", transition: "continuous_motion", note: "Macro detail grows through forward motion with crisp texture and living highlights.", use_guide: true, use_prompt: false, relay_prompt: "macro detail grows through forward motion, crisp texture, living highlights" }, + { second: 7.5, ref: 4, force: 0.22, label: "space_transfer", camera: "macro push-in", transition: "soft_morph", note: "The next space appears as an optical travel event with feathered texture continuity.", use_guide: true, use_prompt: false, relay_prompt: "the next space appears as an optical travel event with feathered texture continuity" }, + { second: 10.0, ref: 5, force: 0.20, label: "wide_motion", camera: "aerial descent", transition: "continuous_motion", note: "Wide environment opens with moving light, atmospheric depth and a steady camera drift.", use_guide: true, use_prompt: false, relay_prompt: "wide environment opens with moving light, atmospheric depth, steady camera drift" }, + { second: 13.5, ref: 6, force: 0.18, label: "final_push", camera: "continuous dolly-in", transition: "continuous_motion", note: "Final push continues the same movement with coherent scale, texture and direction.", use_guide: true, use_prompt: false, relay_prompt: "final push continues the same movement with coherent scale, texture and direction" }, + ]; +} + function defaultShotboardRows() { return [ - { second: 0.0, ref: 1, force: 0.62, use_guide: true, use_prompt: true, label: "opening_anchor", camera: "slow push-in", transition: "continuous_motion", camera_relay_mode: "before", transition_relay_mode: "off", relay_addon_position: "after", note: "", relay_prompt: "opening subject or place stays alive with subtle natural motion" }, - { second: 4.0, ref: 2, force: 0.0, use_guide: false, use_prompt: true, label: "approach_motion", camera: "easy-in push", transition: "continuous_motion", camera_relay_mode: "before", transition_relay_mode: "off", relay_addon_position: "after", note: "", relay_prompt: "camera advances through the same space with growing parallax" }, - { second: 7.0, ref: 3, force: 0.22, use_guide: true, use_prompt: true, label: "midpoint_anchor", camera: "macro push-in", transition: "soft_morph", camera_relay_mode: "before", transition_relay_mode: "off", relay_addon_position: "after", note: "", relay_prompt: "soft visual checkpoint, keep focus stable and avoid a still-frame flash" }, - { second: 10.0, ref: 4, force: 0.0, use_guide: false, use_prompt: true, label: "environment_shift", camera: "continuous dolly-in", transition: "continuous_motion", camera_relay_mode: "before", transition_relay_mode: "off", relay_addon_position: "after", note: "", relay_prompt: "the next space appears through physical travel, not a slideshow transition" }, - { second: 13.0, ref: 5, force: 0.0, use_guide: false, use_prompt: false, label: "prompt_continuity", camera: "tracking shot", transition: "continuous_motion", camera_relay_mode: "before", transition_relay_mode: "off", relay_addon_position: "after", note: "", relay_prompt: "optional prompt-only continuity row; keep previous visual direction" }, - { second: 16.0, ref: 6, force: 0.18, use_guide: true, use_prompt: true, label: "final_anchor", camera: "continuous dolly-in", transition: "continuous_motion", camera_relay_mode: "before", transition_relay_mode: "off", relay_addon_position: "after", note: "", relay_prompt: "final soft anchor, keep motion alive until the end" }, + { second: 0.0, ref: 1, force: 0.22, label: "ref_1", camera: "continuous dolly-in", transition: "continuous_motion", note: "", use_guide: true, use_prompt: false, relay_prompt: "", camera_relay_mode: "off", transition_relay_mode: "off", relay_addon_position: "after", relay_modifier_text: "" }, ]; } @@ -911,7 +1183,7 @@ function boardFromWorkflowJson(data) { if (!Array.isArray(data?.nodes)) return null; const node = data.nodes.find((item) => { const type = String(item?.type || item?.class_type || ""); - return type === "IAMCCS_CineShotboardLite" || type === "IAMCCS_CineShotboardPlannerPro" || type === "IAMCCS_CineShotboardPlannerProV2" || type === "IAMCCS_CineShotboardPlannerProLegacy" || type === "IAMCCS_CineShotboardTimelinePro"; + return type === "IAMCCS_CineShotboardLite" || type === "IAMCCS_CineShotboardPlannerPro" || type === "IAMCCS_CineShotboardPlannerProV2" || type === "IAMCCS_CineShotboardPlannerV3" || type === "IAMCCS_CineShotboardPlannerProLegacy" || type === "IAMCCS_CineShotboardTimelinePro"; }); if (!node) return null; const widgets = Array.isArray(node.widgets_values) ? node.widgets_values : []; @@ -936,6 +1208,9 @@ function boardFromWorkflowJson(data) { 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, }; } @@ -955,13 +1230,26 @@ function normalizeShotboardRow(row, index, options = {}) { row.relayPrompt, row.promptLocal, ); + const stepEnabled = Boolean(row.step_transition_enabled || row.stepTransitionEnabled); + const stepType = normalizeOption( + row.step_transition_type || row.stepTransitionType || (stepEnabled ? "slow_dolly_in" : "off"), + STEP_TRANSITION_OPTIONS, + "off", + ); + const stepDuration = Number(row.step_transition_duration ?? row.stepTransitionDuration ?? row.step_seconds ?? 0); + const relayFlag = row.use_prompt ?? row.use_relay ?? row.relay ?? row.prompt_relay; + const relayFlagText = String(relayFlag).trim().toLowerCase(); + const relayRequested = relayFlag === undefined + ? String(legacyRelayPrompt || "").trim().length > 0 + : !(relayFlag === false || relayFlagText === "false" || relayFlagText === "0" || relayFlagText === "off" || relayFlagText === "no"); return { _ui_id: String(row._ui_id || row.ui_id || `row_${Date.now()}_${Math.random().toString(16).slice(2)}`), second: Number.isFinite(Number(row.second ?? row.time ?? row.seconds)) ? Number(row.second ?? row.time ?? row.seconds) : index * 3, ref: Number.isFinite(Number(row.ref ?? row.image_ref ?? row.reference_index)) ? Number(row.ref ?? row.image_ref ?? row.reference_index) : index + 1, force: Number.isFinite(force) ? Math.max(0, Math.min(1, force)) : 0.22, + image_lock_strength: Number.isFinite(force) ? Math.max(0, Math.min(1, force)) : 0.22, use_guide: row.use_guide ?? row.guide ?? true, - use_prompt: row.use_prompt ?? row.use_relay ?? row.relay ?? row.prompt_relay ?? true, + use_prompt: Boolean(relayRequested || (stepEnabled && stepType !== "off")), label: String(row.label || row.shot_label || `shot_${index + 1}`), camera: normalizeCameraOption(row.camera || row.camera_move || "continuous dolly-in"), transition: String(row.transition || row.transition_intent || "continuous_motion"), @@ -972,6 +1260,24 @@ function normalizeShotboardRow(row, index, options = {}) { transition_relay_mode: normalizeOption(row.transition_relay_mode || row.transition_prompt_mode || (legacyOn ? "safe_only" : "off"), TRANSITION_RELAY_OPTIONS, "off"), relay_addon_position: normalizeOption(row.relay_addon_position || row.addon_position || "after", RELAY_ADDON_POSITION_OPTIONS, "after"), relay_modifier_text: String(row.relay_modifier_text || row.modifier_text || row.relay_addon || ""), + step_transition_enabled: Boolean(stepEnabled && stepType !== "off"), + step_transition_type: stepType, + step_transition_prompt: String(row.step_transition_prompt || row.stepTransitionPrompt || ""), + step_transition_easing: normalizeOption(row.step_transition_easing || row.stepTransitionEasing || "ease_in_out", [ + { value: "linear" }, + { value: "ease_in" }, + { value: "ease_out" }, + { value: "ease_in_out" }, + ], "ease_in_out"), + step_transition_force_curve: normalizeOption(row.step_transition_force_curve || row.stepTransitionForceCurve || "late_target", [ + { value: "balanced" }, + { value: "late_target" }, + { value: "early_source" }, + { value: "free_motion" }, + ], "late_target"), + step_transition_duration: Number.isFinite(stepDuration) ? Math.max(0, stepDuration) : 0, + step_transition_arrival: normalizeOption(row.step_transition_arrival || row.stepTransitionArrival || "auto", STEP_TRANSITION_ARRIVAL_OPTIONS, "auto"), + step_transition_auto_fit: (row.step_transition_auto_fit ?? row.stepTransitionAutoFit ?? true) !== false, }; } @@ -991,6 +1297,347 @@ function downloadJsonFile(data, filename) { setTimeout(() => URL.revokeObjectURL(url), 1200); } +function sanitizePackageComponent(value, fallback = "cine_shotboard_package") { + const clean = String(value || fallback) + .trim() + .replace(/[<>:"/\\|?*\x00-\x1F]+/g, "_") + .replace(/\s+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 90); + return clean || fallback; +} + +function timestampForPackageName() { + return new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); +} + +function imageExtensionForPackage(path, contentType = "") { + const fromPath = String(path || "").split(/[?#]/)[0].match(/\.([a-z0-9]{2,5})$/i)?.[1]; + if (fromPath) return fromPath.toLowerCase() === "jpeg" ? "jpg" : fromPath.toLowerCase(); + const type = String(contentType || "").toLowerCase(); + if (type.includes("jpeg")) return "jpg"; + if (type.includes("png")) return "png"; + if (type.includes("webp")) return "webp"; + if (type.includes("gif")) return "gif"; + return "png"; +} + +function cineResizeMethodValue(value) { + const method = String(value || "").trim(); + return ["crop", "pad", "keep proportion", "stretch"].includes(method) ? method : "crop"; +} + +function collectPackageImagePaths(board) { + const seen = new Set(); + const paths = []; + const add = (value) => { + const clean = String(value || "").trim(); + if (!clean || seen.has(clean)) return; + seen.add(clean); + paths.push(clean); + }; + for (const path of splitReferencePaths(board?.image_paths)) add(path); + if (Array.isArray(board?.images)) { + for (const image of board.images) add(image?.path || image?.original_path || image?.filename || image?.name); + } + const timelines = [board?.timeline, board?.timeline_data].filter(Boolean); + for (const source of timelines) { + let data = source; + if (typeof source === "string") { + try { data = JSON.parse(source); } catch { data = null; } + } + const segments = Array.isArray(data?.segments) ? data.segments : []; + for (const seg of segments) add(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); + } + return paths; +} + +function cloneJsonData(value) { + try { return JSON.parse(JSON.stringify(value ?? {})); } + catch { return {}; } +} + +function packagedImageEntries(board) { + const entries = []; + const addEntries = (items) => { + if (!Array.isArray(items)) return; + for (const item of items) { + if (item && typeof item === "object") entries.push(item); + } + }; + addEntries(board?.package?.images); + addEntries(board?.images); + return entries; +} + +function packagedReferencePaths(board) { + const seen = new Set(); + const paths = []; + const packageName = sanitizePackageComponent(board?.package?.name || board?.metadata?.package_name || "", ""); + const add = (value) => { + const clean = String(value || "").trim(); + if (!clean || seen.has(clean)) return; + seen.add(clean); + paths.push(clean); + }; + const entries = packagedImageEntries(board); + const packageEntries = entries.filter((item) => item?.path || item?.comfy_input_path || item?.package_path || item?.filename || item?.name); + for (const item of packageEntries) { + let path = item.path || item.comfy_input_path || item.package_path || item.filename || item.name; + if (packageName && !item.path && !item.comfy_input_path && String(path || "").replace(/\\/g, "/").startsWith("images/")) { + path = `${packageName}/${String(path).replace(/\\/g, "/")}`; + } + add(path); + } + if (!paths.length) { + for (const path of splitReferencePaths(board?.image_paths)) add(path); + } + return paths; +} + +function rewritePackagedSegments(segments, pathMap) { + if (!Array.isArray(segments)) return; + for (const seg of segments) { + if (!seg || typeof seg !== "object") continue; + for (const key of ["imageFile", "image_file", "path"]) { + const value = String(seg[key] || "").trim(); + if (value && pathMap[value]) seg[key] = pathMap[value]; + } + } +} + +function rewriteBoardForPackage(board, orderedPaths, pathMap, manifestImages) { + const packagedBoard = cloneJsonData(board); + const packagedPaths = orderedPaths.map((path) => pathMap[path]).filter(Boolean); + if (packagedPaths.length) packagedBoard.image_paths = packagedPaths; + packagedBoard.images = (manifestImages || []).map((entry, index) => ({ + ref: entry.ref || index + 1, + path: entry.path || entry.comfy_input_path || entry.package_path || entry.original_path || "", + package_path: entry.package_path || "", + comfy_input_path: entry.comfy_input_path || "", + original_path: entry.original_path || "", + name: entry.filename || entry.original_name || `ref_${index + 1}`, + filename: entry.filename || "", + content_type: entry.content_type || "", + data_url: entry.data_url || "", + bytes: entry.bytes || 0, + error: entry.error || undefined, + })); + rewritePackagedSegments(packagedBoard.segments, pathMap); + if (packagedBoard.timeline && typeof packagedBoard.timeline === "object") { + if (packagedPaths.length) packagedBoard.timeline.image_paths = packagedPaths; + rewritePackagedSegments(packagedBoard.timeline.segments, pathMap); + } + if (typeof packagedBoard.timeline_data === "string" && packagedBoard.timeline_data.trim()) { + try { + const parsed = JSON.parse(packagedBoard.timeline_data); + if (parsed && typeof parsed === "object") { + if (packagedPaths.length) parsed.image_paths = packagedPaths; + rewritePackagedSegments(parsed.segments, pathMap); + packagedBoard.timeline_data = JSON.stringify(parsed, null, 2); + } + } catch {} + } + return packagedBoard; +} + +async function writePackageTextFile(directoryHandle, filename, text) { + const fileHandle = await directoryHandle.getFileHandle(filename, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(text); + await writable.close(); +} + +async function writePackageBlobFile(directoryHandle, filename, blob) { + const fileHandle = await directoryHandle.getFileHandle(filename, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(blob); + await writable.close(); +} + +async function fetchReferenceBlobForPackage(path) { + const url = previewUrlForPath(path); + if (!url) throw new Error("Missing reference image path"); + const resp = await fetch(url, { cache: "no-store" }); + if (!resp.ok) throw new Error(`Image fetch failed (${resp.status})`); + return await resp.blob(); +} + +function blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("Blob read failed")); + reader.readAsDataURL(blob); + }); +} + +async function ensureDirectoryWritable(directoryHandle) { + if (!directoryHandle || typeof directoryHandle.queryPermission !== "function") return true; + const options = { mode: "readwrite" }; + try { + if ((await directoryHandle.queryPermission(options)) === "granted") return true; + if (typeof directoryHandle.requestPermission === "function") { + return (await directoryHandle.requestPermission(options)) === "granted"; + } + } catch {} + return false; +} + +async function downloadShotboardPackageJsonFallback(board, label, reason = "", statusCallback = null) { + const imagePaths = collectPackageImagePaths(board); + const packageName = `${sanitizePackageComponent(label)}_${timestampForPackageName()}`; + const images = []; + for (let index = 0; index < imagePaths.length; index += 1) { + const originalPath = imagePaths[index]; + statusCallback?.(`Building fallback package image ${index + 1}/${imagePaths.length}...`); + const entry = { + ref: index + 1, + original_path: originalPath, + original_name: String(originalPath).split(/[\\/]/).pop() || `ref_${index + 1}`, + }; + try { + const blob = await fetchReferenceBlobForPackage(originalPath); + entry.content_type = blob.type || ""; + entry.bytes = blob.size || 0; + entry.ext = imageExtensionForPackage(originalPath, blob.type); + entry.data_url = await blobToDataUrl(blob); + } catch (err) { + entry.error = String(err?.message || err); + } + images.push(entry); + } + downloadJsonFile({ + metadata: { + schema: "iamccs.cine.shotboard.package.fallback", + schema_version: 2, + cine_ui_version: CINE_VERSION, + saved_at: new Date().toISOString(), + package_name: packageName, + reason: String(reason || "Folder package export unavailable"), + note: "Self-contained fallback package. Images are embedded as data_url when they were readable from ComfyUI.", + }, + board, + images, + }, `${packageName}.json`); + statusCallback?.(`Downloaded fallback package JSON: ${packageName}.json`); +} + +async function saveShotboardPackageViaBackend(board, label, packageName, statusCallback = null) { + statusCallback?.("Folder picker unavailable; saving package folder in ComfyUI input..."); + const resp = await api.fetchApi("/api/iamccs/cine/save_shotboard_package", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ board, label, package_name: packageName }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data?.ok) { + throw new Error(data?.error || `Backend package save failed (${resp.status})`); + } + const failed = Number(data.failed_images || 0); + statusCallback?.(`Saved package folder: ${data.package_name || packageName}${failed ? ` (${failed} image errors in manifest)` : ""}`); + return data; +} + +async function saveShotboardPackageFolder(board, label, statusCallback = null) { + const imagePaths = collectPackageImagePaths(board); + const packageName = `${sanitizePackageComponent(label)}_${timestampForPackageName()}`; + if (typeof window.showDirectoryPicker !== "function") { + try { + await saveShotboardPackageViaBackend(board, label, packageName, statusCallback); + } catch (err) { + console.warn("[IAMCCS Cine Shotboard] backend package save failed; using JSON fallback", err); + await downloadShotboardPackageJsonFallback(board, label, err?.message || "Browser folder writing is unavailable.", statusCallback); + } + return; + } + + try { + statusCallback?.("Choose a folder for the shotboard package..."); + const rootHandle = await window.showDirectoryPicker({ mode: "readwrite" }); + if (!(await ensureDirectoryWritable(rootHandle))) { + statusCallback?.("Folder write permission was not granted."); + return; + } + const packageHandle = await rootHandle.getDirectoryHandle(packageName, { create: true }); + const imagesHandle = await packageHandle.getDirectoryHandle("images", { create: true }); + const pathMap = {}; + const manifest = { + metadata: { + schema: "iamccs.cine.shotboard.package", + schema_version: 1, + cine_ui_version: CINE_VERSION, + saved_at: new Date().toISOString(), + package_name: packageName, + }, + board_file: "board.json", + images_dir: "images", + image_count: imagePaths.length, + images: [], + }; + + for (let index = 0; index < imagePaths.length; index += 1) { + const originalPath = imagePaths[index]; + statusCallback?.(`Saving package image ${index + 1}/${imagePaths.length}...`); + const entry = { + ref: index + 1, + original_path: originalPath, + original_name: String(originalPath).split(/[\\/]/).pop() || `ref_${index + 1}`, + }; + try { + const blob = await fetchReferenceBlobForPackage(originalPath); + const ext = imageExtensionForPackage(originalPath, blob.type); + const filename = `ref_${String(index + 1).padStart(3, "0")}.${ext}`; + await writePackageBlobFile(imagesHandle, filename, blob); + entry.package_path = `images/${filename}`; + entry.comfy_input_path = `${packageName}/images/${filename}`; + entry.path = entry.comfy_input_path; + entry.content_type = blob.type || ""; + entry.bytes = blob.size || 0; + entry.data_url = await blobToDataUrl(blob); + pathMap[originalPath] = entry.path; + } catch (err) { + entry.error = String(err?.message || err); + console.warn("[IAMCCS Cine Shotboard] package image export failed", originalPath, err); + } + manifest.images.push(entry); + } + + const packagedBoard = { + ...rewriteBoardForPackage(board, imagePaths, pathMap, manifest.images), + metadata: { + ...(board?.metadata || {}), + packaged_at: manifest.metadata.saved_at, + package_schema: manifest.metadata.schema, + }, + package: { + name: packageName, + images_dir: "images", + images: manifest.images, + }, + }; + await writePackageTextFile(packageHandle, "board.json", JSON.stringify(packagedBoard, null, 2)); + await writePackageTextFile(packageHandle, "manifest.json", JSON.stringify(manifest, null, 2)); + const failed = manifest.images.filter((entry) => entry.error).length; + statusCallback?.(`Saved package folder: ${packageName}${failed ? ` (${failed} image errors in manifest)` : ""}`); + } catch (err) { + if (String(err?.name || "") === "AbortError") { + statusCallback?.("Save Package cancelled."); + return; + } + console.warn("[IAMCCS Cine Shotboard] folder package failed; using JSON fallback", err); + try { + await saveShotboardPackageViaBackend(board, label, packageName, statusCallback); + } catch (backendErr) { + console.warn("[IAMCCS Cine Shotboard] backend package fallback failed; using JSON fallback", backendErr); + await downloadShotboardPackageJsonFallback(board, label, backendErr?.message || err?.message || err, statusCallback); + } + } +} + function readJsonFile(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -1015,6 +1662,24 @@ function safeBoardFilename(label) { return `${clean || "cine_shotboard"}_${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}.json`; } +async function saveBoardJsonToChosenFolder(board, filename, statusCallback = null) { + const safeName = String(filename || safeBoardFilename("iamccs_board")) + .replace(/[<>:"/\\|?*\x00-\x1F]+/g, "_") + .replace(/\.json$/i, "") + ".json"; + if (typeof window.showDirectoryPicker !== "function") { + downloadJsonFile(board, safeName); + statusCallback?.("Folder picker unavailable; board downloaded instead."); + return { mode: "download", filename: safeName }; + } + const directoryHandle = await window.showDirectoryPicker({ mode: "readwrite" }); + const fileHandle = await directoryHandle.getFileHandle(safeName, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(JSON.stringify(board, null, 2)); + await writable.close(); + statusCallback?.(`Board exported: ${safeName}`); + return { mode: "folder", filename: safeName }; +} + function isAbsoluteLocalPath(path) { return /^[a-zA-Z]:[\\/]/.test(path) || String(path || "").startsWith("\\\\") || String(path || "").startsWith("/"); @@ -1033,8 +1698,30 @@ function previewUrlForPath(path) { return `/api/view?${query.toString()}`; } +function looksLikeCompleteReferencePath(value) { + const clean = String(value || "").trim(); + if (!clean) return false; + if (isAbsoluteLocalPath(clean)) return true; + return /\.(png|jpe?g|webp|bmp|gif|tiff?|avif)$/i.test(clean); +} + function splitReferencePaths(value) { - return String(value || "").split(/\n|,/).map((item) => item.trim()).filter(Boolean); + if (Array.isArray(value)) { + return value.map((item) => String(item || "").trim()).filter(Boolean); + } + const raw = String(value || "").trim(); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.map((item) => String(item || "").trim()).filter(Boolean); + } + } catch {} + const newlineParts = raw.split(/\r?\n/).map((item) => item.trim()).filter(Boolean); + if (newlineParts.length > 1) return newlineParts; + const commaParts = raw.split(",").map((item) => item.trim()).filter(Boolean); + if (commaParts.length > 1 && commaParts.every(looksLikeCompleteReferencePath)) return commaParts; + return [raw]; } function ellipsize(text, maxLength = 14) { @@ -1061,12 +1748,69 @@ function moveRowsKeepingTimelineSlots(rows, from, to, normalizeRow) { }, index)); } +function roundToStep(value, step = 0.1) { + const scale = Math.max(1, Math.round(1 / Math.max(0.0001, Number(step) || 0.1))); + return Math.round((Number(value) || 0) * scale) / scale; +} + +function shiftFollowingRowsForTimeChange(rows, rowId, value, normalizeRow, minGapSeconds = 0.1) { + const list = (Array.isArray(rows) ? rows : []).map((row, index) => normalizeRow(row, index)); + const index = list.findIndex((row) => row?._ui_id === rowId); + if (index < 0) return list; + const before = Number(list[index]?.second || 0); + let nextValue = Math.max(0, Number(value) || 0); + if (index > 0) nextValue = Math.max(nextValue, Number(list[index - 1]?.second || 0) + minGapSeconds); + nextValue = roundToStep(nextValue, 0.1); + const delta = nextValue - before; + list[index] = normalizeRow({ ...list[index], second: nextValue }, index); + for (let i = index + 1; i < list.length; i += 1) { + const shifted = Number(list[i]?.second || 0) + delta; + const minAllowed = Number(list[i - 1]?.second || 0) + minGapSeconds; + list[i] = normalizeRow({ ...list[i], second: roundToStep(Math.max(shifted, minAllowed), 0.1) }, i); + } + return list; +} + +function insertDuplicateRowAfter(rows, sourceIndex, normalizeRow, makeFallbackRow, newRef) { + const list = (Array.isArray(rows) ? rows : []).map((row, index) => normalizeRow(row, index)); + const safeIndex = Math.max(0, Math.min(list.length - 1, Number(sourceIndex) || 0)); + const source = list[safeIndex] || makeFallbackRow(); + const insertSecond = roundToStep(Number(source?.second || 0) + 1, 0.1); + const shifted = list.map((row, index) => index > safeIndex ? normalizeRow({ + ...row, + second: roundToStep(Number(row.second || 0) + 1, 0.1), + }, index) : row); + const clone = normalizeRow({ + ...source, + _ui_id: undefined, + ref: newRef, + second: insertSecond, + label: `${String(source?.label || `ref_${Math.max(1, Number(newRef) - 1)}`).replace(/_dup\d*$/i, "")}_dup`, + use_guide: true, + }, safeIndex + 1); + shifted.splice(safeIndex + 1, 0, clone); + return shifted.map((row, index) => normalizeRow(row, index)); +} + function getOwnReferencePaths(node) { return splitReferencePaths(getWidget(node, "image_paths")?.value); } function setOwnReferencePaths(node, paths) { - return setWidgetValue(node, "image_paths", paths.join("\n")); + const clean = (Array.isArray(paths) ? paths : []).map((p) => String(p || "").trim()).filter(Boolean); + if (clean.length && node) { + node.properties = node.properties || {}; + node.properties.iamccs_refs_cleared = false; + } + return setWidgetValue(node, "image_paths", clean.join("\n")); +} + +function clearOwnReferencePaths(node) { + if (node) { + node.properties = node.properties || {}; + node.properties.iamccs_refs_cleared = true; + } + return setWidgetValue(node, "image_paths", ""); } function replaceReferencePathAt(node, index, newPath) { @@ -1097,6 +1841,7 @@ function isShotboardLiteNode(node) { } function getConnectedReferencePaths(node) { + if (node?.properties?.iamccs_refs_cleared === true) return []; const own = getOwnReferencePaths(node); if (own.length) return own; const input = node?.inputs?.find((item) => item?.name === "multi_input"); @@ -1126,13 +1871,15 @@ async function uploadShotboardImages(files) { for (const file of files || []) { const body = new FormData(); body.append("image", file); + body.append("type", "input"); + body.append("overwrite", "false"); try { const resp = await api.fetchApi("/upload/image", { method: "POST", body }); if (resp.status === 200) { const data = await resp.json(); - let name = data.name; + let name = String(data.name || file.name || "").trim(); if (data.subfolder) name = `${data.subfolder}/${name}`; - uploaded.push(name); + if (name) uploaded.push(name); } } catch (err) { console.error("[IAMCCS Cine Shotboard] image upload failed", err); @@ -1141,6 +1888,96 @@ async function uploadShotboardImages(files) { return uploaded; } +async function fileFromPackageDataUrl(dataUrl, filename, contentType = "") { + const resp = await fetch(String(dataUrl || "")); + if (!resp.ok) throw new Error(`Packaged image decode failed (${resp.status})`); + const blob = await resp.blob(); + return new File([blob], filename || "iamccs_packaged_ref.png", { type: contentType || blob.type || "image/png" }); +} + +async function restorePackagedImagesToComfyInput(board, statusCallback = null) { + const seen = new Set(); + const entries = []; + for (const entry of packagedImageEntries(board)) { + const dataUrl = String(entry?.data_url || "").trim(); + if (!dataUrl) continue; + const key = `${String(entry?.path || entry?.comfy_input_path || entry?.package_path || entry?.original_path || entry?.filename || entry?.name || "")}|${dataUrl.slice(0, 96)}`; + if (seen.has(key)) continue; + seen.add(key); + entries.push(entry); + } + if (!entries.length) return { paths: [], pathMap: {} }; + 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}`); + statusCallback?.(`Restoring packaged image ${index + 1}/${entries.length}...`); + try { + files.push(await fileFromPackageDataUrl(entry.data_url, filename, entry.content_type || "")); + } catch (err) { + console.warn("[IAMCCS Cine Shotboard] packaged image restore failed", entry, err); + } + } + const uploaded = await uploadShotboardImages(files); + const pathMap = {}; + 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 packagePath = String(entry.package_path || "").replace(/\\/g, "/").replace(/^\/+/, ""); + if (packageName && packagePath) pathMap[`${packageName}/${packagePath}`] = uploadedPath; + }); + return { paths: uploaded.filter(Boolean), pathMap }; +} + +function rewriteReferencePathList(value, pathMap) { + const paths = splitReferencePaths(value); + if (!paths.length) return value; + return paths.map((path) => pathMap[path] || path); +} + +function rewriteBoardPackagedImagePaths(board, pathMap) { + if (!board || !pathMap || !Object.keys(pathMap).length) return board; + if (Object.prototype.hasOwnProperty.call(board, "image_paths")) { + board.image_paths = rewriteReferencePathList(board.image_paths, pathMap); + } + rewritePackagedSegments(board.segments, pathMap); + if (board.timeline && typeof board.timeline === "object") { + if (Object.prototype.hasOwnProperty.call(board.timeline, "image_paths")) { + board.timeline.image_paths = rewriteReferencePathList(board.timeline.image_paths, pathMap); + } + rewritePackagedSegments(board.timeline.segments, pathMap); + } + if (typeof board.timeline_data === "string" && board.timeline_data.trim()) { + try { + const parsed = JSON.parse(board.timeline_data); + if (parsed && typeof parsed === "object") { + if (Object.prototype.hasOwnProperty.call(parsed, "image_paths")) { + parsed.image_paths = rewriteReferencePathList(parsed.image_paths, pathMap); + } + rewritePackagedSegments(parsed.segments, pathMap); + board.timeline_data = JSON.stringify(parsed, null, 2); + } + } catch {} + } + return board; +} + +async function packagedReferencePathsForImport(board, statusCallback = null) { + const restored = await restorePackagedImagesToComfyInput(board, statusCallback); + if (restored.paths.length) { + rewriteBoardPackagedImagePaths(board, restored.pathMap); + return restored.paths; + } + return packagedReferencePaths(board); +} + async function transformShotboardReference(payload) { const resp = await api.fetchApi("/api/iamccs/cine/transform_reference", { method: "POST", @@ -1154,6 +1991,27 @@ async function transformShotboardReference(payload) { return data; } +async function grabShotboardReferenceProjectCopy(node, path) { + if (!path) return null; + const targetWidth = Number(getWidget(node, "image_width")?.value || 768); + const targetHeight = Number(getWidget(node, "image_height")?.value || 432); + const data = await transformShotboardReference({ + path, + width: targetWidth, + height: targetHeight, + fit_mode: "contain", + zoom: 1, + pan_x: 0, + pan_y: 0, + rotation: 0, + resample: "lanczos", + crop_box: null, + crop_box_source: "grab_project_size", + }); + node._iamccsLastGrabbedReferencePath = data?.absolute_path || data?.path || ""; + return data; +} + function openReferenceFrameEditor(node, index, path, onApply) { const existing = document.querySelector(".iamccs-cine-ref-editor-overlay"); existing?.remove(); @@ -1168,7 +2026,7 @@ function openReferenceFrameEditor(node, index, path, onApply) { overlay.style.cssText = [ "position:fixed", "inset:0", - "z-index:100000", + `z-index:${CINE_REF_EDITOR_Z_INDEX}`, "background:rgba(5,7,9,.82)", "display:flex", "align-items:center", @@ -1176,6 +2034,7 @@ function openReferenceFrameEditor(node, index, path, onApply) { "padding:28px", "box-sizing:border-box", "pointer-events:auto", + "isolation:isolate", ].join(";"); const panel = document.createElement("div"); @@ -1483,7 +2342,7 @@ function toggleFullscreenEditor(root, node) { overlay.style.cssText = ` position: fixed; inset: 0; - z-index: 999999; + z-index: ${CINE_FULLSCREEN_Z_INDEX}; background: rgba(4, 8, 11, .82); display: flex; flex-direction: column; @@ -1503,7 +2362,12 @@ function toggleFullscreenEditor(root, node) { font: 12px Arial, sans-serif; `; const title = document.createElement("div"); - title.textContent = "Cine Shotboard Timeline Pro"; + const klass = nodeClassName(node); + title.textContent = klass === "IAMCCS_BoardMaker" + ? "IAMCCS BoardMaker Editor" + : klass === "IAMCCS_CineShotboardPlannerV3" + ? "IAMCCS Shotboard V3 Full Frame" + : "IAMCCS Shotboard Full Frame"; title.style.cssText = "font-weight:700;letter-spacing:0;color:#ffffff;"; const close = button("Close Editor", "primary"); bar.append(title, close); @@ -1550,24 +2414,25 @@ function cineReferenceActionTone(label) { E: { bg: "#0F5F78", border: "#79D7F0", color: "#F2FCFF" }, R: { bg: "#7A5420", border: "#F0C36A", color: "#FFF4D8" }, D: { bg: "#513887", border: "#C9B4FF", color: "#F6F1FF" }, + G: { bg: "#2E6B3F", border: "#8CEAA3", color: "#F2FFF5" }, }; return tones[label] || { bg: CINE_NODE_CHROME.shotboard.header, border: CINE_FILM_LAB.borderSoft, color: "#fff" }; } function refPicker(value, referencePaths, onChange, options = {}) { - const thumbWidth = Math.max(150, Number(options.thumbWidth) || 168); - const thumbHeight = Math.max(116, Number(options.thumbHeight) || 116); - const hasSideActions = Boolean(options.onEdit || options.onReplace || options.onDuplicate); - const sideActionCount = [options.onEdit, options.onReplace, options.onDuplicate].filter(Boolean).length; + const thumbWidth = Math.max(132, Number(options.thumbWidth) || 150); + const thumbHeight = Math.max(86, Number(options.thumbHeight) || 96); + const hasSideActions = Boolean(options.onEdit || options.onReplace || options.onDuplicate || options.onGrab); + const sideActionCount = [options.onEdit, options.onReplace, options.onDuplicate, options.onGrab].filter(Boolean).length; const gutterWidth = hasSideActions ? 28 : 0; const imageWidth = hasSideActions ? Math.max(72, thumbWidth - gutterWidth) : Math.max(120, thumbWidth); const wrap = document.createElement("div"); - wrap.style.cssText = `display:grid;grid-template-rows:${thumbHeight}px 26px;gap:5px;align-items:center;`; + wrap.style.cssText = `display:grid;grid-template-rows:${thumbHeight}px 26px;gap:5px;align-items:center;width:${thumbWidth}px;max-width:${thumbWidth}px;min-width:0;box-sizing:border-box;`; const frameWrap = document.createElement("div"); frameWrap.style.cssText = hasSideActions - ? `display:grid;grid-template-columns:${gutterWidth}px 1fr;width:${thumbWidth}px;height:${thumbHeight}px;min-width:0;` - : `display:block;width:${thumbWidth}px;height:${thumbHeight}px;`; + ? `display:grid;grid-template-columns:${gutterWidth}px ${imageWidth}px;width:${thumbWidth}px;height:${thumbHeight}px;min-width:0;box-sizing:border-box;` + : `display:block;width:${thumbWidth}px;height:${thumbHeight}px;min-width:0;box-sizing:border-box;`; const thumb = document.createElement("div"); thumb.style.cssText = ` @@ -1583,6 +2448,7 @@ function refPicker(value, referencePaths, onChange, options = {}) { justify-content: center; color: #8fa3af; font-size: 11px; + box-sizing: border-box; `; const index = Math.max(1, Math.min(50, Number(value) || 1)); @@ -1659,6 +2525,12 @@ function refPicker(value, referencePaths, onChange, options = {}) { options.onDuplicate(index - 1, path); })); } + if (options.onGrab) { + actionRail.appendChild(sideAction("G", "Grab/export a project-sized copy into IAMCCS_newimages", () => { + if (!path) return; + options.onGrab(index - 1, path); + })); + } frameWrap.appendChild(actionRail); } @@ -1669,7 +2541,7 @@ function refPicker(value, referencePaths, onChange, options = {}) { frameWrap.appendChild(thumb); const select = document.createElement("select"); - select.style.cssText = inputBase() + "height:26px;padding:2px 5px;"; + select.style.cssText = inputBase() + `height:26px;padding:2px 5px;width:${thumbWidth}px;max-width:${thumbWidth}px;`; const maxOptions = Math.max(50, referencePaths.length || 0); for (let i = 1; i <= maxOptions; i++) { const opt = document.createElement("option"); @@ -1729,20 +2601,62 @@ function formatStepperValue(value, precision) { return n.toFixed(precision).replace(/\.?0+$/, ""); } -function numberStepperControl(value, step, min, max, onChange) { +function attachVerticalStepDrag(element, getValue, applyValue, stepValue, options = {}) { + const threshold = Number(options.threshold || 8); + const minValue = Number.isFinite(Number(options.min)) ? Number(options.min) : -Infinity; + 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.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + const startY = event.clientY; + let stepsApplied = 0; + let dragging = true; + element.setPointerCapture?.(event.pointerId); + const finish = (finishEvent) => { + dragging = false; + finishEvent?.stopPropagation?.(); + try { element.releasePointerCapture?.(event.pointerId); } catch {} + window.removeEventListener("pointermove", move, true); + window.removeEventListener("pointerup", finish, true); + window.removeEventListener("pointercancel", finish, true); + }; + const move = (moveEvent) => { + if (!dragging || !(moveEvent.buttons & 1)) return; + moveEvent.preventDefault(); + moveEvent.stopPropagation(); + const nextSteps = Math.trunc((startY - moveEvent.clientY) / threshold); + const deltaSteps = nextSteps - stepsApplied; + if (!deltaSteps) return; + stepsApplied = nextSteps; + const current = Number(getValue()); + const next = clamp((Number.isFinite(current) ? current : 0) + deltaSteps * stepValue); + applyValue(next); + }; + 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 }); +} + +function numberStepperControl(value, step, min, max, onChange, options = {}) { const stepValue = Math.max(0.0001, Number(step) || 1); const minValue = Number.isFinite(Number(min)) ? Number(min) : -Infinity; const hasMax = max !== null && max !== undefined && String(max).trim() !== ""; const maxValue = hasMax && Number.isFinite(Number(max)) ? Number(max) : Infinity; - const precision = stepPrecision(step); + const precision = Math.max(1, stepPrecision(step)); + const liveInput = options.liveInput !== false; const wrap = document.createElement("div"); - wrap.style.cssText = "display:grid;grid-template-columns:22px minmax(48px,1fr) 22px;gap:3px;align-items:center;min-width:96px;"; + wrap.style.cssText = "display:grid;grid-template-columns:32px minmax(58px,1fr) 32px;gap:12px;align-items:center;min-width:148px;"; const input = document.createElement("input"); input.type = "text"; - input.inputMode = precision > 0 ? "decimal" : "numeric"; + input.inputMode = "decimal"; input.value = formatStepperValue(value, precision); - input.style.cssText = inputBase() + "height:28px;padding:4px 5px;text-align:center;font-variant-numeric:tabular-nums;"; + input.style.cssText = inputBase() + "height:30px;padding:4px 6px;text-align:center;font-variant-numeric:tabular-nums;cursor:ns-resize;"; const clamp = (raw) => Math.min(maxValue, Math.max(minValue, Number(raw))); const apply = (raw) => { @@ -1755,7 +2669,7 @@ function numberStepperControl(value, step, min, max, onChange) { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = label; - btn.style.cssText = "height:28px;padding:0;border:1px solid #405664;border-radius:4px;background:#0b1116;color:#e8eef2;font-size:13px;line-height:1;cursor:pointer;"; + btn.style.cssText = "height:30px;min-width:32px;padding:0;border:1px solid #405664;border-radius:5px;background:#0b1116;color:#e8eef2;font-size:15px;font-weight:900;line-height:1;cursor:pointer;margin:0 2px;"; btn.onclick = (event) => { event.preventDefault(); const current = Number(String(input.value).replace(",", ".")) || 0; @@ -1765,6 +2679,7 @@ function numberStepperControl(value, step, min, max, onChange) { }; input.oninput = () => { + if (!liveInput) return; const raw = String(input.value).replace(",", ".").trim(); if (!raw) return; const n = Number(raw); @@ -1779,12 +2694,26 @@ function numberStepperControl(value, step, min, max, onChange) { apply(Number(raw)); }; input.onkeydown = (event) => { + if (event.key === "Enter") { + event.preventDefault(); + const raw = String(input.value).replace(",", ".").trim(); + if (raw) apply(Number(raw)); + input.blur(); + return; + } if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; event.preventDefault(); const current = Number(String(input.value).replace(",", ".")) || 0; apply(current + (event.key === "ArrowUp" ? stepValue : -stepValue)); }; input.onfocus = () => input.select(); + attachVerticalStepDrag( + input, + () => Number(String(input.value).replace(",", ".")) || 0, + (next) => apply(next), + stepValue, + { min: minValue, max: maxValue } + ); protectControlDrag(input); wrap.append(makeStep("-", -stepValue), input, makeStep("+", stepValue)); @@ -1794,7 +2723,113 @@ function numberStepperControl(value, step, min, max, onChange) { return protectControlDrag(wrap); } -function timeControl(value, onChange) { +function timeControl(value, onChange, frameRate = null, opts = {}) { + const validFps = Number.isFinite(Number(frameRate)) && Number(frameRate) > 0 ? Number(frameRate) : null; + const totalDuration = Number(opts.totalDuration || opts.duration || 0); + const nextSecond = Number(opts.nextSecond); + const liveInput = opts.liveInput !== false; + const wrap = document.createElement("div"); + wrap.style.cssText = "display:grid;grid-template-columns:1fr;gap:4px;min-width:104px;"; + + const makeMiniStepper = (initial, step, min, apply, inputMode = "decimal", formatter = formatTimeValue, title = "") => { + const row = document.createElement("div"); + row.style.cssText = "display:grid;grid-template-columns:20px minmax(48px,1fr) 20px;gap:3px;align-items:center;"; + const input = document.createElement("input"); + input.type = "text"; + input.inputMode = inputMode; + input.value = formatter(initial); + input.title = title; + input.style.cssText = inputBase() + "height:25px;padding:3px 4px;text-align:center;font-variant-numeric:tabular-nums;"; + const commit = (raw) => { + const n = Math.max(min, Number(String(raw).replace(",", ".")) || 0); + input.value = formatter(n); + apply(n); + updateInfo(); + }; + const makeStep = (label, delta) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = label; + btn.title = delta < 0 ? "Decrease" : "Increase"; + btn.style.cssText = "height:25px;padding:0;border:1px solid #405664;border-radius:4px;background:#0b1116;color:#e8eef2;font-size:12px;line-height:1;cursor:pointer;"; + btn.onclick = (event) => { + event.preventDefault(); + const current = Number(String(input.value).replace(",", ".")) || 0; + commit(current + delta); + }; + return protectControlDrag(btn); + }; + input.oninput = () => { + if (!liveInput) return; + const raw = String(input.value).replace(",", ".").trim(); + if (!raw) return; + const n = Number(raw); + if (Number.isFinite(n)) { + apply(Math.max(min, n)); + updateInfo(); + } + }; + input.onblur = () => commit(input.value); + input.onkeydown = (event) => { + if (event.key === "Enter") { + event.preventDefault(); + commit(input.value); + return; + } + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; + event.preventDefault(); + const current = Number(String(input.value).replace(",", ".")) || 0; + commit(current + (event.key === "ArrowUp" ? step : -step)); + }; + input.onfocus = () => input.select(); + attachVerticalStepDrag(input, () => Number(String(input.value).replace(",", ".")) || 0, commit, step, { min }); + protectControlDrag(input); + row._iamccsSetValue = (next) => { input.value = formatter(next); }; + row.append(makeStep("-", -step), input, makeStep("+", step)); + return row; + }; + + let currentSeconds = Math.max(0, Number(value) || 0); + const secondsRow = makeMiniStepper(currentSeconds, 0.1, 0, (next) => { + currentSeconds = Math.max(0, Number(next) || 0); + if (frameRow && validFps) frameRow._iamccsSetValue(Math.round(currentSeconds * validFps)); + onChange(currentSeconds); + }, "decimal", formatTimeValue, "Seconds"); + + let frameRow = null; + if (validFps) { + frameRow = makeMiniStepper(Math.round(currentSeconds * validFps), 1, 0, (frame) => { + currentSeconds = Math.max(0, Number(frame) || 0) / validFps; + secondsRow._iamccsSetValue(currentSeconds); + onChange(currentSeconds); + }, "numeric", (frame) => String(Math.max(0, Math.round(Number(frame) || 0))), "Absolute frame"); + } + + const durationLine = document.createElement("div"); + const rangeLine = document.createElement("div"); + const infoCss = "font-size:10px;line-height:1.15;color:#9fb9c7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums;border:1px solid rgba(104,138,154,.35);border-radius:3px;background:rgba(5,16,22,.72);padding:2px 4px;"; + durationLine.style.cssText = infoCss; + rangeLine.style.cssText = infoCss + "color:#7f98a6;"; + + function updateInfo() { + const start = Math.max(0, Number(currentSeconds) || 0); + const endCandidate = Number.isFinite(nextSecond) ? nextSecond : totalDuration; + const end = Number.isFinite(endCandidate) && endCandidate > 0 ? Math.max(start, endCandidate) : start; + const dur = Math.max(0, end - start); + const durText = formatTimeValue(dur); + const frameText = validFps ? ` (${Math.round(dur * validFps)}f)` : ""; + durationLine.textContent = `โฑ ${durText}s${frameText} - segment duration`; + rangeLine.textContent = `${formatTimeValue(start)}s โ†’ ${formatTimeValue(end)}s`; + } + + updateInfo(); + wrap.append(secondsRow); + if (frameRow) wrap.append(frameRow); + wrap.append(durationLine, rangeLine); + return protectControlDrag(wrap); +} + +function oldTimeControlRemoved(value, onChange) { const wrap = document.createElement("div"); wrap.style.cssText = "display:grid;grid-template-columns:22px minmax(42px,1fr) 22px;gap:3px;align-items:center;min-width:86px;"; @@ -1868,6 +2903,7 @@ function forceControl(value, onChange) { slider.oninput = () => sync(slider.value); out.oninput = () => sync(out.value); out.onfocus = () => out.select(); + attachVerticalStepDrag(out, () => Number(String(out.value).replace(",", ".")) || 0, sync, 0.1, { min: 0, max: 1 }); protectControlDrag(slider); protectControlDrag(out); wrap.append(slider, out); @@ -1922,7 +2958,7 @@ function shotboardWarnings(rows) { for (let i = 0; i < clean.length; i++) { const row = clean[i]; if (row.use_guide && Number(row.force || 0) === 0) warnings.push(`${row.label}: Guide is ON but force is 0, so this row will not anchor the image.`); - if (row.use_guide && row.force > 0.55) warnings.push(`${row.label}: high FLF force can pin the frame or create a still-slide. Use strong anchors only for start/end or deliberate locks.`); + if (row.use_guide && row.force > 0.70 && i > 0 && i < clean.length - 1) warnings.push(`${row.label}: force > 0.70 on a middle guide can pin the frame. Keep strong anchors for first/last unless you want a deliberate lock.`); if (relayIsOn(row) && !String(row.relay_prompt || "").trim()) warnings.push(`${row.label}: Relay is ON but Local prompt is empty, so this row will be skipped by PromptRelay.`); if (relayHasPrompt(row) && String(row.relay_prompt || "").length > 260) warnings.push(`${row.label}: local PromptRelay beat is long. Keep beat prompts concise and focused on motion/action.`); if (row.transition === "hard_cut") warnings.push(`${row.label}: hard cuts inside one generation usually morph. Use multigen + concat for a true editorial cut.`); @@ -1954,9 +2990,7 @@ function shotboardStatusTip(rows) { function defaultLiteRows() { return [ - { second: 0.0, ref: 1, force: 0.82, use_guide: true, use_prompt: false, label: "opening_anchor", camera: "slow push-in", transition: "continuous_motion", note: "Opening visual anchor. Keep the global prompt focused on one continuous motion path.", relay_prompt: "" }, - { second: 3.5, ref: 2, force: 0.24, use_guide: true, use_prompt: false, label: "middle_waypoint", camera: "continuous dolly-in", transition: "continuous_motion", note: "Soft waypoint. Lower force helps avoid a still-frame or slideshow feel.", relay_prompt: "" }, - { second: 7.0, ref: 3, force: 0.28, use_guide: true, use_prompt: false, label: "final_anchor", camera: "continuous dolly-in", transition: "continuous_motion", note: "Final visual target. Keep the ending guide inside the video duration.", relay_prompt: "" }, + { second: 0.0, ref: 1, force: 0.22, label: "ref_1", camera: "continuous dolly-in", transition: "continuous_motion", note: "", use_guide: true }, ]; } @@ -2006,6 +3040,9 @@ function renderShotboardLite(node) { "image_paths", "image_width", "image_height", + "image_resize_method", + "image_multiple_of", + "img_compression", ].forEach((name) => hideWidget(getWidget(node, name))); let rows = parseJsonWidget(node, defaultLiteRows).map(normalizeLiteRow); @@ -2052,18 +3089,21 @@ function renderShotboardLite(node) { }; const numberSetting = (label, name, step = "1", min = "0") => { const widget = getWidget(node, name); - addSetting(label, numberStepperControl(widget?.value ?? "", step, min, null, (value) => setWidgetValue(node, name, value))); + addSetting(label, numberStepperControl(widget?.value ?? "", step, min, null, (value) => { + setWidgetValue(node, name, value); + if (name === "duration_seconds" || name === "frame_rate") draw(); + })); }; const selectSetting = (label, name, options) => { const widget = getWidget(node, name); addSetting(label, makeSelect(String(widget?.value || options[0]), options, (value) => setWidgetValue(node, name, value))); }; - numberSetting("Duration", "duration_seconds", "0.1", "0.1"); + numberSetting("Duration", "duration_seconds", "1", "1"); numberSetting("FPS", "frame_rate", "1", "1"); selectSetting("Guide mode", "guide_policy", ["every_checked_row", "safe_core_guides"]); numberSetting("Max guides", "max_guides", "1", "1"); - numberSetting("Guide gap", "min_guide_gap_seconds", "0.05", "0"); - numberSetting("Default force", "default_force", "0.01", "0"); + numberSetting("Guide gap", "min_guide_gap_seconds", "0.1", "0"); + numberSetting("Default force", "default_force", "0.1", "0"); numberSetting("Ref width", "image_width", "32", "64"); numberSetting("Ref height", "image_height", "32", "64"); settingsPanel.appendChild(settingsGrid); @@ -2081,9 +3121,10 @@ function renderShotboardLite(node) { const addImagesBtn = liteButton("Add Images", "primary"); const importBoardBtn = liteButton("Import Board"); const saveBoardBtn = liteButton("Save Board"); + const savePackageBtn = liteButton("Save Package"); const importRefsBtn = liteButton("Import Refs"); const clearImagesBtn = liteButton("Clear Images", "danger"); - referencesActions.append(addImagesBtn, importBoardBtn, saveBoardBtn, importRefsBtn, clearImagesBtn); + referencesActions.append(addImagesBtn, importBoardBtn, saveBoardBtn, savePackageBtn, importRefsBtn, clearImagesBtn); referencesHead.append(referencesTitle, referencesActions); const referencesGrid = document.createElement("div"); referencesGrid.style.cssText = "display:flex;gap:8px;overflow-x:auto;padding-bottom:2px;min-height:74px;"; @@ -2106,9 +3147,9 @@ function renderShotboardLite(node) { function referenceSecond(index, total) { const duration = Number(getWidget(node, "duration_seconds")?.value || 0); - const safeDuration = Math.max(0, duration - 0.1); + const safeDuration = Math.max(0, duration - 1); if (total <= 1 || safeDuration <= 0) return 0; - return Math.round((safeDuration * (index / Math.max(1, total - 1))) * 10) / 10; + return Math.round(safeDuration * (index / Math.max(1, total - 1))); } function makeLiteReferenceRow(refNumber, total, seed = {}) { @@ -2223,57 +3264,75 @@ function renderShotboardLite(node) { nextPaths.splice(index + 1, 0, current[index]); setOwnReferencePaths(node, nextPaths); - const paired = rowsAreOneToOneWithReferences(current.length); - const normalizedRows = rows.map((row, rowIndex) => normalizeShotboardRow(row, rowIndex)); - const duration = Math.max(0, Number(getWidget(node, "duration_seconds")?.value || 0)); - const roundedSecond = (value) => Math.round(Math.max(0, Number(value) || 0) * 10) / 10; - const midpointSecond = (source, following) => { - const sourceSecond = Number(source?.second || 0); - const nextSecond = following ? Number(following.second || sourceSecond + 0.75) : Math.min(Math.max(0, duration - 0.1), sourceSecond + 0.75); - if (Number.isFinite(nextSecond) && nextSecond > sourceSecond) return roundedSecond((sourceSecond + nextSecond) / 2); - return roundedSecond(Math.min(Math.max(0, duration - 0.1), sourceSecond + 0.75)); - }; - const cloneFrom = (source, following, refNumber, rowIndex) => normalizeShotboardRow({ - ...source, - ref: refNumber, - second: midpointSecond(source, following), - label: `${String(source?.label || `ref_${Math.max(1, refNumber - 1)}`)}_hold`, - use_guide: true, - }, rowIndex); - - if (paired) { - const adjusted = normalizedRows.map((row, rowIndex) => rowIndex > index ? { ...row, ref: Number(row.ref || 1) + 1 } : row); - const source = adjusted[index] || makeReferenceRow(index + 1, nextPaths.length); - const clone = cloneFrom(source, adjusted[index + 1] || null, index + 2, index + 1); - adjusted.splice(index + 1, 0, clone); - rows = adjusted; - sync(); - } else { - const adjusted = normalizedRows.map((row) => { - const ref = Number(row.ref || 1); - return ref > index + 1 ? { ...row, ref: ref + 1 } : row; - }); - const sourceRowIndex = adjusted.findIndex((row) => Number(row.ref || 1) === index + 1); - const safeSourceIndex = sourceRowIndex >= 0 ? sourceRowIndex : Math.min(index, Math.max(0, adjusted.length - 1)); - const source = adjusted[safeSourceIndex] || makeReferenceRow(index + 1, nextPaths.length); - const insertAt = sourceRowIndex >= 0 ? sourceRowIndex + 1 : adjusted.length; - const clone = cloneFrom(source, adjusted[insertAt] || null, index + 2, insertAt); - adjusted.splice(insertAt, 0, clone); - rows = adjusted; - sync(); - } + const normalizedRows = rows.map((row, rowIndex) => normalizeLiteRow(row, rowIndex)).map((row) => { + const ref = Number(row.ref || 1); + return ref > index + 1 ? { ...row, ref: ref + 1 } : row; + }); + rows = insertDuplicateRowAfter( + normalizedRows, + index, + normalizeLiteRow, + () => makeLiteReferenceRow(index + 1, nextPaths.length), + index + 2 + ); + sync(); drawReferenceStrip(); draw(); } + function deleteLiteRowAndLinkedReference(rowIndex) { + const normalized = rows.map((row, index) => normalizeLiteRow(row, index)); + if (rowIndex < 0 || rowIndex >= normalized.length) return; + const paths = getConnectedReferencePaths(node); + const removedRef = Number(normalized[rowIndex]?.ref || rowIndex + 1); + const refUseCount = normalized.filter((row) => Number(row.ref || 1) === removedRef).length; + normalized.splice(rowIndex, 1); + rows = normalized; + + if (paths.length && removedRef >= 1 && removedRef <= paths.length && refUseCount <= 1) { + const nextPaths = paths.slice(); + nextPaths.splice(removedRef - 1, 1); + setOwnReferencePaths(node, nextPaths); + rows = rows.map((row, index) => { + const item = normalizeLiteRow(row, index); + const ref = Number(item.ref || 1); + return normalizeLiteRow({ + ...item, + ref: ref > removedRef ? ref - 1 : Math.min(ref, Math.max(1, nextPaths.length || 1)), + }, index); + }); + drawReferenceStrip(); + } + + if (!rows.length) rows = [makeLiteReferenceRow(1, Math.max(1, getConnectedReferencePaths(node).length || 1))]; + sync(); + draw(); + } + function drawReferenceStrip() { referencesGrid.innerHTML = ""; const paths = getConnectedReferencePaths(node); + const normalizedRows = rows.map((row, rowIndex) => normalizeLiteRow(row, rowIndex)); + const rowByReference = new Map(); + normalizedRows.forEach((row, rowIndex) => { + const ref = Math.max(1, Math.round(Number(row.ref || rowIndex + 1))) - 1; + if (!rowByReference.has(ref)) rowByReference.set(ref, row); + }); if (!paths.length) { - const empty = document.createElement("div"); - empty.textContent = "No internal images. Add Images or Import Board."; - empty.style.cssText = "color:#CBB17B;font-size:11px;padding:20px 4px;"; - referencesGrid.appendChild(empty); + const card = document.createElement("div"); + card.style.width = "104px"; + card.style.height = "58px"; + card.style.border = "1px dashed rgba(125, 211, 252, 0.45)"; + card.style.borderRadius = "4px"; + card.style.background = "rgba(3, 20, 31, 0.48)"; + card.style.display = "flex"; + card.style.alignItems = "flex-end"; + card.style.justifyContent = "flex-start"; + card.style.padding = "4px"; + card.style.color = "#9cc7dc"; + card.style.fontSize = "11px"; + card.textContent = "ref 1"; + referencesGrid.appendChild(card); return; } paths.forEach((path, index) => { @@ -2337,20 +3396,31 @@ function renderShotboardLite(node) { rows = rows.map(normalizeLiteRow).sort((a, b) => a.second - b.second); const paths = getConnectedReferencePaths(node); const activeGuides = rows.filter((row) => row.use_guide && Number(row.force || 0) > 0).length; - statusBar.textContent = `FLF-only: ${activeGuides} guide rows active. Notes are private and are not sent to PromptRelay.`; + const highForceMiddle = rows.map(normalizeLiteRow).filter((row, i, list) => i > 0 && i < list.length - 1 && row.use_guide && Number(row.force || 0) > 0.70); + statusBar.textContent = highForceMiddle.length + ? `Warnings: ${highForceMiddle.map((row) => `${row.label}: force > 0.70 on a middle guide`).join(" | ")}` + : `FLF-only: ${activeGuides} guide rows active. Notes are private and are not sent to PromptRelay.`; const header = document.createElement("div"); header.style.cssText = `display:grid;grid-template-columns:${SHOTBOARD_LITE_ROW_GRID};gap:8px;color:#D8BC80;font-size:10px;font-weight:700;padding:0 4px;`; - header.innerHTML = "
Time
Image Ref
Force
Guide
Label
Notes
"; + header.innerHTML = "
Time
Image Ref
Force / Lock
Guide
Label
Notes
"; table.appendChild(header); rows.forEach((row, index) => { const r = normalizeLiteRow(row, index); rows[index] = r; + const rowId = r._ui_id; const updateRow = (patch) => { - rows[index] = normalizeLiteRow({ ...rows[index], ...patch }, index); + const targetIndex = rows.findIndex((item) => item?._ui_id === rowId); + if (targetIndex < 0) return; + rows[targetIndex] = normalizeLiteRow({ ...rows[targetIndex], ...patch }, targetIndex); sync(); }; + const updateRowTime = (value) => { + rows = shiftFollowingRowsForTimeChange(rows, rowId, value, normalizeLiteRow, 0.1); + sync(); + draw(); + }; const card = document.createElement("div"); card.style.cssText = ` display:grid; @@ -2392,13 +3462,27 @@ function renderShotboardLite(node) { moveLiteRowAndLinkedReference(from, index); }; - const sec = timeControl(r.second, (value) => updateRow({ second: value })); + const liteFps = Number(getWidget(node, "frame_rate")?.value || 0); + const liteDuration = Number(getWidget(node, "duration_seconds")?.value || 0); + const sec = timeControl(r.second, updateRowTime, liteFps, { + nextSecond: rows[index + 1]?.second, + totalDuration: liteDuration, + }); const ref = refPicker(r.ref, paths, (value) => { updateRow({ ref: value }); draw(); }, { thumbWidth: 152, thumbHeight: 92, onReplace: (referenceIndex) => openReplaceReferencePicker(referenceIndex), }); - const force = forceControl(r.force, (value) => updateRow({ force: value })); + const force = forceControl(r.force, (value) => updateRow({ force: value, image_lock_strength: value })); + const forceLockCell = document.createElement("div"); + forceLockCell.style.cssText = "display:grid;grid-template-rows:auto auto;gap:6px;min-width:0;"; + const forceLabel = document.createElement("div"); + forceLabel.textContent = "Guide"; + forceLabel.style.cssText = "text-align:center;color:#D8BC80;font-size:9px;font-weight:800;"; + const forceGroup = document.createElement("div"); + forceGroup.style.cssText = "display:grid;gap:2px;"; + forceGroup.append(forceLabel, force); + forceLockCell.append(forceGroup); const guide = checkbox(r.use_guide, (value) => updateRow({ use_guide: value })); guide.title = "Use this row as an FLF image guide"; @@ -2425,13 +3509,11 @@ function renderShotboardLite(node) { statusBar.textContent = "At least one FLF row must remain."; return; } - rows.splice(index, 1); - sync(); - draw(); + deleteLiteRowAndLinkedReference(index); }; protectControlDrag(del); - card.append(handle, sec, ref, force, guide, label, notes, del); + card.append(handle, sec, ref, forceLockCell, guide, label, notes, del); table.appendChild(card); }); sync(); @@ -2466,20 +3548,27 @@ function renderShotboardLite(node) { replaceFileInput.value = ""; }; clearImagesBtn.onclick = () => { - setOwnReferencePaths(node, []); - rows = [makeLiteReferenceRow(1, 1)]; + pendingReplaceReferenceIndex = null; + fileInput.value = ""; + replaceFileInput.value = ""; + boardFileInput.value = ""; + clearOwnReferencePaths(node); + rows = [makeLiteReferenceRow(1, 1, { second: 0, ref: 1, label: "ref_1", note: "", relay_prompt: "", use_prompt: false })]; + sync(); drawReferenceStrip(); draw(); }; - saveBoardBtn.onclick = () => { + const collectLiteBoard = () => { syncLitePromptWidget(); const currentPrompt = String(promptArea.value || getWidget(node, "global_prompt")?.value || ""); - const board = { + return { metadata: { schema: "iamccs.cine.shotboard.lite.board", schema_version: 1, node: "IAMCCS_CineShotboardLite", promptrelay: "disabled", + cine_ui_version: CINE_VERSION, + saved_at: new Date().toISOString(), }, global_prompt: currentPrompt, prompt: currentPrompt, @@ -2494,12 +3583,33 @@ function renderShotboardLite(node) { image_paths: getConnectedReferencePaths(node), image_width: Number(getWidget(node, "image_width")?.value || 768), image_height: Number(getWidget(node, "image_height")?.value || 432), + images: getConnectedReferencePaths(node).map((path, index) => ({ + ref: index + 1, + path, + name: String(path).split(/[\\/]/).pop() || `ref_${index + 1}`, + })), }; + }; + saveBoardBtn.onclick = () => { + const board = collectLiteBoard(); downloadJsonFile(board, safeBoardFilename("iamccs_cine_shotboard_lite")); }; + savePackageBtn.onclick = async () => { + try { + savePackageBtn.disabled = true; + const board = collectLiteBoard(); + await saveShotboardPackageFolder(board, "iamccs_cine_shotboard_lite", (message) => { + statusBar.textContent = message; + }); + } catch (err) { + console.error("[IAMCCS Cine Shotboard Lite] package save failed", err); + statusBar.textContent = `Save Package failed: ${err?.message || err}`; + } finally { + savePackageBtn.disabled = false; + } + }; importBoardBtn.onclick = () => boardFileInput.click(); - boardFileInput.onchange = async (event) => { - const file = event.target.files?.[0]; + const importLiteBoardFile = async (file) => { if (!file) return; try { const raw = await readJsonFile(file); @@ -2519,7 +3629,10 @@ function renderShotboardLite(node) { if (boardValue("default_force") != null) setWidgetValue(node, "default_force", Number(boardValue("default_force"))); if (boardValue("image_width") != null) setWidgetValue(node, "image_width", Number(boardValue("image_width"))); if (boardValue("image_height") != null) setWidgetValue(node, "image_height", Number(boardValue("image_height"))); - const paths = Array.isArray(data.image_paths) ? data.image_paths : splitReferencePaths(data.image_paths); + if (boardValue("image_resize_method") != null) setWidgetValue(node, "image_resize_method", cineResizeMethodValue(boardValue("image_resize_method"))); + if (boardValue("image_multiple_of") != null) setWidgetValue(node, "image_multiple_of", Number(boardValue("image_multiple_of"))); + if (boardValue("img_compression") != null) setWidgetValue(node, "img_compression", Number(boardValue("img_compression"))); + const paths = packagedReferencePaths(data); if (paths.length) setOwnReferencePaths(node, paths); const importedRows = Array.isArray(data.rows) ? data.rows : parseShotboardTimelineString(String(data.timeline_data || "")); rows = (importedRows.length ? importedRows : defaultLiteRows()).map(normalizeLiteRow); @@ -2531,8 +3644,23 @@ function renderShotboardLite(node) { console.error("[IAMCCS Cine Shotboard Lite] board load failed", err); statusBar.textContent = `Board import failed: ${err?.message || err}`; } + }; + boardFileInput.onchange = async (event) => { + await importLiteBoardFile(event.target.files?.[0]); boardFileInput.value = ""; }; + const handleLiteBoardDrop = async (event) => { + const file = Array.from(event.dataTransfer?.files || []).find((item) => /\.json$/i.test(item.name || "")); + if (!file) return; + event.preventDefault(); + await importLiteBoardFile(file); + }; + [root, referencesPanel].forEach((target) => { + target.addEventListener("dragover", (event) => { + if (Array.from(event.dataTransfer?.items || []).some((item) => item.kind === "file")) event.preventDefault(); + }); + target.addEventListener("drop", handleLiteBoardDrop); + }); addBtn.onclick = () => { rows.push(makeLiteReferenceRow(rows.length + 1, Math.max(rows.length + 1, getConnectedReferencePaths(node).length || rows.length + 1), { @@ -2575,9 +3703,26 @@ function renderShotboardLite(node) { }; } +function disposeShotboardProWidget(node) { + const widget = node?._iamccsCineShotboardWidget; + const root = node?._iamccsCineShotboardRoot; + try { root?.remove?.(); } catch {} + try { widget?.element?.remove?.(); } catch {} + if (widget && Array.isArray(node?.widgets)) { + const idx = node.widgets.indexOf(widget); + if (idx >= 0) node.widgets.splice(idx, 1); + } + node._iamccsCineShotboardWidget = null; + node._iamccsCineShotboardRoot = null; + node._iamccsCineShotboardReady = false; + node._iamccsCineShotboardVersion = ""; +} + function renderShotboardPro(node) { - if (node._iamccsCineShotboardReady) return; + if (node._iamccsCineShotboardReady && node._iamccsCineShotboardVersion === CINE_VERSION) return; + disposeShotboardProWidget(node); node._iamccsCineShotboardReady = true; + node._iamccsCineShotboardVersion = CINE_VERSION; const shotboardV2 = isShotboardV2Node(node); const shotChrome = shotboardV2 ? CINE_NODE_CHROME.shotboardV2 : CINE_NODE_CHROME.shotboard; const shotButtonPalette = shotboardV2 @@ -2619,6 +3764,9 @@ function renderShotboardPro(node) { hideWidget(getWidget(node, "image_paths")); hideWidget(getWidget(node, "image_width")); hideWidget(getWidget(node, "image_height")); + hideWidget(getWidget(node, "image_resize_method")); + hideWidget(getWidget(node, "image_multiple_of")); + hideWidget(getWidget(node, "img_compression")); const initialSourceRows = parseJsonWidget(node, defaultShotboardRows); const initialHasCanonicalRelay = initialSourceRows.some(rowHasCanonicalRelayPrompt); @@ -2626,10 +3774,24 @@ function renderShotboardPro(node) { let rows = initialSourceRows.map((row, index) => normalizeShotboardRow(row, index, { useNoteAsRelayFallback: !initialHasCanonicalRelay && initialHasLegacyNotes, })); + if (shotboardV2) { + rows = rows.map((row) => { + const transition = String(row.transition || "continuous_motion"); + const activeTransition = transition && transition !== "continuous_motion" && transition !== "off"; + return { + ...row, + camera_relay_mode: "off", + transition_relay_mode: activeTransition ? "safe_only" : "off", + use_prompt: activeTransition ? true : row.use_prompt, + }; + }); + } const { root, toolbar, table } = tableShell( shotboardV2 ? "Cine Shotboard Timeline Pro V2" : "Cine Shotboard Timeline Pro", "" ); + root.dataset.iamccsCineVersion = CINE_VERSION; + node._iamccsCineShotboardRoot = root; root.style.borderColor = shotChrome.border; root.style.boxShadow = `inset 0 1px 0 ${shotChrome.glow}, 0 0 0 1px rgba(0,0,0,.35)`; root.style.maxHeight = "790px"; @@ -2669,6 +3831,9 @@ function renderShotboardPro(node) { "tail_safety_frames", "image_width", "image_height", + "image_resize_method", + "image_multiple_of", + "img_compression", ]; const addSetting = (labelText, control) => { const wrap = document.createElement("label"); @@ -2680,7 +3845,10 @@ function renderShotboardPro(node) { }; const numberSetting = (label, name, step = "1", min = "0") => { const widget = getWidget(node, name); - const control = numberStepperControl(widget?.value ?? "", step, min, null, (value) => setWidgetValue(node, name, value)); + const control = numberStepperControl(widget?.value ?? "", step, min, null, (value) => { + setWidgetValue(node, name, value); + if (name === "duration_seconds" || name === "frame_rate") draw(); + }); settingControls.set(name, control); addSetting(label, control); }; @@ -2735,16 +3903,19 @@ function renderShotboardPro(node) { draw(); }; - numberSetting("Duration", "duration_seconds", "0.1", "0.1"); + numberSetting("Duration", "duration_seconds", "1", "1"); numberSetting("FPS", "frame_rate", "1", "1"); selectSetting("Guide policy", "guide_policy", ["safe_core_guides", "prompt_only", "every_checked_row"], applyGuidePolicyToRows); numberSetting("Max guides", "max_guides", "1", "0"); - numberSetting("Guide gap", "min_guide_gap_seconds", "0.05", "0"); - numberSetting("Default force", "default_force", "0.01", "0"); - numberSetting("Relay softness", "promptrelay_epsilon", "0.01", "0"); + numberSetting("Guide gap", "min_guide_gap_seconds", "0.1", "0"); + numberSetting("Default force", "default_force", "0.1", "0"); + numberSetting("Relay softness", "promptrelay_epsilon", "0.1", "0"); selectSetting("LTX frames", "ltx_round_mode", ["up_8n_plus_1", "nearest_8n_plus_1", "none"]); numberSetting("Ref width", "image_width", "32", "64"); numberSetting("Ref height", "image_height", "32", "64"); + selectSetting("Resize", "image_resize_method", ["crop", "pad", "keep proportion", "stretch"]); + numberSetting("Multiple", "image_multiple_of", "1", "1"); + numberSetting("Compression", "img_compression", "1", "0"); addSetting("Editor zoom", editorZoomControl(root, node)); settingsPanel.appendChild(settingsGrid); root.insertBefore(settingsPanel, toolbar); @@ -2761,12 +3932,13 @@ function renderShotboardPro(node) { const addImagesBtn = shotButton("Add Images", "primary"); const importBoardBtn = shotButton("Import Board"); const saveBoardBtn = shotButton("Save Board"); + const savePackageBtn = shotButton("Save Package"); const importRefsBtn = shotButton("Import Refs"); const clearImagesBtn = shotButton("Clear Images", "danger"); - referencesActions.append(addImagesBtn, importBoardBtn, saveBoardBtn, importRefsBtn, clearImagesBtn); + referencesActions.append(addImagesBtn, importBoardBtn, saveBoardBtn, savePackageBtn, importRefsBtn, clearImagesBtn); referencesHead.append(referencesTitle, referencesActions); const referencesGrid = document.createElement("div"); - referencesGrid.style.cssText = "display:flex;gap:8px;overflow-x:auto;padding-bottom:2px;min-height:76px;"; + referencesGrid.style.cssText = `display:flex;gap:8px;overflow-x:auto;padding-bottom:2px;min-height:${shotboardV2 ? "104px" : "76px"};`; const fileInput = document.createElement("input"); fileInput.type = "file"; fileInput.multiple = true; @@ -2786,9 +3958,9 @@ function renderShotboardPro(node) { function referenceSecond(index, total) { const duration = Number(getWidget(node, "duration_seconds")?.value || 0); - const safeDuration = Math.max(0, duration - 0.1); + const safeDuration = Math.max(0, duration - 1); if (total <= 1 || safeDuration <= 0) return 0; - return Math.round((safeDuration * (index / Math.max(1, total - 1))) * 10) / 10; + return Math.round(safeDuration * (index / Math.max(1, total - 1))); } function makeReferenceRow(refNumber, total, seed = {}) { @@ -2956,69 +4128,147 @@ function renderShotboardPro(node) { nextPaths.splice(index + 1, 0, current[index]); setOwnReferencePaths(node, nextPaths); - const normalizedRows = rows.map((row, rowIndex) => normalizeShotboardRow(row, rowIndex)); - const paired = rowsAreOneToOneWithReferences(current.length); - const duration = Math.max(0, Number(getWidget(node, "duration_seconds")?.value || 0)); - const safeEnd = Math.max(0, duration - 0.1); - const roundedSecond = (value) => Math.round(Math.max(0, Number(value) || 0) * 10) / 10; - const midpointSecond = (source, following) => { - const sourceSecond = Number(source?.second || 0); - const nextSecond = following ? Number(following.second || sourceSecond + 0.75) : Math.min(safeEnd, sourceSecond + 0.75); - if (Number.isFinite(nextSecond) && nextSecond > sourceSecond) return roundedSecond((sourceSecond + nextSecond) / 2); - return roundedSecond(Math.min(safeEnd, sourceSecond + 0.75)); - }; - const duplicateLabel = (source, refNumber) => { - const base = String(source?.label || `ref_${Math.max(1, refNumber - 1)}`).replace(/_dup\d*$/i, ""); - return `${base}_dup`; - }; - const cloneFrom = (source, following, refNumber, rowIndex) => normalizeShotboardRow({ - ...source, - ref: refNumber, - second: midpointSecond(source, following), - label: duplicateLabel(source, refNumber), - use_guide: true, - }, rowIndex); - - if (paired) { - const adjusted = normalizedRows.map((row, rowIndex) => rowIndex > index ? { ...row, ref: Number(row.ref || 1) + 1 } : row); - const source = adjusted[index] || makeReferenceRow(index + 1, nextPaths.length); - const clone = cloneFrom(source, adjusted[index + 1] || null, index + 2, index + 1); - adjusted.splice(index + 1, 0, clone); - rows = adjusted; - } else { - const adjusted = normalizedRows.map((row) => { - const ref = Number(row.ref || 1); - return ref > index + 1 ? { ...row, ref: ref + 1 } : row; - }); - const hinted = Number.isFinite(Number(rowIndexHint)) ? Math.floor(Number(rowIndexHint)) : -1; - const hintedMatches = hinted >= 0 && hinted < adjusted.length && Number(adjusted[hinted]?.ref || 1) === index + 1; - const sourceRowIndex = hintedMatches ? hinted : adjusted.findIndex((row) => Number(row.ref || 1) === index + 1); - const safeSourceIndex = sourceRowIndex >= 0 ? sourceRowIndex : Math.min(index, Math.max(0, adjusted.length - 1)); - const source = adjusted[safeSourceIndex] || makeReferenceRow(index + 1, nextPaths.length); - const insertAt = safeSourceIndex >= 0 ? safeSourceIndex + 1 : adjusted.length; - const clone = cloneFrom(source, adjusted[insertAt] || null, index + 2, insertAt); - adjusted.splice(insertAt, 0, clone); - rows = adjusted.map((row, rowIndex) => normalizeShotboardRow(row, rowIndex)); - } + const normalizedRows = rows.map((row, rowIndex) => normalizeShotboardRow(row, rowIndex)).map((row) => { + const ref = Number(row.ref || 1); + return ref > index + 1 ? { ...row, ref: ref + 1 } : row; + }); + const hinted = Number.isFinite(Number(rowIndexHint)) ? Math.floor(Number(rowIndexHint)) : -1; + const sourceIndex = hinted >= 0 && hinted < normalizedRows.length && Number(normalizedRows[hinted]?.ref || 1) === index + 1 + ? hinted + : Math.max(0, normalizedRows.findIndex((row) => Number(row.ref || 1) === index + 1)); + rows = insertDuplicateRowAfter( + normalizedRows, + sourceIndex, + normalizeShotboardRow, + () => makeReferenceRow(index + 1, nextPaths.length), + index + 2 + ); sync(); drawReferenceStrip(); draw(); } + function deleteRowAndLinkedReference(rowIndex) { + const normalized = rows.map((row, index) => normalizeShotboardRow(row, index)); + if (rowIndex < 0 || rowIndex >= normalized.length) return; + const paths = getConnectedReferencePaths(node); + const removedRef = Number(normalized[rowIndex]?.ref || rowIndex + 1); + const refUseCount = normalized.filter((row) => Number(row.ref || 1) === removedRef).length; + normalized.splice(rowIndex, 1); + rows = normalized; + + if (paths.length && removedRef >= 1 && removedRef <= paths.length && refUseCount <= 1) { + const nextPaths = paths.slice(); + nextPaths.splice(removedRef - 1, 1); + setOwnReferencePaths(node, nextPaths); + rows = rows.map((row, index) => { + const item = normalizeShotboardRow(row, index); + const ref = Number(item.ref || 1); + return normalizeShotboardRow({ + ...item, + ref: ref > removedRef ? ref - 1 : Math.min(ref, Math.max(1, nextPaths.length || 1)), + }, index); + }); + drawReferenceStrip(); + } + + if (!rows.length) rows = [makeReferenceRow(1, Math.max(1, getConnectedReferencePaths(node).length || 1))]; + sync(); + draw(); + } + + function compactReferencesToUsedRows(referencePaths = getConnectedReferencePaths(node), options = {}) { + const paths = (Array.isArray(referencePaths) ? referencePaths : []).map((path) => String(path || "").trim()).filter(Boolean); + const normalized = rows.map((row, index) => normalizeShotboardRow(row, index)); + if (!paths.length || !normalized.length) { + if (options.applyToNode && !paths.length) clearOwnReferencePaths(node); + return { paths, rows: normalized, changed: false }; + } + + const oldRefToNewRef = new Map(); + const nextPaths = []; + for (const row of normalized) { + const oldRef = Math.round(Number(row.ref || 0)); + if (!Number.isFinite(oldRef) || oldRef < 1 || oldRef > paths.length) continue; + if (!oldRefToNewRef.has(oldRef)) { + oldRefToNewRef.set(oldRef, nextPaths.length + 1); + nextPaths.push(paths[oldRef - 1]); + } + } + if (!nextPaths.length) { + return { paths, rows: normalized, changed: false }; + } + + const nextRows = normalized.map((row, index) => { + const oldRef = Math.round(Number(row.ref || 0)); + const nextRef = oldRefToNewRef.get(oldRef); + return normalizeShotboardRow({ + ...row, + ref: nextRef || Math.min(Math.max(1, oldRef || 1), nextPaths.length), + }, index); + }); + const changed = nextPaths.length !== paths.length + || nextRows.some((row, index) => Number(row.ref || 1) !== Number(normalized[index]?.ref || 1)); + if (changed && options.applyToNode) { + rows = nextRows; + setOwnReferencePaths(node, nextPaths); + sync(); + drawReferenceStrip(); + } + return { paths: nextPaths, rows: nextRows, changed }; + } + function drawReferenceStrip() { referencesGrid.innerHTML = ""; const paths = getConnectedReferencePaths(node); + const normalizedRows = rows.map((row, rowIndex) => normalizeShotboardRow(row, rowIndex)); + const rowByReference = new Map(); + normalizedRows.forEach((row, rowIndex) => { + const ref = Math.max(1, Math.round(Number(row.ref || rowIndex + 1))) - 1; + if (!rowByReference.has(ref)) rowByReference.set(ref, { row, rowIndex }); + }); + const isRelayBridgeRowForStrip = (row) => { + if (!shotboardV2 || !row) return false; + const text = String(row.relay_prompt || row.note || "").trim(); + const transition = String(row.transition || "").trim(); + const guideOff = row.use_guide === false || Number(row.force || 0) <= 0; + return transition === "prompt_relay_text" && guideOff && Boolean(text); + }; + const relayBridgeAfterReference = (refIndex, sourceRowIndex) => { + if (!shotboardV2 || !Number.isFinite(Number(sourceRowIndex))) return null; + const sourceRef = Math.max(1, Number(refIndex || 0) + 1); + for (let i = Number(sourceRowIndex) + 1; i < normalizedRows.length; i += 1) { + const candidate = normalizedRows[i]; + if (!candidate) continue; + if (isRelayBridgeRowForStrip(candidate)) return { row: candidate, rowIndex: i }; + const candidateRef = Math.max(1, Math.round(Number(candidate.ref || 1))); + const hasImageGuide = candidate.use_guide !== false && Number(candidate.force || 0) > 0; + const isImageRow = String(candidate.transition || "") !== "prompt_relay_text" && hasImageGuide; + if (isImageRow && candidateRef !== sourceRef) break; + } + return null; + }; if (!paths.length) { - const empty = document.createElement("div"); - empty.textContent = "No internal images. Add Images or Import Board."; - empty.style.cssText = `color:${CINE_FILM_LAB.muted};font-size:11px;padding:20px 4px;`; - referencesGrid.appendChild(empty); + const card = document.createElement("div"); + card.style.width = shotboardV2 ? "152px" : "104px"; + card.style.height = shotboardV2 ? "88px" : "58px"; + card.style.border = "1px dashed rgba(214, 166, 90, 0.48)"; + card.style.borderRadius = "4px"; + card.style.background = "rgba(25, 17, 10, 0.58)"; + card.style.display = "flex"; + card.style.alignItems = "flex-end"; + card.style.justifyContent = "flex-start"; + card.style.padding = "4px"; + card.style.color = "#e8c98f"; + card.style.fontSize = "11px"; + card.textContent = "ref 1"; + referencesGrid.appendChild(card); return; } paths.forEach((path, index) => { const card = document.createElement("div"); - card.style.cssText = `position:relative;flex:0 0 ${shotboardV2 ? "152px" : "104px"};height:72px;border:1px solid ${CINE_FILM_LAB.borderSoft};background:${CINE_FILM_LAB.field};border-radius:5px;overflow:hidden;`; + card.style.cssText = `position:relative;flex:0 0 ${shotboardV2 ? "164px" : "104px"};height:${shotboardV2 ? "96px" : "72px"};border:1px solid ${CINE_FILM_LAB.borderSoft};background:${CINE_FILM_LAB.field};border-radius:5px;overflow:${shotboardV2 ? "visible" : "hidden"};`; card.draggable = true; card.ondragstart = (event) => { event.dataTransfer?.setData("text/iamccs-ref", String(index)); @@ -3054,7 +4304,7 @@ function renderShotboardPro(node) { `width:${stripeActionWidth}px`, "display:grid", "grid-template-columns:1fr", - "grid-template-rows:repeat(3,1fr)", + "grid-template-rows:repeat(4,1fr)", `border-right:1px solid ${CINE_FILM_LAB.borderSoft}`, "overflow:hidden", ].join(";"); @@ -3102,6 +4352,13 @@ function renderShotboardPro(node) { }), makeRailButton("D", "Duplicate this reference into the next slot and create a new keyframe", () => { duplicateReferenceAndLinkedRow(index); + }), + makeRailButton("G", "Grab/export a project-sized copy into IAMCCS_newimages", async () => { + try { + await grabShotboardReferenceProjectCopy(node, path); + } catch (err) { + console.error("[IAMCCS Cine Shotboard V2] grab reference failed", err); + } }) ); card.appendChild(stripeRail); @@ -3145,8 +4402,140 @@ function renderShotboardPro(node) { }) ); controls.append(...miniButtons); + const rowEntryForStripe = rowByReference.get(index) || { row: normalizedRows[index], rowIndex: index }; + const rowForStripe = rowEntryForStripe.row; + const rowIndexForStripe = Number.isFinite(Number(rowEntryForStripe.rowIndex)) ? Number(rowEntryForStripe.rowIndex) : index; + const classicTransition = String(rowForStripe?.transition || "continuous_motion"); + const relayTransitionMode = String(rowForStripe?.transition_relay_mode || "off"); + const hasClassicTransition = classicTransition && classicTransition !== "continuous_motion" && classicTransition !== "off"; + const hasRelayTransition = relayTransitionMode !== "off"; + const bridgeText = String(rowForStripe?.step_transition_prompt || rowForStripe?.note || ""); + const stripeRelayBridge = relayBridgeAfterReference(index, rowIndexForStripe); + const stripeRelayText = String(stripeRelayBridge?.row?.relay_prompt || stripeRelayBridge?.row?.note || "").trim(); + const stripeRelaySeconds = stripeRelayBridge + ? Math.max(0.1, Number(normalizedRows[(stripeRelayBridge.rowIndex || 0) + 1]?.second || 0) > Number(stripeRelayBridge.row.second || 0) + ? Number(normalizedRows[(stripeRelayBridge.rowIndex || 0) + 1].second || 0) - Number(stripeRelayBridge.row.second || 0) + : 0) + : 0; + const hasStepTransition = Boolean((rowForStripe?.step_transition_enabled || bridgeText.trim()) && String(rowForStripe?.step_transition_type || "off") !== "off"); + const hasStripeBridge = shotboardV2 && index < paths.length - 1; + const stripeBridgeActive = hasStepTransition || hasClassicTransition || hasRelayTransition || Boolean(bridgeText.trim()) || Boolean(stripeRelayText); + card.style.zIndex = hasStripeBridge ? "5" : "1"; + if (false && hasStripeBridge) { + const bridge = document.createElement("div"); + const bridgeLabel = hasStepTransition ? stepTransitionLabel(rowForStripe.step_transition_type) : (hasClassicTransition ? classicTransition.replace(/_/g, " ") : relayTransitionMode.replace(/_/g, " ")); + bridge.title = `${bridgeLabel} to next reference`; + bridge.innerHTML = ` + `; + bridge.style.cssText = "position:absolute;right:-48px;top:18px;width:76px;height:34px;display:flex;align-items:center;justify-content:center;z-index:30;pointer-events:none;filter:drop-shadow(0 2px 4px rgba(0,0,0,.52));"; + card.appendChild(bridge); + } card.append(img, badge, controls); referencesGrid.appendChild(card); + if (hasStripeBridge && stripeRelayBridge) { + const bridgeBlock = document.createElement("div"); + bridgeBlock.dataset.iamccsRole = "relay-bridge-placeholder"; + bridgeBlock.title = `Relay Bridge${stripeRelaySeconds ? ` ${stripeRelaySeconds.toFixed(2)}s` : ""}: ${stripeRelayText}`; + bridgeBlock.style.cssText = [ + "flex:0 0 236px", + "height:96px", + "border:1px solid rgba(143,208,204,.58)", + "border-radius:6px", + "background:linear-gradient(135deg, rgba(11,39,43,.96), rgba(34,35,30,.96))", + "box-shadow:inset 0 0 0 1px rgba(255,255,255,.05)", + "display:grid", + "grid-template-rows:22px minmax(0,1fr)", + "gap:4px", + "padding:5px", + "box-sizing:border-box", + "min-width:0", + "overflow:hidden", + ].join(";"); + const bridgeHead = document.createElement("div"); + bridgeHead.innerHTML = `Relay Bridge${stripeRelaySeconds ? `${stripeRelaySeconds.toFixed(1)}s` : "text"}`; + bridgeHead.style.cssText = "display:grid;grid-template-columns:1fr auto;align-items:center;gap:6px;border-radius:999px;background:rgba(41,132,142,.24);border:1px solid rgba(143,208,204,.48);color:#CFF2EE;font:8px/1 monospace;font-weight:900;letter-spacing:0;text-transform:uppercase;padding:0 7px;"; + const bridgePreview = document.createElement("div"); + bridgePreview.textContent = stripeRelayText || "Relay bridge text"; + bridgePreview.style.cssText = "min-width:0;overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;white-space:normal;border:1px solid rgba(244,239,230,.28);border-radius:5px;background:#F4EFE6;color:#15130F;font:10px/1.25 'Courier New',monospace;font-weight:700;padding:7px 8px;"; + bridgeBlock.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + const row = table?.children?.[Number(stripeRelayBridge.rowIndex || 0) + 1]; + try { row?.scrollIntoView?.({ block: "center", behavior: "smooth" }); } catch {} + }; + referencesGrid.appendChild(bridgeBlock); + bridgeBlock.append(bridgeHead, bridgePreview); + } + if (false && hasStripeBridge) { + const bridgeBlock = document.createElement("div"); + bridgeBlock.dataset.iamccsRole = "action-bridge-block"; + bridgeBlock.style.cssText = [ + "flex:0 0 236px", + "height:96px", + `border:1px solid ${stripeBridgeActive ? "rgba(223,164,81,.72)" : "rgba(118,103,83,.42)"}`, + "border-radius:6px", + `background:${stripeBridgeActive ? "linear-gradient(135deg, rgba(42,34,25,.95), rgba(22,38,41,.95))" : "linear-gradient(135deg, rgba(30,29,27,.92), rgba(22,26,27,.92))"}`, + "box-shadow:inset 0 0 0 1px rgba(255,255,255,.05)", + "display:grid", + "grid-template-rows:20px 30px minmax(34px,1fr)", + "gap:4px", + "padding:4px", + "box-sizing:border-box", + "min-width:0", + "overflow:hidden", + ].join(";"); + const bridgeHead = document.createElement("div"); + bridgeHead.innerHTML = `Actionnext`; + bridgeHead.style.cssText = "display:grid;grid-template-columns:1fr 54px 1fr;align-items:center;gap:5px;border-radius:999px;background:rgba(223,164,81,.16);border:1px solid rgba(223,164,81,.38);color:#F4E5C4;font:8px/1 monospace;font-weight:900;letter-spacing:0;text-transform:uppercase;"; + const bridgeLine = bridgeHead.querySelector("i"); + const bridgeArrow = bridgeHead.querySelector("b"); + if (bridgeLine) bridgeLine.style.cssText = `display:block;height:4px;flex:1;border-radius:999px;background:${stripeBridgeActive ? "#F0C247" : "rgba(244,229,196,.42)"};box-shadow:0 1px 2px rgba(0,0,0,.45);`; + if (bridgeArrow) bridgeArrow.style.cssText = `display:block;width:0;height:0;border-top:6px solid transparent;border-bottom:6px solid transparent;border-left:10px solid ${stripeBridgeActive ? "#F0C247" : "rgba(244,229,196,.42)"};filter:drop-shadow(0 1px 1px rgba(0,0,0,.45));`; + const bridgeSelect = makeChoiceSelect(rowForStripe.step_transition_type || (stripeBridgeActive ? "action_beat" : "off"), STEP_TRANSITION_OPTIONS, (value) => { + const source = normalizeShotboardRow(rows[rowIndexForStripe] || rowForStripe, rowIndexForStripe); + rows[rowIndexForStripe] = normalizeShotboardRow({ + ...source, + step_transition_type: value, + step_transition_enabled: value !== "off", + use_prompt: value !== "off" ? true : source.use_prompt, + step_transition_duration: value !== "off" && Number(source.step_transition_duration || 0) <= 0 + ? defaultStepTransitionSeconds(value, Math.max(0, Number(rows[rowIndexForStripe + 1]?.second || 0) - Number(source.second || 0))) + : source.step_transition_duration, + step_transition_arrival: value !== "off" ? (source.step_transition_arrival || defaultStepTransitionArrival(value)) : source.step_transition_arrival, + }, rowIndexForStripe); + sync(); + drawReferenceStrip(); + draw(); + }); + bridgeSelect.style.cssText += "height:30px;line-height:30px;padding-top:0;padding-bottom:0;font-size:10px;font-weight:900;text-align:center;"; + const bridgePrompt = document.createElement("textarea"); + bridgePrompt.value = bridgeText; + bridgePrompt.placeholder = "what happens before next ref..."; + bridgePrompt.rows = 1; + bridgePrompt.style.cssText = inputBase() + "resize:none;min-height:34px;height:34px;font-size:9px;line-height:1.18;padding:5px 7px;background:#F4EFE6;color:#111;"; + bridgePrompt.oninput = () => { + const source = normalizeShotboardRow(rows[rowIndexForStripe] || rowForStripe, rowIndexForStripe); + const hasText = Boolean(String(bridgePrompt.value || "").trim()); + rows[rowIndexForStripe] = normalizeShotboardRow({ + ...source, + note: bridgePrompt.value, + step_transition_prompt: bridgePrompt.value, + step_transition_enabled: hasText || source.step_transition_enabled, + step_transition_type: hasText && source.step_transition_type === "off" ? "action_beat" : source.step_transition_type, + use_prompt: hasText || source.use_prompt, + }, rowIndexForStripe); + sync(); + }; + protectControlDrag(bridgeSelect); + protectControlDrag(bridgePrompt); + bridgeBlock.append(bridgeHead, bridgeSelect, bridgePrompt); + referencesGrid.appendChild(bridgeBlock); + } }); } @@ -3191,7 +4580,9 @@ function renderShotboardPro(node) { const collectBoard = () => { syncProPromptWidget(); sync(); - const imagePaths = getConnectedReferencePaths(node); + const compact = compactReferencesToUsedRows(getConnectedReferencePaths(node), { applyToNode: true }); + const imagePaths = compact.paths; + const boardRows = compact.rows; const settings = {}; for (const name of settingNames) { settings[name] = getWidget(node, name)?.value ?? null; @@ -3208,7 +4599,7 @@ function renderShotboardPro(node) { global_prompt: String(promptArea.value || getWidget(node, "global_prompt")?.value || ""), prompt: String(promptArea.value || getWidget(node, "global_prompt")?.value || ""), timeline_data: String(getWidget(node, "timeline_data")?.value || ""), - rows: rows.map(normalizeShotboardRow), + rows: boardRows, settings, duration_seconds: settings.duration_seconds, frame_rate: settings.frame_rate, @@ -3239,10 +4630,11 @@ function renderShotboardPro(node) { } } }; - const applyBoard = (data) => { + const applyBoard = async (data) => { const workflowBoard = boardFromWorkflowJson(data); const nestedBoard = data?.board && typeof data.board === "object" ? data.board : null; const board = workflowBoard || nestedBoard || data || {}; + if ((!Array.isArray(board.images) || !board.images.length) && Array.isArray(data?.images)) board.images = data.images; const metadata = board.metadata || data?.metadata || {}; const settings = { ...(board.settings || {}) }; for (const name of settingNames) { @@ -3260,12 +4652,17 @@ function renderShotboardPro(node) { setWidgetValue(node, name, settings[name]); } } - const refs = Array.isArray(board.image_paths) - ? board.image_paths - : Array.isArray(board.images) - ? board.images.map((item) => item?.path || item?.filename || item?.name).filter(Boolean) - : []; - if (refs.length) setOwnReferencePaths(node, refs); + const refs = await packagedReferencePathsForImport(board, (message) => { + if (warnText) { + warnText.textContent = message; + warnText.title = message; + } + }); + if (refs.length) { + setOwnReferencePaths(node, refs); + } else { + clearOwnReferencePaths(node); + } const rowCandidates = []; if (Array.isArray(board.rows)) rowCandidates.push({ source: "board.rows", rows: board.rows }); @@ -3293,6 +4690,9 @@ function renderShotboardPro(node) { const schemaVersion = Number(metadata.schema_version || board.schema_version || 0); const useNoteAsRelayFallback = false; rows = loadedRows.map((row, index) => normalizeShotboardRow(row, index, { useNoteAsRelayFallback })); + if (refs.length) { + compactReferencesToUsedRows(refs, { applyToNode: true }); + } writeShotboard(node, rows); const localCount = rows.filter((row) => String(row.relay_prompt || "").trim()).length; console.info("[IAMCCS Cine Shotboard] board imported", { @@ -3318,12 +4718,33 @@ function renderShotboardPro(node) { const firstLabel = rows[0]?.label || "cine_shotboard"; downloadJsonFile(board, safeBoardFilename(firstLabel)); }; + savePackageBtn.onclick = async () => { + try { + savePackageBtn.disabled = true; + const board = collectBoard(); + const firstLabel = rows[0]?.label || "cine_shotboard"; + await saveShotboardPackageFolder(board, firstLabel, (message) => { + if (warnText) { + warnText.textContent = message; + warnText.title = message; + } + }); + } catch (err) { + console.error("[IAMCCS Cine Shotboard] package save failed", err); + if (warnText) { + warnText.textContent = `Save Package failed: ${err?.message || err}`; + warnText.title = warnText.textContent; + } + } finally { + savePackageBtn.disabled = false; + } + }; boardFileInput.onchange = async (event) => { const file = event.target.files?.[0]; if (!file) return; try { const data = await readJsonFile(file); - applyBoard(data); + await applyBoard(data); } catch (err) { console.error("[IAMCCS Cine Shotboard] board load failed", err); warnText.textContent = `Board load failed: ${err?.message || err}`; @@ -3331,15 +4752,41 @@ function renderShotboardPro(node) { boardFileInput.value = ""; } }; + const handleBoardDrop = async (event) => { + const file = Array.from(event.dataTransfer?.files || []).find((item) => /\.json$/i.test(item.name || "")); + if (!file) return; + event.preventDefault(); + try { + const data = await readJsonFile(file); + await applyBoard(data); + } catch (err) { + console.error("[IAMCCS Cine Shotboard] board drop import failed", err); + warnText.textContent = `Board drop import failed: ${err?.message || err}`; + } + }; + [root, referencesPanel].forEach((target) => { + target.addEventListener("dragover", (event) => { + if (Array.from(event.dataTransfer?.items || []).some((item) => item.kind === "file")) event.preventDefault(); + }); + target.addEventListener("drop", handleBoardDrop); + }); clearImagesBtn.onclick = () => { - setOwnReferencePaths(node, []); - if (!rows.length) rows = [makeReferenceRow(1, 1)]; + pendingReplaceReferenceIndex = null; + fileInput.value = ""; + replaceFileInput.value = ""; + boardFileInput.value = ""; + clearOwnReferencePaths(node); + rows = [makeReferenceRow(1, 1, { second: 0, ref: 1, label: "ref_1", note: "", relay_prompt: "", use_prompt: false })]; sync(); drawReferenceStrip(); draw(); }; const addBtn = shotButton("Add Row", "primary"); + const addTextSlotBtn = shotButton(shotboardV2 ? "Add Relay Bridge" : "Add Text Slot", "primary"); + addTextSlotBtn.title = shotboardV2 + ? "Add a dedicated relay bridge row: no image guide, only timed PromptRelay text between two frames." + : "Add a prompt-only row: no image guide, only a timed PromptRelay text beat."; const presetSafe = shotButton("Continuous Template"); presetSafe.title = "Create a generic continuous FLF board with a few soft image anchors and prompt-only motion rows."; const promptOnly = shotButton("Prompt-Only Mode"); @@ -3356,7 +4803,8 @@ function renderShotboardPro(node) { const dialogueBtn = shotButton("Shot/Reverse Template"); dialogueBtn.title = "Create a generic shot/reverse-shot board. Use multigen for true editorial cuts."; const clearBtn = shotButton("Clear", "danger"); - toolbar.append(addBtn, presetSafe, promptOnly, smoothBtn, coreBtn, thumbsBtn, bakeRelayBtn, openEditorBtn, dialogueBtn, clearBtn); + toolbar.append(addBtn, addTextSlotBtn, presetSafe, promptOnly, smoothBtn, coreBtn, thumbsBtn, bakeRelayBtn, openEditorBtn); + toolbar.append(dialogueBtn, clearBtn); const warnBox = document.createElement("div"); warnBox.style.cssText = `margin-bottom:7px;padding:7px 9px;border:1px solid ${CINE_FILM_LAB.border};background:${CINE_FILM_LAB.panelDark};color:${CINE_FILM_LAB.text};font-size:11px;line-height:1.35;border-radius:5px;min-height:30px;display:flex;align-items:center;gap:10px;box-sizing:border-box;width:100%;`; @@ -3381,12 +4829,14 @@ function renderShotboardPro(node) { const normalized = rows.map(normalizeShotboardRow); const allOn = normalized.length > 0 && normalized.every((row) => row.use_guide !== false); rows = normalized.map((row) => ({ ...row, use_guide: !allOn })); + sync(); draw(); }); const relayBulkToggle = makeBulkToggle("Relay", "Toggle the Relay column for every row", () => { const normalized = rows.map(normalizeShotboardRow); const allOn = normalized.length > 0 && normalized.every((row) => row.use_prompt !== false && String(row.use_prompt).toLowerCase() !== "false"); rows = normalized.map((row) => ({ ...row, use_prompt: !allOn })); + sync(); draw(); }); warnControls.append(guideBulkToggle, relayBulkToggle); @@ -3416,20 +4866,69 @@ function renderShotboardPro(node) { warnText.title = warnText.textContent; } + function insertRelayBridgeAfterRow(sourceIndex) { + const normalized = rows.map(normalizeShotboardRow).sort((a, b) => Number(a.second || 0) - Number(b.second || 0)); + const source = normalized[sourceIndex]; + if (!source) return; + const next = normalized[sourceIndex + 1] || null; + const sourceSecond = Math.max(0, Number(source.second || 0)); + const nextSecond = next ? Math.max(sourceSecond + 0.1, Number(next.second || sourceSecond + 2.5)) : sourceSecond + 2.5; + const gap = Math.max(0.2, nextSecond - sourceSecond); + const bridgeSeconds = next + ? Math.max(0.25, Math.min(1.5, gap * 0.4)) + : Math.max(0.5, Math.min(1.5, gap)); + let bridgeStart = next ? Number((nextSecond - bridgeSeconds).toFixed(3)) : Number((sourceSecond + gap).toFixed(3)); + if (next && bridgeStart <= sourceSecond + 0.05) { + bridgeStart = Number((sourceSecond + Math.max(0.1, gap * 0.5)).toFixed(3)); + } + const seedText = String(source.step_transition_prompt || source.note || "").trim(); + normalized.splice(sourceIndex + 1, 0, normalizeShotboardRow({ + second: bridgeStart, + ref: Math.max(1, Number(source.ref || 1)), + force: 0, + image_lock_strength: 0, + use_guide: false, + use_prompt: true, + label: `relay_${sourceIndex + 1}_to_${sourceIndex + 2}`, + camera: "prompt relay text", + transition: "prompt_relay_text", + camera_relay_mode: "off", + transition_relay_mode: "off", + relay_addon_position: "after", + note: seedText || "continue the same forward motion toward the next frame", + relay_prompt: seedText || "continue the same forward motion toward the next frame", + step_transition_enabled: false, + step_transition_type: "off", + step_transition_prompt: "", + }, sourceIndex + 1)); + rows = normalized.map((row, index) => normalizeShotboardRow(row, index)); + sync(); + drawReferenceStrip(); + draw(); + } + function draw() { table.innerHTML = ""; + const rowGrid = shotboardV2 ? SHOTBOARD_ROW_GRID_V2 : SHOTBOARD_ROW_GRID; const header = document.createElement("div"); - header.style.cssText = `display:grid;grid-template-columns:${SHOTBOARD_ROW_GRID};gap:8px;color:${CINE_FILM_LAB.muted};font-size:11px;font-weight:600;padding:0 6px;box-sizing:border-box;width:100%;max-width:100%;min-width:0;overflow:hidden;`; - header.innerHTML = "
Time
Image Ref
Force / Notes
Guide
Relay
Shot controls
Local prompt
"; + header.style.cssText = `display:grid;grid-template-columns:${rowGrid};gap:8px;color:${CINE_FILM_LAB.muted};font-size:11px;font-weight:600;padding:0 6px;box-sizing:border-box;width:100%;max-width:100%;min-width:0;overflow:hidden;`; + header.innerHTML = `
Time
Image Ref
${shotboardV2 ? "Guide force" : "Force / Notes"}
Guide
Relay
Shot controls
Local prompt
`; table.appendChild(header); const referencePaths = getConnectedReferencePaths(node); const rowCount = Math.max(1, rows.length); const expandedRows = rowCount <= 2; - const rowThumbHeight = rowCount === 1 ? 190 : rowCount === 2 ? 156 : 116; - const rowThumbWidth = rowCount === 1 ? 238 : rowCount === 2 ? 206 : 168; - const notesMinHeight = rowCount === 1 ? 132 : rowCount === 2 ? 104 : 68; - const localPromptMinHeight = rowCount === 1 ? 214 : rowCount === 2 ? 170 : 128; + const rowThumbHeight = 96; + const rowThumbWidth = 150; + const notesMinHeight = rowCount === 1 ? 96 : rowCount === 2 ? 84 : 72; + const localPromptMinHeight = rowCount === 1 ? 104 : rowCount === 2 ? 96 : 88; + const isV2RelayBridgeRow = (row) => { + if (!shotboardV2) return false; + const transition = String(row.transition || "").trim(); + const hasRelayText = Boolean(String(row.relay_prompt || row.note || "").trim()); + const guideOff = row.use_guide === false || Number(row.force || 0) <= 0; + return transition === "prompt_relay_text" && guideOff && hasRelayText; + }; rows.forEach((row, index) => { const r = normalizeShotboardRow(row, index); rows[index] = r; @@ -3440,10 +4939,15 @@ function renderShotboardPro(node) { Object.assign(target, patch); sync(); }; + const updateRowTime = (value) => { + rows = shiftFollowingRowsForTimeChange(rows, rowId, value, normalizeShotboardRow, 0.1); + sync(); + draw(); + }; const card = document.createElement("div"); card.style.cssText = ` display:grid; - grid-template-columns:${SHOTBOARD_ROW_GRID}; + grid-template-columns:${rowGrid}; gap:8px; align-items:start; padding:9px 6px; @@ -3511,7 +5015,146 @@ function renderShotboardPro(node) { handle.style.cursor = "grab"; }; protectDragHandle(handle); - const sec = timeControl(r.second, (value) => updateRow({ second: value })); + const proFps = Number(getWidget(node, "frame_rate")?.value || 0); + const proDuration = Number(getWidget(node, "duration_seconds")?.value || 0); + const updateRowDuration = (value) => { + const nextDuration = Math.max(0.1, Number(value) || 0.1); + const list = rows.map((item, itemIndex) => normalizeShotboardRow(item, itemIndex)).sort((a, b) => Number(a.second || 0) - Number(b.second || 0)); + const targetIndex = list.findIndex((item) => item?._ui_id === rowId); + if (targetIndex < 0) return; + const start = Math.max(0, Number(list[targetIndex].second || 0)); + const oldEnd = targetIndex < list.length - 1 + ? Math.max(start + 0.1, Number(list[targetIndex + 1].second || 0)) + : Math.max(start + 0.1, Number(getWidget(node, "duration_seconds")?.value || proDuration || start + nextDuration)); + const nextEnd = Number((start + nextDuration).toFixed(3)); + const delta = Number((nextEnd - oldEnd).toFixed(3)); + if (targetIndex < list.length - 1) { + for (let i = targetIndex + 1; i < list.length; i += 1) { + list[i] = normalizeShotboardRow({ + ...list[i], + second: Number((Number(list[i].second || 0) + delta).toFixed(3)), + }, i); + } + const durationWidget = getWidget(node, "duration_seconds"); + if (durationWidget && Math.abs(delta) > 0.0005) { + const oldDuration = Number(durationWidget.value || proDuration || 0); + const lastStart = Math.max(nextEnd, ...list.map((item) => Number(item.second || 0))); + const nextTotal = Math.max(lastStart + 0.1, oldDuration + delta); + setWidgetValue(node, "duration_seconds", Number(nextTotal.toFixed(3))); + } + } else { + const durationWidget = getWidget(node, "duration_seconds"); + if (durationWidget) setWidgetValue(node, "duration_seconds", Number(nextEnd.toFixed(3))); + } + rows = list; + sync(); + draw(); + }; + const sec = timeControl(r.second, updateRowTime, proFps, { + nextSecond: rows[index + 1]?.second, + totalDuration: proDuration, + liveInput: false, + }); + const segmentEnd = Number.isFinite(Number(rows[index + 1]?.second)) ? Number(rows[index + 1].second) : proDuration; + const segmentSeconds = Math.max(0.1, (Number.isFinite(segmentEnd) && segmentEnd > Number(r.second || 0) ? segmentEnd : Number(r.second || 0) + 0.1) - Number(r.second || 0)); + const lenControl = numberStepperControl(segmentSeconds, "0.1", "0.1", null, updateRowDuration, { liveInput: false }); + lenControl.title = "Set this shot/box duration in seconds. Following rows ripple to preserve their spacing."; + lenControl.style.cssText += "min-width:0;grid-template-columns:20px minmax(42px,1fr) 20px;gap:3px;"; + lenControl.querySelectorAll("button").forEach((button) => { + button.style.width = "20px"; + button.style.minWidth = "20px"; + button.style.height = "24px"; + }); + const timeCell = document.createElement("div"); + timeCell.style.cssText = "display:flex;flex-direction:column;gap:6px;min-width:0;"; + const lenWrap = document.createElement("label"); + lenWrap.style.cssText = "display:flex;flex-direction:column;gap:3px;color:#9fb9c7;font-size:9px;font-weight:800;min-width:0;"; + const lenLabel = document.createElement("span"); + lenLabel.textContent = "Len seconds"; + lenLabel.style.cssText = "text-align:center;line-height:1;"; + lenWrap.append(lenLabel, lenControl); + timeCell.append(sec, lenWrap); + + if (isV2RelayBridgeRow(r)) { + card.style.cssText = ` + display:grid; + grid-template-columns:24px minmax(108px,128px) minmax(170px,220px) minmax(0,1fr) 46px 28px; + gap:8px; + align-items:stretch; + padding:8px 8px 8px 34px; + margin-left:28px; + border:1px solid rgba(143,208,204,.46); + background:linear-gradient(180deg,rgba(12,31,34,.92),rgba(34,36,33,.92)); + border-radius:6px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 4px 10px rgba(0,0,0,.12); + box-sizing:border-box; + width:calc(100% - 28px); + max-width:calc(100% - 28px); + min-width:0; + overflow:hidden; + `; + const bridgeMeta = document.createElement("div"); + bridgeMeta.style.cssText = "display:flex;flex-direction:column;gap:5px;justify-content:center;min-width:0;"; + const bridgeTitle = document.createElement("div"); + bridgeTitle.textContent = "Relay Bridge"; + bridgeTitle.style.cssText = "display:flex;align-items:center;justify-content:center;height:20px;border-radius:999px;border:1px solid rgba(143,208,204,.58);background:rgba(41,132,142,.22);color:#CFF2EE;font-size:8px;font-weight:900;text-transform:uppercase;"; + const bridgeLabel = document.createElement("input"); + bridgeLabel.value = r.label || `relay_${index + 1}`; + bridgeLabel.title = "Relay bridge label"; + bridgeLabel.style.cssText = inputBase() + "height:24px;text-align:center;font-size:10px;font-weight:800;"; + bridgeLabel.oninput = () => updateRow({ label: bridgeLabel.value || `relay_${index + 1}` }); + protectControlDrag(bridgeLabel); + bridgeMeta.append(bridgeTitle, bridgeLabel); + + const bridgePrompt = document.createElement("textarea"); + bridgePrompt.value = r.relay_prompt || r.note || ""; + bridgePrompt.placeholder = "PromptRelay bridge between surrounding frames..."; + bridgePrompt.title = "This text is the actual PromptRelay local prompt for this bridge row."; + bridgePrompt.style.cssText = inputBase() + "resize:vertical;min-height:62px;line-height:1.32;font-size:12px;padding:8px 16px 8px 10px;scrollbar-gutter:stable;"; + bridgePrompt.oninput = () => { + const text = bridgePrompt.value; + updateRow({ + relay_prompt: text, + note: text, + transition: "prompt_relay_text", + force: 0, + image_lock_strength: 0, + use_guide: false, + use_prompt: Boolean(String(text || "").trim()), + }); + drawReferenceStrip(); + }; + protectControlDrag(bridgePrompt); + + const bridgeRelay = checkbox(r.use_prompt !== false, (value) => updateRow({ use_prompt: value })); + bridgeRelay.title = "Use this relay bridge as a PromptRelay segment"; + const relayWrap = document.createElement("label"); + relayWrap.style.cssText = "display:flex;flex-direction:column;gap:6px;align-items:center;justify-content:center;color:#9fb9c7;font-size:9px;font-weight:900;text-transform:uppercase;"; + const relayText = document.createElement("span"); + relayText.textContent = "Relay"; + relayWrap.append(relayText, bridgeRelay); + + const removeBridge = shotButton("x", "danger"); + removeBridge.style.width = "26px"; + removeBridge.style.height = "30px"; + removeBridge.style.padding = "0"; + removeBridge.style.alignSelf = "center"; + removeBridge.onclick = () => { + if (rows.length <= 1) { + if (warnText) warnText.textContent = "At least one shot row must remain."; + return; + } + rows.splice(index, 1); + sync(); + drawReferenceStrip(); + draw(); + }; + protectControlDrag(removeBridge); + + card.append(handle, timeCell, bridgeMeta, bridgePrompt, relayWrap, removeBridge); + table.appendChild(card); + return; + } const ref = refPicker(r.ref, referencePaths, (value) => { updateRow({ ref: value }); draw(); }, { thumbWidth: rowThumbWidth, @@ -3529,8 +5172,15 @@ function renderShotboardPro(node) { onDuplicate: shotboardV2 ? (referenceIndex) => { duplicateReferenceAndLinkedRow(referenceIndex, index); } : null, + onGrab: shotboardV2 ? async (_referenceIndex, path) => { + try { + await grabShotboardReferenceProjectCopy(node, path); + } catch (err) { + console.error("[IAMCCS Cine Shotboard V2] grab reference failed", err); + } + } : null, }); - const force = forceControl(r.force, (value) => updateRow({ force: value })); + const force = forceControl(r.force, (value) => updateRow({ force: value, image_lock_strength: value })); const guide = checkbox(r.use_guide, (value) => updateRow({ use_guide: value })); guide.title = "Use as FLF image guide"; const relay = checkbox(r.use_prompt, (value) => updateRow({ use_prompt: value })); @@ -3542,20 +5192,70 @@ function renderShotboardPro(node) { protectControlDrag(label); const camera = makeSelect(r.camera, CAMERA_OPTIONS, (value) => { updateRow({ camera: value }); }); - const transition = makeSelect(r.transition, TRANSITION_OPTIONS, (value) => { updateRow({ transition: value }); }); + const transition = makeSelect(r.transition, TRANSITION_OPTIONS, (value) => { + const activeTransition = value && value !== "continuous_motion" && value !== "off"; + updateRow({ + transition: value, + use_prompt: activeTransition ? true : r.use_prompt, + transition_relay_mode: activeTransition ? "safe_only" : "off", + }); + drawReferenceStrip(); + }); + const stepSelect = makeChoiceSelect(r.step_transition_type, STEP_TRANSITION_OPTIONS, (value) => { + const nextPatch = { + step_transition_type: value, + step_transition_enabled: value !== "off", + use_prompt: value !== "off" ? true : r.use_prompt, + }; + if (value !== "off" && Number(r.step_transition_duration || 0) <= 0) { + const nextSecond = Number(rows[index + 1]?.second ?? 0); + const gap = nextSecond > Number(r.second || 0) ? nextSecond - Number(r.second || 0) : 0; + nextPatch.step_transition_duration = defaultStepTransitionSeconds(value, gap); + nextPatch.step_transition_auto_fit = true; + nextPatch.step_transition_arrival = defaultStepTransitionArrival(value); + } + updateRow({ + ...nextPatch, + }); + drawReferenceStrip(); + draw(); + }); + stepSelect.title = shotboardV2 ? "Relay Bridge timing/motion instruction from this row to the next row" : "Step Transition: cinematic instruction from this row to the next row"; const forceNotesCell = document.createElement("div"); forceNotesCell.style.cssText = "display:flex;flex-direction:column;gap:8px;min-width:0;"; - forceNotesCell.appendChild(force); + const forceWrap = document.createElement("label"); + forceWrap.style.cssText = "display:grid;gap:4px;color:#b6cac3;font-size:10px;font-weight:800;"; + const forceLabel = document.createElement("span"); + forceLabel.textContent = "Guide Strength"; + forceLabel.style.textAlign = "center"; + forceWrap.append(forceLabel, force); + forceNotesCell.append(forceWrap); const notesBox = document.createElement("textarea"); - notesBox.value = r.note || ""; + notesBox.value = shotboardV2 ? (r.step_transition_prompt || r.note || "") : (r.note || ""); notesBox.rows = 3; - notesBox.placeholder = "Notes"; - notesBox.title = "Private shot notes. Notes are never sent to PromptRelay."; + notesBox.placeholder = shotboardV2 ? "Relay Bridge to next frame" : "Notes"; + notesBox.title = shotboardV2 + ? "Relay Bridge text: what happens between this row/reference and the next. This is sent to PromptRelay when text is present." + : "Private shot notes. Notes are never sent to PromptRelay."; notesBox.style.cssText = inputBase() + `resize:vertical;min-height:${notesMinHeight}px;line-height:1.32;font-size:11px;padding:8px 15px 8px 9px;scrollbar-gutter:stable;`; - notesBox.oninput = () => { updateRow({ note: notesBox.value }); }; + notesBox.oninput = () => { + if (shotboardV2) { + const hasText = Boolean(String(notesBox.value || "").trim()); + updateRow({ + note: notesBox.value, + step_transition_prompt: notesBox.value, + step_transition_enabled: hasText ? true : r.step_transition_enabled, + step_transition_type: hasText && String(r.step_transition_type || "off") === "off" ? "action_beat" : r.step_transition_type, + use_prompt: hasText ? true : r.use_prompt, + }); + drawReferenceStrip(); + return; + } + updateRow({ note: notesBox.value }); + }; protectControlDrag(notesBox); - forceNotesCell.appendChild(notesBox); + if (!shotboardV2) forceNotesCell.appendChild(notesBox); const promptCell = document.createElement("div"); promptCell.style.cssText = "display:flex;flex-direction:column;gap:5px;min-width:0;height:100%;"; @@ -3571,12 +5271,21 @@ function renderShotboardPro(node) { const current = normalizeShotboardRow({ ...r, ...rows[index] }, index); const normalizedRows = rows.map(normalizeShotboardRow); const nextRow = normalizedRows[index + 1] || null; - updateRow({ - relay_prompt: composeRelayPromptPreview(current, nextRow), + const transitionValue = String(current.transition || "continuous_motion"); + const bakeSource = normalizeShotboardRow({ + ...current, camera_relay_mode: "off", - transition_relay_mode: "off", + transition_relay_mode: transitionValue !== "continuous_motion" && transitionValue !== "off" ? "safe_only" : "off", + }, index); + const bakedPrompt = composeRelayPromptPreview(bakeSource, nextRow); + updateRow({ + relay_prompt: bakedPrompt, + use_prompt: Boolean(String(bakedPrompt || "").trim()), + camera_relay_mode: "off", + transition_relay_mode: transitionValue !== "continuous_motion" && transitionValue !== "off" ? "safe_only" : "off", relay_modifier_text: "", }); + drawReferenceStrip(); draw(); }; protectControlDrag(applyRowControls); @@ -3587,51 +5296,144 @@ function renderShotboardPro(node) { localPrompt.placeholder = "Local PromptRelay beat"; localPrompt.title = "Only this text becomes one PromptRelay local prompt segment when Relay is checked. Notes and labels are not sent."; localPrompt.style.cssText = inputBase() + `resize:vertical;min-height:${localPromptMinHeight}px;line-height:1.35;font-size:${expandedRows ? "13px" : "12px"};padding:10px 20px 10px 10px;scrollbar-gutter:stable;`; - localPrompt.oninput = () => { updateRow({ relay_prompt: localPrompt.value }); }; + localPrompt.oninput = () => { + updateRow({ + relay_prompt: localPrompt.value, + use_prompt: Boolean(String(localPrompt.value || "").trim()), + }); + }; protectControlDrag(localPrompt); promptCell.append(promptActions, localPrompt); const controlsCell = document.createElement("div"); - controlsCell.style.cssText = "display:flex;flex-direction:column;gap:6px;min-width:0;"; - const cameraRelay = makeChoiceSelect(r.camera_relay_mode, [ - { value: "off", label: "off" }, - { value: "before", label: "before local" }, - { value: "after", label: "after local" }, - ], (value) => { updateRow({ camera_relay_mode: value }); }); - cameraRelay.title = "Insert camera movement into this PromptRelay local prompt"; - const transitionRelay = makeChoiceSelect(r.transition_relay_mode, [ - { value: "off", label: "off" }, - { value: "safe_only", label: "safe only" }, - { value: "append", label: "append all" }, - ], (value) => { updateRow({ transition_relay_mode: value }); }); - transitionRelay.title = "Insert transition language into this PromptRelay local prompt. safe_only avoids hard_cut."; + controlsCell.style.cssText = "display:grid;grid-template-rows:auto auto;gap:7px;min-width:0;align-content:start;overflow:hidden;"; const shotControls = document.createElement("div"); - shotControls.style.cssText = "display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;min-width:0;"; - const namedControl = (labelText, control, relayControl = null) => { + shotControls.style.cssText = `display:grid;grid-template-columns:${shotboardV2 ? "minmax(0,.9fr) minmax(0,1.2fr) minmax(0,1.25fr)" : "minmax(0,.95fr) minmax(0,1fr) minmax(0,1fr)"};gap:6px;min-width:0;`; + const namedControl = (labelText, control) => { const wrap = document.createElement("label"); - wrap.style.cssText = "display:flex;flex-direction:column;gap:3px;color:#a9bac5;font-size:10px;min-width:0;"; + wrap.style.cssText = "display:flex;flex-direction:column;gap:3px;color:#a9bac5;font-size:10px;min-width:0;overflow:hidden;"; const span = document.createElement("span"); span.textContent = labelText; wrap.append(span, control); - if (relayControl) wrap.append(relayControl); return wrap; }; shotControls.append( namedControl("Label", label), - namedControl("Camera", camera, cameraRelay), - namedControl("Transition", transition, transitionRelay) + namedControl("Camera", camera), + namedControl("Transition", transition) ); + const stepEnabled = Boolean(r.step_transition_enabled && String(r.step_transition_type || "off") !== "off"); + const stepPanel = document.createElement("div"); + stepPanel.style.cssText = [ + "display:grid", + `grid-template-columns:${stepEnabled ? "minmax(250px,.7fr) minmax(240px,1.2fr)" : "minmax(0,1fr)"}`, + `gap:${stepEnabled ? "9px" : "6px"}`, + "align-items:stretch", + `border:1px solid ${stepEnabled ? "rgba(223,164,81,.72)" : "rgba(118,103,83,.36)"}`, + "border-radius:7px", + `background:${stepEnabled ? "rgba(73,50,28,.35)" : "rgba(255,255,255,.025)"}`, + `padding:${stepEnabled ? "9px" : "7px"}`, + "min-width:0", + `${shotboardV2 ? "min-height:178px" : "min-height:142px"}`, + ].join(";"); + const stepLeft = document.createElement("div"); + stepLeft.style.cssText = "display:flex;flex-direction:column;gap:6px;min-width:0;"; + stepSelect.style.height = "32px"; + stepSelect.style.lineHeight = "32px"; + stepSelect.style.paddingTop = "0"; + stepSelect.style.paddingBottom = "0"; + stepSelect.style.borderColor = r.step_transition_enabled ? "rgba(223,164,81,.78)" : "rgba(118,103,83,.42)"; + stepSelect.style.fontWeight = "800"; + const stepPrompt = document.createElement("textarea"); + stepPrompt.value = r.step_transition_prompt || ""; + stepPrompt.placeholder = shotboardV2 ? "Relay bridge toward next row..." : "Bridge note toward next row..."; + stepPrompt.rows = shotboardV2 ? 3 : 1; + stepPrompt.style.cssText = inputBase() + (shotboardV2 + ? "resize:vertical;min-height:88px;font-size:12px;line-height:1.32;padding:9px 15px 9px 10px;scrollbar-gutter:stable;" + : "resize:vertical;min-height:42px;font-size:11px;line-height:1.25;padding:7px 15px 7px 9px;"); + stepPrompt.oninput = () => { + const hasText = Boolean(String(stepPrompt.value || "").trim()); + updateRow({ + note: shotboardV2 ? stepPrompt.value : r.note, + step_transition_prompt: stepPrompt.value, + step_transition_type: hasText && r.step_transition_type === "off" ? "action_beat" : r.step_transition_type, + step_transition_enabled: hasText ? true : r.step_transition_enabled, + use_prompt: hasText ? true : r.use_prompt, + }); + if (shotboardV2) drawReferenceStrip(); + }; + protectControlDrag(stepPrompt); + const addonControls = document.createElement("div"); - addonControls.style.cssText = "display:grid;grid-template-columns:minmax(110px,160px) 1fr;gap:6px;align-items:end;"; + addonControls.style.cssText = shotboardV2 + ? "display:grid;grid-template-columns:minmax(122px,150px) minmax(0,1fr);gap:7px;align-items:stretch;min-width:0;" + : "display:grid;grid-template-columns:minmax(118px,140px) minmax(0,1fr);gap:7px;align-items:stretch;min-width:0;"; const miniControl = (labelText, control) => { const wrap = document.createElement("label"); - wrap.style.cssText = "display:flex;flex-direction:column;gap:2px;color:#a9bac5;font-size:10px;min-width:0;"; + wrap.style.cssText = "display:flex;flex-direction:column;gap:3px;color:#a9bac5;font-size:9px;line-height:1.1;min-width:0;overflow:hidden;"; const span = document.createElement("span"); span.textContent = labelText; wrap.append(span, control); return wrap; }; + const fitProStepDuration = (seconds) => { + const nextRow = rows[index + 1]; + if (!nextRow || seconds <= 0) return; + const requiredSecond = Number(r.second || 0) + Number(seconds || 0); + const nextSecond = Number(nextRow.second || 0); + if (nextSecond >= requiredSecond) return; + const delta = Number((requiredSecond - nextSecond).toFixed(3)); + for (let j = index + 1; j < rows.length; j += 1) { + rows[j].second = Number((Number(rows[j].second || 0) + delta).toFixed(3)); + } + const lastSecond = Math.max(...rows.map((item) => Number(item.second || 0))); + const durationCtl = getWidget(node, "duration_seconds"); + if (durationCtl && Number(durationCtl.value || 0) < lastSecond + 0.1) { + durationCtl.value = Number((lastSecond + 0.1).toFixed(3)); + setWidgetValue(node, "duration_seconds", durationCtl.value); + } + }; + const stepSeconds = numberStepperControl(Math.max(0, Number(r.step_transition_duration || 0) || 0), "0.1", "0", null, (value) => { + const seconds = Math.max(0, Number(value || 0)); + updateRow({ + step_transition_duration: seconds, + step_transition_enabled: seconds > 0 ? true : r.step_transition_enabled, + step_transition_type: seconds > 0 && r.step_transition_type === "off" ? "slow_dolly_in" : r.step_transition_type, + use_prompt: seconds > 0 ? true : r.use_prompt, + }); + if ((r.step_transition_auto_fit ?? true) !== false) fitProStepDuration(seconds); + sync(); + draw(); + }, { liveInput: false }); + stepSeconds.style.gridTemplateColumns = "22px minmax(34px,1fr) 22px"; + stepSeconds.style.gap = "4px"; + stepSeconds.style.minWidth = "0"; + stepSeconds.querySelectorAll("button").forEach((button) => { + button.style.width = "22px"; + button.style.minWidth = "22px"; + }); + styleValueControls(stepSeconds); + const stepArrival = makeChoiceSelect(r.step_transition_arrival || "auto", STEP_TRANSITION_ARRIVAL_OPTIONS, (value) => { + updateRow({ step_transition_arrival: value }); + }); + stepArrival.style.height = "32px"; + stepArrival.style.lineHeight = "32px"; + stepArrival.style.paddingTop = "0"; + stepArrival.style.paddingBottom = "0"; + stepArrival.title = "Where the arrival toward the next row should happen inside the transition"; + const stepAuto = checkbox((r.step_transition_auto_fit ?? true) !== false, (value) => { + updateRow({ step_transition_auto_fit: value }); + if (value && Number(r.step_transition_duration || 0) > 0) { + fitProStepDuration(Number(r.step_transition_duration || 0)); + sync(); + draw(); + } + }); + stepAuto.title = "When transition seconds exceed the current gap, shift following rows later"; + const stepTiming = document.createElement("div"); + stepTiming.style.cssText = "display:grid;grid-template-columns:minmax(78px,1fr) minmax(88px,1fr) 28px;gap:5px;align-items:end;min-width:0;"; + stepTiming.append(miniControl("Seconds", stepSeconds), miniControl("Arrival", stepArrival), miniControl("Fit", stepAuto)); const addonPosition = makeChoiceSelect(r.relay_addon_position, [ { value: "after", label: "add-on after" }, { value: "before", label: "add-on before" }, @@ -3641,11 +5443,47 @@ function renderShotboardPro(node) { modifierBox.value = r.relay_modifier_text || ""; modifierBox.placeholder = "Custom Relay add-on text, optional"; modifierBox.rows = 1; - modifierBox.style.cssText = inputBase() + "resize:vertical;min-height:30px;font-size:11px;padding:7px 15px 7px 9px;"; + modifierBox.style.cssText = inputBase() + (shotboardV2 + ? "resize:vertical;min-height:52px;font-size:11px;line-height:1.25;padding:7px 15px 7px 9px;scrollbar-gutter:stable;" + : "resize:vertical;min-height:66px;font-size:11px;line-height:1.28;padding:8px 15px 8px 9px;"); modifierBox.oninput = () => { updateRow({ relay_modifier_text: modifierBox.value }); }; protectControlDrag(modifierBox); - addonControls.append(miniControl("Add-on pos", addonPosition), modifierBox); - controlsCell.append(shotControls, addonControls); + const bridgeNoteControl = miniControl(shotboardV2 ? "Relay Bridge" : "Bridge note", stepPrompt); + const addonPositionControl = miniControl("Add-on pos", addonPosition); + const customRelayControl = miniControl("Custom Relay", modifierBox); + if (shotboardV2) addonControls.append(addonPositionControl, customRelayControl); + else addonControls.append(addonPositionControl, bridgeNoteControl); + stepLeft.append( + makeStepTransitionHeader(stepEnabled, stepTransitionLabel(r.step_transition_type), "to next row"), + miniControl("Mode", stepSelect) + ); + if (stepEnabled) stepLeft.append(stepTiming); + const stepRight = document.createElement("div"); + stepRight.style.cssText = shotboardV2 + ? "display:grid;grid-template-rows:minmax(88px,auto) auto;gap:8px;min-width:0;align-content:stretch;" + : "display:grid;grid-template-rows:auto auto;gap:8px;min-width:0;align-content:stretch;"; + if (shotboardV2) stepRight.append(bridgeNoteControl, addonControls); + else stepRight.append(addonControls, customRelayControl); + if (stepEnabled) stepPanel.append(stepLeft, stepRight); + else stepPanel.append(stepLeft); + if (shotboardV2) { + const insertRelayAfter = shotButton("Insert Relay After", "primary"); + insertRelayAfter.title = "Create a real PromptRelay bridge row after this frame. The bridge appears as its own block between this frame and the next one."; + insertRelayAfter.style.minHeight = "32px"; + insertRelayAfter.style.fontSize = "10px"; + insertRelayAfter.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + insertRelayBridgeAfterRow(index); + }; + protectControlDrag(insertRelayAfter); + const v2Tools = document.createElement("div"); + v2Tools.style.cssText = "display:grid;grid-template-rows:auto auto;gap:8px;min-width:0;"; + v2Tools.append(shotControls, insertRelayAfter); + controlsCell.append(v2Tools); + } else { + controlsCell.append(shotControls, addonControls); + } const del = shotButton("x", "danger"); del.style.width = "26px"; @@ -3657,21 +5495,53 @@ function renderShotboardPro(node) { if (warnText) warnText.textContent = "At least one shot row must remain."; return; } - rows.splice(index, 1); - sync(); - draw(); + deleteRowAndLinkedReference(index); }; protectControlDrag(del); - card.append(handle, sec, ref, forceNotesCell, guide, relay, controlsCell, promptCell, del); + card.append(handle, timeCell, ref, forceNotesCell, guide, relay, controlsCell, promptCell, del); table.appendChild(card); }); sync(); } - addBtn.onclick = () => { rows.push({ second: rows.length ? Number(rows[rows.length - 1].second) + 2.5 : 0, ref: rows.length + 1, force: 0.18, use_guide: false, use_prompt: true, label: `shot_${rows.length + 1}`, camera: "continuous dolly-in", transition: "continuous_motion", camera_relay_mode: "before", transition_relay_mode: "off", relay_addon_position: "after", note: "", relay_prompt: "describe the motion beat" }); draw(); }; - presetSafe.onclick = () => { rows = defaultShotboardRows(); draw(); }; - promptOnly.onclick = () => { rows = defaultShotboardRows().map((row, i) => ({ ...row, force: i === 0 ? 0.65 : 0, use_guide: i === 0, use_prompt: true })); draw(); }; + addBtn.onclick = () => { rows.push({ second: rows.length ? Number(rows[rows.length - 1].second) + 2.5 : 0, ref: rows.length + 1, force: 0.18, use_guide: false, use_prompt: true, label: `shot_${rows.length + 1}`, camera: "continuous dolly-in", transition: "continuous_motion", camera_relay_mode: "off", transition_relay_mode: "off", relay_addon_position: "after", note: "", relay_prompt: "describe the motion beat" }); draw(); }; + addTextSlotBtn.onclick = () => { + const currentRefs = getConnectedReferencePaths(node); + const last = rows.length ? normalizeShotboardRow(rows[rows.length - 1], rows.length - 1) : null; + rows.push({ + second: last ? Number(last.second || 0) + 2.5 : 0, + ref: Math.max(1, Math.min(Number(last?.ref || 1), Math.max(1, currentRefs.length || 1))), + force: 0, + image_lock_strength: 0, + use_guide: false, + use_prompt: true, + label: `text_${rows.length + 1}`, + camera: "prompt relay text", + transition: "prompt_relay_text", + camera_relay_mode: "off", + transition_relay_mode: "off", + relay_addon_position: "after", + note: "", + relay_prompt: "describe the timed action beat", + step_transition_enabled: false, + step_transition_type: "off", + step_transition_prompt: "", + }); + sync(); + draw(); + }; + presetSafe.onclick = () => { rows = shotboardTemplateRows(); draw(); }; + promptOnly.onclick = () => { + const sourceRows = rows.length ? rows : shotboardTemplateRows(); + rows = sourceRows.map((row, i) => normalizeShotboardRow({ + ...row, + force: i === 0 ? 0.65 : 0, + use_guide: i === 0, + use_prompt: true, + }, i)); + draw(); + }; smoothBtn.onclick = () => { rows = rows.map((row, i) => ({ ...row, force: i === 0 ? Math.min(row.force, 0.72) : Math.min(row.force, 0.24), transition: row.transition === "hard_cut" ? "match_cut" : row.transition })); draw(); }; coreBtn.onclick = () => { rows = rows.map((row, i) => ({ ...row, use_guide: i === 0 || i === Math.floor(rows.length / 2) || i === rows.length - 1, use_prompt: i === 0 || i === Math.floor(rows.length / 2) || i === rows.length - 1, force: (i === 0 || i === Math.floor(rows.length / 2) || i === rows.length - 1) ? Math.max(0.18, Math.min(row.force || 0.22, 0.32)) : 0 })); draw(); }; thumbsBtn.onclick = () => { drawReferenceStrip(); draw(); }; @@ -3696,11 +5566,17 @@ function renderShotboardPro(node) { { second: 8.0, ref: 1, force: 0.72, use_guide: true, use_prompt: true, label: "return_A", camera: "slow push-in", transition: "hard_cut", note: "", relay_prompt: "return to person A reaction" }, { second: 11.0, ref: 3, force: 0.58, use_guide: true, use_prompt: true, label: "wide_reveal", camera: "wide reveal", transition: "match_cut", note: "", relay_prompt: "environment reveals both people" }, ]; draw(); }; - clearBtn.onclick = () => { rows = [makeReferenceRow(1, Math.max(1, getConnectedReferencePaths(node).length))]; draw(); }; + clearBtn.onclick = () => { + promptArea.value = ""; + syncProPromptWidget(); + rows = [makeReferenceRow(1, Math.max(1, getConnectedReferencePaths(node).length))]; + draw(); + }; drawReferenceStrip(); draw(); const widget = node.addDOMWidget("Cine Shotboard Pro", "iamccs_cine_shotboard_pro", root, { serialize: false }); + node._iamccsCineShotboardWidget = widget; widget.computeSize = (width) => { if (root._iamccsFullscreenState) return [width, 24]; const rowCount = Math.max(1, rows.length); @@ -3823,6 +5699,4529 @@ function showRenderError(node, err) { widget.computeSize = (width) => [width, 64]; } +function disposeShotboardV3Widget(node) { + const widget = node?._iamccsCineShotboardV3Widget; + const root = node?._iamccsCineShotboardV3Root; + try { root?.remove?.(); } catch {} + try { widget?.element?.remove?.(); } catch {} + if (widget && Array.isArray(node?.widgets)) { + const idx = node.widgets.indexOf(widget); + if (idx >= 0) node.widgets.splice(idx, 1); + } + node._iamccsCineShotboardV3Widget = null; + node._iamccsCineShotboardV3Root = null; +} + +function renderShotboardV3(node) { + if (node._iamccsCineShotboardV3Ready && node._iamccsCineShotboardV3Version === CINE_VERSION) return; + disposeShotboardV3Widget(node); + node._iamccsCineShotboardV3Ready = true; + node._iamccsCineShotboardV3Version = CINE_VERSION; + const chrome = applyCineChrome(node, "shotboardV3"); + [ + "timeline_data", + "global_prompt", + "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", + "image_multiple_of", + "img_compression", + ].forEach((name) => hideWidget(getWidget(node, name))); + + const purple = { + bg: "#1E2022", + panel: "#2A2D30", + panel2: "#34383C", + border: "#6B6258", + borderSoft: "#4A4742", + text: "#F7F2EA", + muted: "#D2C7BA", + image: "#3F7781", + image2: "#2E515A", + textBlock: "#4B4037", + audio: "#40614F", + danger: "#B55245", + button: "#3B3936", + buttonHover: "#4B4640", + play: "#D89B45", + accent: "#55B8B2", + warm: "#C96F32", + valueBg: "#F4EFE7", + valueText: "#181512", + }; + + const styleValueControls = (element) => { + if (element?.matches?.("input,select")) { + element.style.background = purple.valueBg; + element.style.color = purple.valueText; + element.style.borderColor = purple.border; + element.style.textAlign = "center"; + element.style.fontWeight = "800"; + } + element?.querySelectorAll?.("input,select")?.forEach((input) => { + input.style.background = purple.valueBg; + input.style.color = purple.valueText; + input.style.borderColor = purple.border; + input.style.textAlign = "center"; + input.style.fontWeight = "800"; + }); + element?.querySelectorAll?.("button")?.forEach((button) => { + button.style.background = purple.button; + button.style.color = purple.text; + button.style.borderColor = purple.border; + }); + return element; + }; + + const root = document.createElement("div"); + root.dataset.iamccsCineVersion = CINE_VERSION; + node._iamccsCineShotboardV3Root = root; + root.style.cssText = [ + "width:100%", + "box-sizing:border-box", + "padding:9px", + `border:1px solid ${purple.border}`, + "border-radius:7px", + `background:${purple.bg}`, + `color:${purple.text}`, + "font:12px Arial,sans-serif", + `max-height:${SHOTBOARD_V3_OPEN_HEIGHT}px`, + "overflow-y:auto", + "overflow-x:hidden", + "scrollbar-gutter:stable", + `box-shadow:inset 0 1px 0 ${chrome.glow},0 0 0 1px rgba(0,0,0,.45)`, + "pointer-events:auto", + ].join(";"); + + const promptWidget = getWidget(node, "global_prompt"); + const timelineWidget = getWidget(node, "timeline_data"); + const durationWidget = getWidget(node, "duration_seconds"); + const fpsWidget = getWidget(node, "frame_rate"); + const defaultForceWidget = getWidget(node, "default_force"); + const imageWidthWidget = getWidget(node, "image_width"); + const imageHeightWidget = getWidget(node, "image_height"); + let collapsed = Boolean(node.properties?.iamccs_v3_collapsed); + let promptTextScale = Math.max(0.85, Math.min(1.55, Number(node.properties?.iamccs_v3_prompt_text_scale || 1))); + const promptFontSize = (base) => `${Math.max(8, Math.round(Number(base || 10) * promptTextScale * 10) / 10)}px`; + let timelineMeterSeconds = Math.max(0.5, Number(node.properties?.iamccs_v3_timeline_meter_seconds || durationWidget?.value || 20) || 20); + let selectedId = null; + let pendingImageInsertFrame = null; + let pendingImageTargetId = null; + let timelineNotice = null; + let timelineNoticeUntil = 0; + let lastDefaultForce = Math.max(0, Math.min(1, Number(defaultForceWidget?.value || 0.25))); + + 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())); + const clampTimelineMeterSeconds = (value = timelineMeterSeconds) => { + const duration = Math.max(0.5, getDuration()); + const rounded = Math.round((Number(value) || duration) * 2) / 2; + return Math.max(duration, Math.min(Math.max(duration * 3, duration + 0.5), rounded)); + }; + const defaultLen = () => Math.max(1, Math.round(getFps() * 3)); + const newId = (prefix) => `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; + + function readTimeline() { + try { + const data = JSON.parse(String(timelineWidget?.value || "{}")); + if (data && typeof data === "object") { + return { + schema: data.schema || "iamccs.cine.filmmaker_timeline", + schema_version: Number(data.schema_version || 1), + segments: Array.isArray(data.segments) ? data.segments : [], + audioSegments: Array.isArray(data.audioSegments) ? data.audioSegments : [], + audioTrackCount: Math.max(1, Number(data.audioTrackCount || 2)), + 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), + }; + } + } 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 }; + } + + let timeline = readTimeline(); + const refPaths = () => getConnectedReferencePaths(node); + const endOfSegments = (items) => (items || []).reduce((max, item) => Math.max(max, Number(item.start || 0) + Number(item.length || 1)), 0); + const endOfVisualSegments = () => endOfSegments((timeline.segments || []).filter((seg) => String(seg.type || "image") !== "audio")); + const showTimelineNotice = (message, tone = "warn") => { + if (!timelineNotice) return; + if (!message && Date.now() < timelineNoticeUntil) return; + if (message) timelineNoticeUntil = Date.now() + 3500; + timelineNotice.textContent = message || ""; + timelineNotice.style.display = message ? "block" : "none"; + timelineNotice.style.borderColor = tone === "error" ? purple.danger : purple.play; + timelineNotice.style.color = tone === "error" ? "#FFE3DD" : "#FFF1BE"; + }; + const setDurationSeconds = (seconds) => { + const next = Math.max(0.1, Number(seconds) || 0.1); + if (durationWidget) durationWidget.value = next; + setWidgetValue(node, "duration_seconds", next); + }; + const enforceDurationMinimum = () => { + const fps = getFps(); + const minFrames = endOfVisualSegments(); + if (!minFrames) { + showTimelineNotice(""); + return false; + } + const currentFrames = Math.round(getDuration() * fps); + if (currentFrames >= minFrames) { + showTimelineNotice(""); + 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; + }; + 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 followsDefaultForce = (seg, previousDefault) => { + if (!seg || String(seg.type || "image") === "text") return false; + const current = Number(seg.guideStrength); + if (!Number.isFinite(current)) return true; + const source = Number(seg.defaultForceSource); + if (seg.forceCustom === true) { + if (Number.isFinite(source) && Math.abs(current - source) < 0.0005) return true; + if (!Number.isFinite(source) && Math.abs(current - Number(previousDefault || 0)) < 0.0005) return true; + return false; + } + if (Number.isFinite(source) && Math.abs(current - source) < 0.0005) return true; + return Math.abs(current - Number(previousDefault || 0)) < 0.0005; + }; + const applyDefaultForceToLinkedSegments = (value) => { + const next = Math.max(0, Math.min(1, Number(value) || 0)); + const previous = lastDefaultForce; + (timeline.segments || []).forEach((seg) => { + if (!followsDefaultForce(seg, previous)) return; + seg.guideStrength = next; + seg.imageLockStrength = next; + seg.defaultForceSource = next; + seg.forceCustom = false; + }); + lastDefaultForce = next; + }; + const clampSegment = (seg) => { + const total = getTotalFrames(); + seg.length = Math.max(1, Math.round(Number(seg.length || defaultLen()))); + 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); + return seg; + }; + const audioSegmentHasMedia = (seg) => Boolean(seg && (String(seg.audioFile || "").trim() || String(seg.audioB64 || "").trim())); + const segmentRangesOverlap = (aStart, aLength, bStart, bLength) => { + const a0 = Math.max(0, Math.round(Number(aStart || 0))); + const b0 = Math.max(0, Math.round(Number(bStart || 0))); + const a1 = a0 + Math.max(1, Math.round(Number(aLength || 1))); + const b1 = b0 + Math.max(1, Math.round(Number(bLength || 1))); + return a0 < b1 && b0 < a1; + }; + const removeEmptyAudioPlaceholdersInRange = (track, start, length) => { + const trackIndex = Math.max(0, Math.round(Number(track || 0))); + const before = timeline.audioSegments || []; + timeline.audioSegments = before.filter((seg) => { + if (Number(seg?.track || 0) !== trackIndex) return true; + if (audioSegmentHasMedia(seg) && !seg.placeholder) return true; + return !segmentRangesOverlap(seg?.start, seg?.length, start, length); + }); + return timeline.audioSegments.length !== before.length; + }; + const cleanupAudioPlaceholdersOverlappingMedia = () => { + const audio = timeline.audioSegments || []; + const media = audio.filter((seg) => audioSegmentHasMedia(seg) && !seg.placeholder); + if (!media.length) return false; + const before = audio.length; + timeline.audioSegments = audio.filter((seg) => { + if (audioSegmentHasMedia(seg) && !seg.placeholder) return true; + return !media.some((clip) => + Number(clip.track || 0) === Number(seg?.track || 0) + && segmentRangesOverlap(clip.start, clip.length, seg?.start, seg?.length) + ); + }); + return timeline.audioSegments.length !== before; + }; + const magnetize = () => { + const total = getTotalFrames(); + timeline.segments = (timeline.segments || []).map((seg) => clampSegment(seg)).sort((a, b) => Number(a.start || 0) - Number(b.start || 0)); + let cursor = 0; + for (const seg of timeline.segments) { + if (Number(seg.start || 0) < cursor) seg.start = cursor; + if (seg.start + seg.length > total) seg.length = Math.max(1, total - seg.start); + cursor = seg.start + seg.length; + } + timeline.audioSegments = (timeline.audioSegments || []).map((seg) => clampSegment(seg)).sort((a, b) => (Number(a.track || 0) - Number(b.track || 0)) || (Number(a.start || 0) - Number(b.start || 0))); + }; + const segmentToRow = (seg, index) => { + const fps = getFps(); + const isText = String(seg.type || "image") === "text"; + 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))), + label: String(seg.label || `${isText ? "text" : "shot"}_${index + 1}`), + camera: String(seg.camera || "cinematic motion"), + transition: String(seg.transition || "continuous_motion"), + note: String(seg.note || ""), + use_guide: !isText && seg.use_guide !== false, + use_prompt: Boolean((seg.use_prompt !== false && String(seg.prompt || "").trim()) || seg.dialogue_pin || seg.image_lock || seg.motion_boost), + relay_prompt: String(seg.prompt || ""), + camera_relay_mode: String(seg.camera_relay_mode || "off"), + transition_relay_mode: String(seg.transition_relay_mode || "off"), + relay_addon_position: String(seg.relay_addon_position || "after"), + relay_modifier_text: String(seg.relay_modifier_text || ""), + dialogue_pin: Boolean(seg.dialogue_pin || seg.dialoguePin), + image_lock: Boolean(seg.image_lock || seg.imageLock), + motion_boost: Boolean(seg.motion_boost || seg.motionBoost), + clean_relay: Boolean(seg.clean_relay || seg.cleanRelay), + step_transition_enabled: false, + step_transition_type: "off", + 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, + }; + }; + let pendingTimelineCommit = 0; + const isTextEditing = () => { + const active = document.activeElement; + return Boolean(active && root.contains(active) && /^(TEXTAREA|INPUT)$/i.test(active.tagName || "")); + }; + const commitTimelineJson = (json, force = false) => { + if (timelineWidget) timelineWidget.value = json; + if (pendingTimelineCommit) { + clearTimeout(pendingTimelineCommit); + pendingTimelineCommit = 0; + } + if (force || !isTextEditing()) { + setWidgetValue(node, "timeline_data", json); + return; + } + pendingTimelineCommit = setTimeout(() => { + pendingTimelineCommit = 0; + setWidgetValue(node, "timeline_data", timelineWidget?.value ?? json); + }, 220); + }; + const flushTimelineWrite = () => { + if (pendingTimelineCommit) { + clearTimeout(pendingTimelineCommit); + pendingTimelineCommit = 0; + setWidgetValue(node, "timeline_data", timelineWidget?.value || ""); + } + }; + const syncSegmentTextPeers = (segmentId, key, value, source) => { + root.querySelectorAll(`[data-iamccs-v3-segment-id="${String(segmentId)}"][data-iamccs-v3-key="${String(key)}"]`).forEach((el) => { + if (el === source) return; + if (el.value !== value) el.value = value; + }); + }; + const syncSegmentRelayPeers = (segmentId, checked, source) => { + root.querySelectorAll(`[data-iamccs-v3-segment-id="${String(segmentId)}"][data-iamccs-v3-key="use_prompt"]`).forEach((el) => { + if (el === source) return; + if (el.checked !== checked) el.checked = checked; + }); + }; + const writeTimeline = (options = {}) => { + enforceDurationMinimum(); + cleanupAudioPlaceholdersOverlappingMedia(); + magnetize(); + const fps = getFps(); + 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(); + return hasPrompt; + }); + const audioHasMedia = (timeline.audioSegments || []).some((seg) => audioSegmentHasMedia(seg)); + const durationFrames = getTotalFrames(); + const visual = (timeline.segments || []) + .filter((seg) => String(seg.type || "image") !== "audio" && !seg.placeholder) + .slice() + .sort((a, b) => Number(a.start || 0) - Number(b.start || 0)); + const directorPrompts = []; + const directorLengths = []; + let cursor = 0; + let pendingGap = 0; + for (const seg of visual) { + const start = Math.max(0, Math.round(Number(seg.start || 0))); + if (start >= durationFrames) break; + if (start > cursor) { + const gap = Math.min(start, durationFrames) - cursor; + if (directorLengths.length) directorLengths[directorLengths.length - 1] += Math.max(0, gap); + else pendingGap += Math.max(0, gap); + } + const clippedEnd = Math.min(start + Math.max(1, Math.round(Number(seg.length || 1))), durationFrames); + const length = Math.max(1, clippedEnd - start + pendingGap); + pendingGap = 0; + const prompt = String(seg.prompt || "").trim(); + if (prompt) { + directorPrompts.push(prompt); + directorLengths.push(length); + } else if (directorLengths.length) { + directorLengths[directorLengths.length - 1] += length; + } else { + pendingGap += length; + } + cursor = start + Math.max(1, Math.round(Number(seg.length || 1))); + } + if (directorLengths.length && Math.min(cursor, durationFrames) < durationFrames) { + directorLengths[directorLengths.length - 1] += durationFrames - Math.min(cursor, durationFrames); + } + const guideStrength = visual + .filter((seg) => String(seg.type || "image") !== "text") + .map((seg) => Number(seg.guideStrength ?? seg.guide_strength ?? seg.force ?? defaultForceWidget?.value ?? 1.0).toFixed(2)) + .join(","); + const audioData = JSON.stringify({ + audioSegments: timeline.audioSegments || [], + use_custom_audio: audioHasMedia, + audioSyncMode: String(timeline.audioSyncMode || "timeline_audio"), + }); + const flfrealMode = ["flfreal_parity", "iamccs_enhanced"].includes(String(timeline.flfrealMode || "iamccs_enhanced")) + ? String(timeline.flfrealMode || "iamccs_enhanced") + : "iamccs_enhanced"; + const globalPromptOnly = Boolean(timeline.globalPromptOnly); + const effectiveDirectorPrompts = globalPromptOnly ? [] : directorPrompts; + const effectiveDirectorLengths = globalPromptOnly ? [] : directorLengths; + const effectivePromptRelayEnabled = globalPromptOnly ? false : promptRelayEnabled; + const clean = { + schema: "iamccs.cine.filmmaker_timeline", + schema_version: 2, + flfrealMode, + flfreal_mode: flfrealMode, + global_prompt_only: globalPromptOnly, + use_global_prompt_only: globalPromptOnly, + promptrelay_enabled: effectivePromptRelayEnabled, + use_custom_audio: audioHasMedia, + audioSyncMode: String(timeline.audioSyncMode || "timeline_audio"), + generationStrategy: String(timeline.generationStrategy || "single_timeline"), + 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, + audio_data: audioData, + duration_seconds: getDuration(), + frame_rate: fps, + image_paths: refPaths(), + image_width: Number(imageWidthWidget?.value || 768), + image_height: Number(imageHeightWidget?.value || 432), + image_resize_method: cineResizeMethodValue(getWidget(node, "image_resize_method")?.value), + 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), + audioTrackCount: Math.max(1, Math.round(Number(timeline.audioTrackCount || 2))), + segments: timeline.segments, + audioSegments: timeline.audioSegments, + rows, + }; + commitTimelineJson(JSON.stringify(clean, null, 2), Boolean(options.force)); + }; + + if (!Array.isArray(timeline.segments)) timeline.segments = []; + const collapsedNodeHeight = () => { + const tracks = Math.max(1, Math.round(Number(timeline.audioTrackCount || 2))); + return Math.max(SHOTBOARD_V3_COLLAPSED_HEIGHT, 450 + tracks * 45); + }; + const currentNodeHeight = () => collapsed ? collapsedNodeHeight() : SHOTBOARD_V3_OPEN_HEIGHT; + + const head = document.createElement("div"); + head.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;"; + const title = document.createElement("div"); + title.textContent = "Cine Shotboard Planner V3"; + title.style.cssText = `font-size:13px;font-weight:800;color:${purple.text};`; + const topActions = document.createElement("div"); + topActions.style.cssText = "display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end;"; + const v3ButtonColors = { + normal: { bg: purple.button, hover: purple.buttonHover, border: purple.border, color: purple.text }, + teal: { bg: "linear-gradient(180deg,#2F686A,#22474A)", hover: "linear-gradient(180deg,#397A7D,#29565A)", border: "#77BFC2", color: "#F2FFFB" }, + amber: { bg: "linear-gradient(180deg,#6B5630,#493B24)", hover: "linear-gradient(180deg,#7C653A,#56462B)", border: "#D8A64C", color: "#FFF4D7" }, + blue: { bg: "linear-gradient(180deg,#345C7E,#263E57)", hover: "linear-gradient(180deg,#3E6C93,#2D4B68)", border: "#78A9D2", color: "#F2F8FF" }, + violet: { bg: "linear-gradient(180deg,#5A4D7C,#3D345B)", hover: "linear-gradient(180deg,#6A5B91,#493E6A)", border: "#A996D8", color: "#FAF6FF" }, + olive: { bg: "linear-gradient(180deg,#586B35,#3D4A28)", hover: "linear-gradient(180deg,#687D40,#495831)", border: "#A4BF6C", color: "#F7FFE9" }, + slate: { bg: "linear-gradient(180deg,#58616C,#3D444D)", hover: "linear-gradient(180deg,#68737F,#48515C)", border: "#9DABB8", color: "#F5FAFF" }, + sand: { bg: "linear-gradient(180deg,#756246,#514532)", hover: "linear-gradient(180deg,#887352,#5F513B)", border: "#C7A875", color: "#FFF7EA" }, + green: { bg: "linear-gradient(180deg,#3E6B4F,#2B4B39)", hover: "linear-gradient(180deg,#497E5D,#345A44)", border: "#8BC69E", color: "#F2FFF6" }, + gold: { bg: "linear-gradient(180deg,#80672D,#56451F)", hover: "linear-gradient(180deg,#957835,#654F24)", border: "#E1B94F", color: "#FFF7D5" }, + danger: { bg: "#6B302A", hover: "#8A3A32", border: purple.danger, color: purple.text }, + }; + const makeBtn = (label, tone = "normal") => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = label; + const colors = v3ButtonColors[tone] || v3ButtonColors.normal; + btn.style.cssText = [ + "height:28px", + "padding:0 10px", + `border:1px solid ${colors.border}`, + "border-radius:5px", + `background:${colors.bg}`, + `color:${colors.color}`, + "font-size:11px", + "font-weight:800", + "cursor:pointer", + ].join(";"); + btn.onmouseenter = () => { btn.style.background = colors.hover; }; + btn.onmouseleave = () => { btn.style.background = colors.bg; }; + return protectControlDrag(btn); + }; + const setFlfrealMode = (value) => { + timeline.flfrealMode = value; + writeTimeline({ force: true }); + draw(); + }; + const makeModeBtn = (value, label, tone = "teal") => { + const active = String(timeline.flfrealMode || "iamccs_enhanced") === value; + const colors = v3ButtonColors[tone] || v3ButtonColors.teal; + const btn = makeBtn(label, tone); + btn.style.background = active ? colors.hover : colors.bg; + btn.style.borderColor = active ? "#F2FFFB" : colors.border; + btn.style.color = colors.color; + btn.title = active ? `${label} active` : `Switch to ${label}`; + btn.onmouseenter = () => { btn.style.background = colors.hover; }; + btn.onmouseleave = () => { btn.style.background = active ? colors.hover : colors.bg; }; + btn.onclick = () => setFlfrealMode(value); + return btn; + }; + const flfrealBtn = makeModeBtn("flfreal_parity", "FLFreal parity", "teal"); + const globalOnlyBtn = makeBtn(Boolean(timeline.globalPromptOnly) ? "Global only: ON" : "Global only: OFF", Boolean(timeline.globalPromptOnly) ? "green" : "slate"); + globalOnlyBtn.title = "When ON, ignore local PromptRelay text/action lanes and use only the global prompt, while keeping image guides."; + globalOnlyBtn.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + timeline.globalPromptOnly = !Boolean(timeline.globalPromptOnly); + writeTimeline({ force: true }); + draw(); + }; + const promptSizeWrap = document.createElement("div"); + promptSizeWrap.title = "Prompt text size"; + promptSizeWrap.style.cssText = `display:flex;align-items:center;gap:3px;height:28px;padding:0 4px;border:1px solid ${purple.border};border-radius:5px;background:${purple.button};`; + const promptSizeButton = (label, delta) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = label; + btn.style.cssText = `width:24px;height:22px;padding:0;border:1px solid ${purple.borderSoft};border-radius:4px;background:${purple.valueBg};color:${purple.valueText};font-size:15px;font-weight:900;line-height:1;cursor:pointer;`; + btn.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + promptTextScale = Math.max(0.85, Math.min(1.55, Math.round((promptTextScale + delta) * 100) / 100)); + node.properties = node.properties || {}; + node.properties.iamccs_v3_prompt_text_scale = promptTextScale; + draw(); + }; + return protectControlDrag(btn); + }; + const promptSizeReadout = document.createElement("span"); + promptSizeReadout.style.cssText = `min-width:34px;text-align:center;color:${purple.muted};font-size:9px;font-weight:900;`; + promptSizeWrap.append(promptSizeButton("-", -0.1), promptSizeReadout, promptSizeButton("+", 0.1)); + const timelineMeterWrap = document.createElement("div"); + timelineMeterWrap.title = "Visual timeline meter only. It does not change duration, frame starts, segment lengths or backend values."; + timelineMeterWrap.style.cssText = `display:flex;align-items:center;gap:3px;height:28px;padding:0 4px;border:1px solid ${purple.border};border-radius:5px;background:${purple.button};`; + const timelineMeterButton = (label, delta) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = label; + btn.title = label === "-" ? "Compress timeline visually" : "Expand timeline visually"; + btn.style.cssText = `width:24px;height:22px;border:1px solid ${purple.border};border-radius:4px;background:${purple.valueBg};color:${purple.valueText};font-size:14px;font-weight:900;cursor:pointer;`; + btn.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + timelineMeterSeconds = clampTimelineMeterSeconds(timelineMeterSeconds + delta); + node.properties = node.properties || {}; + node.properties.iamccs_v3_timeline_meter_seconds = timelineMeterSeconds; + draw(); + }; + return protectControlDrag(btn); + }; + 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 enhancedBtn = makeModeBtn("iamccs_enhanced", "IAMCCS enhanced", "amber"); + const addImageBtn = makeBtn("Add Image", "blue"); + const addTextBtn = makeBtn("Add Text", "violet"); + const addAudioBtn = makeBtn("Add Audio", "olive"); + const addTrackBtn = makeBtn("Add Audio Track", "sand"); + const collapseBtn = makeBtn(collapsed ? "Show Boxes" : "Collapse Boxes", "slate"); + const openEditorBtn = makeBtn("Open Editor", "teal"); + const importBoardBtn = makeBtn("Import Board", "violet"); + const saveBtn = makeBtn("Save Board", "green"); + const savePackageBtn = makeBtn("Save Package", "gold"); + const clearBtn = makeBtn("Clear Board", "danger"); + topActions.append(flfrealBtn, globalOnlyBtn, promptSizeWrap, timelineMeterWrap, enhancedBtn, 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"; + }); + + const promptWrap = document.createElement("label"); + promptWrap.style.cssText = `display:block;margin-bottom:8px;padding:8px;border:1px solid ${purple.borderSoft};background:${purple.panel};border-radius:6px;color:${purple.muted};font-size:11px;font-weight:800;`; + const promptLabel = document.createElement("div"); + promptLabel.textContent = "Global prompt"; + promptLabel.style.marginBottom = "5px"; + const promptArea = document.createElement("textarea"); + promptArea.value = String(promptWidget?.value || ""); + promptArea.rows = 3; + promptArea.style.cssText = inputBase() + `background:${purple.valueBg};border-color:${purple.border};color:${purple.valueText};resize:vertical;min-height:64px;font-weight:700;font-size:${promptFontSize(12)};`; + promptArea.oninput = () => setWidgetValue(node, "global_prompt", promptArea.value); + promptWrap.append(promptLabel, promptArea); + + const settings = document.createElement("div"); + settings.style.cssText = "display:grid;grid-template-columns:repeat(10,minmax(86px,1fr));gap:10px;margin-bottom:14px;"; + const addSetting = (label, name, step, min) => { + const wrap = document.createElement("label"); + wrap.style.cssText = `display:flex;flex-direction:column;gap:4px;color:${purple.muted};font-size:10px;font-weight:800;text-align:center;`; + const span = document.createElement("span"); + span.textContent = label; + const widget = getWidget(node, name); + const ctrl = numberStepperControl(widget?.value ?? "", step, min, null, (value) => { + if (name === "default_force") applyDefaultForceToLinkedSegments(value); + setWidgetValue(node, name, value); + if (name === "duration_seconds" && enforceDurationMinimum()) { + ctrl._iamccsSetValue?.(durationWidget?.value); + } + writeTimeline(); + draw(); + }, name === "duration_seconds" ? { liveInput: false } : {}); + styleValueControls(ctrl); + wrap.append(span, ctrl); + settings.appendChild(wrap); + }; + addSetting("Duration", "duration_seconds", "1", "1"); + addSetting("FPS", "frame_rate", "1", "1"); + addSetting("Default force", "default_force", "0.01", "0"); + addSetting("Width", "image_width", "32", "64"); + addSetting("Height", "image_height", "32", "64"); + const addSelectSetting = (label, name, options) => { + const wrap = document.createElement("label"); + wrap.style.cssText = `display:flex;flex-direction:column;gap:4px;color:${purple.muted};font-size:10px;font-weight:800;text-align:center;`; + const span = document.createElement("span"); + span.textContent = label; + const ctrl = makeSelect(String(getWidget(node, name)?.value || options[0]), options, (value) => { + setWidgetValue(node, name, value); + writeTimeline(); + draw(); + }); + styleValueControls(ctrl); + wrap.append(span, ctrl); + settings.appendChild(wrap); + }; + addSelectSetting("Resize", "image_resize_method", ["crop", "pad", "keep proportion", "stretch"]); + addSetting("Multiple", "image_multiple_of", "1", "1"); + addSetting("Relay softness", "promptrelay_epsilon", "0.05", "0"); + const guidePolicyWrap = document.createElement("label"); + guidePolicyWrap.style.cssText = `display:flex;flex-direction:column;gap:4px;color:${purple.muted};font-size:10px;font-weight:800;text-align:center;`; + const guidePolicyLabel = document.createElement("span"); + guidePolicyLabel.textContent = "Guides"; + const guidePolicy = makeSelect(String(getWidget(node, "guide_policy")?.value || "every_checked_row"), ["every_checked_row", "safe_core_guides", "prompt_only"], (value) => setWidgetValue(node, "guide_policy", value)); + styleValueControls(guidePolicy); + guidePolicyWrap.append(guidePolicyLabel, guidePolicy); + settings.appendChild(guidePolicyWrap); + + const parityWrap = document.createElement("label"); + parityWrap.style.cssText = `display:flex;flex-direction:column;gap:4px;color:${purple.muted};font-size:10px;font-weight:800;text-align:center;`; + const parityLabel = document.createElement("span"); + parityLabel.textContent = "Mode"; + const parityMode = makeChoiceSelect(String(timeline.flfrealMode || "iamccs_enhanced"), [ + { value: "flfreal_parity", label: "FLFreal parity" }, + { value: "iamccs_enhanced", label: "IAMCCS enhanced" }, + ], setFlfrealMode); + styleValueControls(parityMode); + parityWrap.append(parityLabel, parityMode); + settings.appendChild(parityWrap); + + timelineNotice = document.createElement("div"); + timelineNotice.style.cssText = `display:none;margin:-2px 0 8px 0;padding:7px 9px;border:1px solid ${purple.play};border-radius:6px;background:rgba(0,0,0,.28);color:#FFF1BE;font-size:11px;font-weight:800;`; + + const refsPanel = document.createElement("div"); + refsPanel.style.cssText = `margin-bottom:0;padding:0;border:0;background:transparent;display:none;`; + const refsTitle = document.createElement("div"); + refsTitle.textContent = "References"; + refsTitle.style.cssText = `color:${purple.muted};font-size:11px;font-weight:800;margin-bottom:6px;`; + const refsGrid = document.createElement("div"); + refsGrid.style.cssText = "display:flex;gap:7px;overflow-x:auto;min-height:74px;"; + refsPanel.append(refsTitle, refsGrid); + + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = "image/*"; + fileInput.multiple = true; + fileInput.style.display = "none"; + const audioInput = document.createElement("input"); + audioInput.type = "file"; + audioInput.accept = "audio/*"; + audioInput.multiple = true; + audioInput.style.display = "none"; + const boardInput = document.createElement("input"); + boardInput.type = "file"; + boardInput.accept = "application/json,.json"; + boardInput.style.display = "none"; + root.append(head, promptWrap, settings, timelineNotice, fileInput, audioInput, boardInput); + + const timelineViewport = document.createElement("div"); + timelineViewport.style.cssText = [ + "width:100%", + "max-width:100%", + "overflow-x:auto", + "overflow-y:hidden", + "border-radius:6px", + "box-sizing:border-box", + "scrollbar-gutter:stable", + ].join(";"); + const timelineCanvas = document.createElement("div"); + timelineCanvas.style.cssText = [ + "position:relative", + "min-width:0", + "box-sizing:border-box", + ].join(";"); + const ruler = document.createElement("div"); + 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);`; + 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));`; + 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;"; + timelineBox.append(imageTrack, audioTracks); + 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"); + const loopBtn = makeBtn("Loop"); + const timeReadout = document.createElement("div"); + timeReadout.style.cssText = `width:86px;color:${purple.muted};font-size:11px;font-weight:800;`; + const scrub = document.createElement("input"); + scrub.type = "range"; + scrub.min = "0"; + scrub.max = String(getTotalFrames()); + scrub.value = "0"; + scrub.step = "1"; + scrub.className = "iamccs-v3-analog-scrub"; + scrub.style.cssText = "flex:1 1 auto;min-width:180px;--iamccs-play-progress:0%;"; + const scrubStyle = document.createElement("style"); + scrubStyle.textContent = ` + .iamccs-v3-analog-scrub { + appearance: none; + height: 20px; + border: 1px solid ${purple.border}; + border-radius: 999px; + background: + linear-gradient(90deg, ${purple.play} 0%, ${purple.play} var(--iamccs-play-progress), transparent var(--iamccs-play-progress), transparent 100%), + repeating-linear-gradient(90deg, rgba(255,255,255,.16) 0, rgba(255,255,255,.16) 1px, transparent 1px, transparent 24px), + linear-gradient(180deg, #1F1D1A 0%, #3B362F 48%, #1E1C19 100%); + box-shadow: inset 0 2px 5px rgba(0,0,0,.55), 0 1px 0 rgba(255,255,255,.08); + cursor: pointer; + } + .iamccs-v3-analog-scrub::-webkit-slider-runnable-track { + height: 20px; + background: transparent; + border: 0; + } + .iamccs-v3-analog-scrub::-webkit-slider-thumb { + appearance: none; + width: 22px; + height: 22px; + margin-top: -1px; + border: 2px solid #F4EFE7; + border-radius: 999px; + background: radial-gradient(circle at 35% 30%, #FFF8EC 0%, #D89B45 42%, #8F5427 100%); + box-shadow: 0 2px 8px rgba(0,0,0,.55); + } + .iamccs-v3-analog-scrub::-moz-range-track { + height: 20px; + background: transparent; + border: 0; + } + .iamccs-v3-analog-scrub::-moz-range-thumb { + width: 20px; + height: 20px; + border: 2px solid #F4EFE7; + border-radius: 999px; + background: #D89B45; + box-shadow: 0 2px 8px rgba(0,0,0,.55); + } + `; + let isPlaying = false; + let isLooping = false; + let playFrame = 0; + let playTimer = null; + let audioContext = null; + let activeAudioNodes = []; + let audioBufferCache = new Map(); + let playbackStartFrame = 0; + let playbackStartTimestamp = 0; + let dragState = null; + let previewSegments = null; + let previewAudioSegments = null; + let pendingAudioInsertFrame = null; + let pendingAudioTrack = 0; + let drawRaf = 0; + let transitionAppliedStamp = 0; + playbar.append(scrubStyle, playBtn, loopBtn, timeReadout, scrub); + timelineCanvas.append(ruler, timelineBox); + timelineViewport.appendChild(timelineCanvas); + root.append(timelineViewport, playbar); + + const inspector = document.createElement("div"); + inspector.style.cssText = `display:${collapsed ? "none" : "block"};`; + const boxList = document.createElement("div"); + boxList.style.cssText = `border:1px solid ${purple.borderSoft};background:${purple.panel};border-radius:6px;padding:6px;overflow-y:auto;overflow-x:hidden;box-sizing:border-box;max-height:300px;`; + const editBox = document.createElement("div"); + editBox.style.cssText = "display:none;"; + inspector.append(boxList, editBox); + root.append(inspector); + + function drawRefs() { + refsGrid.innerHTML = ""; + const paths = refPaths(); + paths.forEach((path, index) => { + const card = document.createElement("div"); + card.style.cssText = `position:relative;flex:0 0 132px;height:72px;border:1px solid ${purple.borderSoft};border-radius:5px;overflow:hidden;background:#050308;`; + const img = document.createElement("img"); + img.src = previewUrlForPath(path); + img.style.cssText = "width:100%;height:100%;object-fit:cover;display:block;"; + const badge = document.createElement("div"); + badge.textContent = `ref ${index + 1}`; + badge.style.cssText = "position:absolute;left:4px;bottom:3px;background:rgba(0,0,0,.72);color:#fff;font-size:10px;padding:1px 5px;border-radius:3px;"; + card.append(img, badge); + refsGrid.appendChild(card); + }); + } + + function frameLabel(frame) { + return `${(frame / getFps()).toFixed(2)}s`; + } + + function drawRuler() { + ruler.innerHTML = ""; + const total = getTotalFrames(); + const seconds = Math.max(0.001, getDuration()); + const step = 0.5; + const baseLine = document.createElement("div"); + baseLine.style.cssText = `position:absolute;left:0;right:0;bottom:0;height:1px;background:${purple.border};opacity:.85;pointer-events:none;`; + ruler.appendChild(baseLine); + for (let s = 0; s <= seconds + 0.001; s += step) { + const major = Math.abs(s - Math.round(s)) < 0.001; + const five = Math.abs((s / 5) - Math.round(s / 5)) < 0.001; + const pos = (s / seconds) * 100; + const tick = document.createElement("div"); + tick.style.cssText = [ + "position:absolute", + `left:calc(${pos}% - ${five ? 1 : 0.5}px)`, + "bottom:0", + `width:${five ? 2 : 1}px`, + `height:${five ? 32 : major ? 24 : 10}px`, + `background:${five ? purple.play : major ? purple.accent : "#7E766C"}`, + `opacity:${five ? 1 : major ? 0.82 : 0.46}`, + "pointer-events:none", + ].join(";"); + ruler.appendChild(tick); + if (!major) continue; + const label = document.createElement("div"); + label.style.cssText = [ + "position:absolute", + `left:${pos}%`, + "top:4px", + "transform:translateX(5px)", + `color:${five ? "#F4D59D" : "#D8E2E5"}`, + "font-size:11px", + "font-weight:800", + "line-height:1", + "text-shadow:0 1px 2px #000", + "pointer-events:none", + ].join(";"); + label.textContent = `${s.toFixed(0)}s`; + ruler.appendChild(label); + } + const last = document.createElement("div"); + last.style.cssText = `position:absolute;right:6px;top:5px;color:${purple.muted};font-size:10px;font-weight:800;background:rgba(0,0,0,.42);padding:2px 5px;border-radius:4px;pointer-events:none;`; + last.textContent = `${total}f`; + ruler.appendChild(last); + } + + function updatePlayUI() { + const total = getTotalFrames(); + playFrame = Math.max(0, Math.min(total, Math.round(playFrame))); + scrub.max = String(total); + scrub.value = String(playFrame); + scrub.style.setProperty("--iamccs-play-progress", `${total ? (playFrame / total) * 100 : 0}%`); + timeReadout.textContent = `${(playFrame / getFps()).toFixed(2)}s`; + playBtn.textContent = isPlaying ? "Pause" : "Play"; + loopBtn.style.borderColor = isLooping ? purple.play : purple.border; + loopBtn.style.color = isLooping ? "#FFF2B8" : purple.text; + } + + function stopAudioNodes() { + activeAudioNodes.forEach((node) => { + try { node.stop(); } catch {} + try { node.disconnect(); } catch {} + }); + activeAudioNodes = []; + } + + async function audioBufferForSegment(seg) { + if (!seg || (!seg.audioFile && !seg.audioB64)) return null; + const key = seg.audioFile || `${seg.id || "audio"}:${String(seg.audioB64 || "").slice(0, 64)}`; + if (audioBufferCache.has(key)) return audioBufferCache.get(key); + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + if (!AudioContextClass) return null; + if (!audioContext) audioContext = new AudioContextClass(); + let arrayBuffer = null; + if (seg.audioFile) { + const parts = String(seg.audioFile || "").split("/"); + const filename = parts.pop() || ""; + const subfolder = parts.join("/"); + const audioUrl = api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input&subfolder=${encodeURIComponent(subfolder)}`); + const resp = await fetch(audioUrl); + if (!resp.ok) throw new Error(`audio fetch failed: ${resp.status}`); + arrayBuffer = await resp.arrayBuffer(); + } else if (seg.audioB64) { + let encoded = String(seg.audioB64 || ""); + if (encoded.includes(",")) encoded = encoded.split(",").pop(); + const binary = window.atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + arrayBuffer = bytes.buffer; + } + if (!arrayBuffer) return null; + const decoded = await audioContext.decodeAudioData(arrayBuffer.slice(0)); + audioBufferCache.set(key, decoded); + return decoded; + } + + async function scheduleAudioFromFrame(frame) { + stopAudioNodes(); + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + if (!AudioContextClass) return; + if (!audioContext) audioContext = new AudioContextClass(); + try { if (audioContext.state !== "running") await audioContext.resume(); } catch {} + const fps = getFps(); + const startFrame = Math.max(0, Math.round(Number(frame || 0))); + const playToken = Symbol("audio_play"); + scheduleAudioFromFrame._token = playToken; + for (const seg of timeline.audioSegments || []) { + const segStart = Math.max(0, Math.round(Number(seg.start || 0))); + const segLength = Math.max(1, Math.round(Number(seg.length || 1))); + const segEnd = segStart + segLength; + if (segEnd <= startFrame) continue; + try { + const buffer = await audioBufferForSegment(seg); + if (!buffer || scheduleAudioFromFrame._token !== playToken || !isPlaying) return; + const source = audioContext.createBufferSource(); + const gain = audioContext.createGain(); + gain.gain.value = Math.max(0, Math.min(2, Number(seg.gain ?? seg.volume ?? 1) || 1)); + source.buffer = buffer; + source.connect(gain); + gain.connect(audioContext.destination); + const skipFrames = Math.max(0, startFrame - segStart); + const fileOffset = (Math.max(0, Number(seg.trimStart || 0)) + skipFrames) / fps; + const wait = Math.max(0, segStart - startFrame) / fps; + const playDuration = Math.max(0, (segLength - skipFrames) / fps); + if (playDuration <= 0) continue; + source.start(audioContext.currentTime + wait, Math.max(0, fileOffset), playDuration); + source.onended = () => { + activeAudioNodes = activeAudioNodes.filter((item) => item !== source); + }; + activeAudioNodes.push(source); + } catch (err) { + console.warn("[IAMCCS Cine Shotboard V3] audio preview failed", err); + } + } + } + + function stopPlayback() { + isPlaying = false; + if (playTimer) clearInterval(playTimer); + playTimer = null; + stopAudioNodes(); + updatePlayUI(); + } + + function startPlayback() { + if (isPlaying) return; + isPlaying = true; + if (playFrame >= getTotalFrames()) playFrame = 0; + playbackStartFrame = playFrame; + playbackStartTimestamp = performance.now(); + scheduleAudioFromFrame(playFrame); + playTimer = setInterval(() => { + const elapsedFrames = Math.floor(((performance.now() - playbackStartTimestamp) / 1000) * getFps()); + playFrame = playbackStartFrame + elapsedFrames; + if (playFrame > getTotalFrames()) { + if (isLooping) { + playFrame = 0; + playbackStartFrame = 0; + playbackStartTimestamp = performance.now(); + scheduleAudioFromFrame(0); + } + else { + playFrame = getTotalFrames(); + stopPlayback(); + } + } + draw(); + }, Math.max(16, Math.round(1000 / Math.max(12, getFps())))); + updatePlayUI(); + } + + const cloneSegments = (items) => JSON.parse(JSON.stringify(items || [])); + const activeVisualSegments = () => previewSegments || timeline.segments || []; + const activeAudioSegments = () => previewAudioSegments || timeline.audioSegments || []; + const visibleAudioSegments = () => { + const items = activeAudioSegments(); + const media = items.filter((seg) => audioSegmentHasMedia(seg) && !seg.placeholder); + if (!media.length) return items; + return items.filter((seg) => { + if (audioSegmentHasMedia(seg) && !seg.placeholder) return true; + return !media.some((clip) => + Number(clip.track || 0) === Number(seg?.track || 0) + && segmentRangesOverlap(clip.start, clip.length, seg?.start, seg?.length) + ); + }); + }; + function isTimelineImageSegment(seg) { + return String(seg?.type || "image") === "image" && !seg?.placeholder; + } + function isActionBridgeRelaySegment(seg) { + return String(seg?.type || "") === "text" && Boolean(seg?.actionBridgeSourceId); + } + function sortedActionBridgeSources(items = timeline.segments || []) { + return (items || []) + .filter(isTimelineImageSegment) + .slice() + .sort((a, b) => Number(a.start || 0) - Number(b.start || 0)); + } + const sortedTimelineVisualSegments = () => sortedActionBridgeSources(timeline.segments || []); + const sortedTimelineDirectorSegments = () => (timeline.segments || []) + .filter((seg) => String(seg.type || "image") !== "audio" && !seg.placeholder) + .slice() + .sort((a, b) => Number(a.start || 0) - Number(b.start || 0)); + const stepTransitionAvailableSeconds = (seg) => { + const fps = getFps(); + const sorted = sortedTimelineVisualSegments(); + const index = sorted.findIndex((item) => item.id === seg?.id); + if (index < 0 || index >= sorted.length - 1) return 0; + const next = sorted[index + 1]; + return Math.max(0, (Number(next.start || 0) - Number(seg.start || 0)) / fps); + }; + const fitStepTransitionDuration = (seg, seconds) => { + const fps = getFps(); + const desiredFrames = Math.max(1, Math.round(Math.max(0, Number(seconds || 0)) * fps)); + if (!seg || desiredFrames <= 1) return false; + const sorted = sortedTimelineVisualSegments(); + const index = sorted.findIndex((item) => item.id === seg.id); + if (index < 0 || index >= sorted.length - 1) return false; + const next = sorted[index + 1]; + const requiredNextStart = Math.round(Number(seg.start || 0) + desiredFrames); + const nextStart = Math.round(Number(next.start || 0)); + const delta = requiredNextStart - nextStart; + const idsToShift = new Set(sorted.slice(index + 1).map((item) => item.id)); + timeline.segments = (timeline.segments || []).map((item) => { + if (item.id === seg.id) return { ...item, length: Math.max(1, desiredFrames) }; + if (idsToShift.has(item.id) && delta !== 0) return { ...item, start: Math.max(0, Math.round(Number(item.start || 0) + delta)) }; + return item; + }); + seg.length = Math.max(1, desiredFrames); + ensureDurationForFrames(endOfSegments(timeline.segments)); + showTimelineNotice(`Applied transition timing: ${(desiredFrames / fps).toFixed(2)}s window.`); + return delta !== 0 || Number(seg.length || 1) !== desiredFrames; + }; + const scheduleDraw = () => { + if (drawRaf) return; + drawRaf = requestAnimationFrame(() => { + drawRaf = 0; + draw(); + }); + }; + + function applyCenterDragPhysics(initItems, targetId, targetStart, pointerFrame, durationFrames) { + const items = cloneSegments(initItems); + const targetIndex = items.findIndex((item) => item.id === targetId); + if (targetIndex < 0) return items; + const dragged = items[targetIndex]; + const maxStart = Math.max(0, durationFrames - Number(dragged.length || 1)); + let clampedStart = Math.max(0, Math.min(Math.round(targetStart), maxStart)); + const base = items.filter((item) => item.id !== dragged.id); + + let insertIndex = base.length; + for (let i = 0; i < base.length; i += 1) { + const center = Number(base[i].start || 0) + Number(base[i].length || 1) / 2; + if (pointerFrame < center) { + insertIndex = i; + break; + } + } + + const leftBound = insertIndex > 0 ? Number(base[insertIndex - 1].start || 0) + Number(base[insertIndex - 1].length || 1) : 0; + const rightBound = insertIndex < base.length ? Number(base[insertIndex].start || 0) : durationFrames; + if (rightBound - leftBound >= Number(dragged.length || 1)) { + clampedStart = Math.max(leftBound, Math.min(clampedStart, rightBound - Number(dragged.length || 1))); + } else { + clampedStart = (leftBound + rightBound) / 2 - Number(dragged.length || 1) / 2; + } + + const test = []; + for (let i = 0; i < insertIndex; i += 1) test.push({ ...base[i], original_start: Number(base[i].start || 0) }); + test.push({ ...dragged, start: clampedStart, original_start: clampedStart }); + const draggedIndex = insertIndex; + for (let i = insertIndex; i < base.length; i += 1) test.push({ ...base[i], original_start: Number(base[i].start || 0) }); + + for (let i = draggedIndex + 1; i < test.length; i += 1) { + const prev = test[i - 1]; + test[i].start = Math.max(Number(test[i].original_start || 0), Number(prev.start || 0) + Number(prev.length || 1)); + } + for (let i = draggedIndex - 1; i >= 0; i -= 1) { + const next = test[i + 1]; + test[i].start = Math.min(Number(test[i].original_start || 0), Number(next.start || 0) - Number(test[i].length || 1)); + } + + let rightCursor = durationFrames; + for (let i = test.length - 1; i >= 0; i -= 1) { + if (Number(test[i].start || 0) + Number(test[i].length || 1) > rightCursor) { + test[i].start = rightCursor - Number(test[i].length || 1); + } + rightCursor = Number(test[i].start || 0); + } + let leftCursor = 0; + for (let i = 0; i < test.length; i += 1) { + if (Number(test[i].start || 0) < leftCursor) test[i].start = leftCursor; + leftCursor = Number(test[i].start || 0) + Number(test[i].length || 1); + } + + return test.map((item) => { + const clean = { ...item, start: Math.round(Number(item.start || 0)) }; + delete clean.original_start; + return clean; + }); + } + + function edgeDragPreview(initItems, targetId, dragDelta, edge, durationFrames) { + const items = cloneSegments(initItems).sort((a, b) => Number(a.start || 0) - Number(b.start || 0)); + const index = items.findIndex((item) => item.id === targetId); + if (index < 0) return items; + const target = items[index]; + const minLength = 1; + if (edge === "right") { + const oldEnd = Number(target.start || 0) + Number(target.length || 1); + const next = items[index + 1]; + if (next) { + const nextEnd = Number(next.start || 0) + Number(next.length || 1); + const boundary = Math.max(Number(target.start || 0) + minLength, Math.min(oldEnd + dragDelta, nextEnd - minLength)); + target.length = Math.round(boundary - Number(target.start || 0)); + next.start = Math.round(boundary); + next.length = Math.max(minLength, Math.round(nextEnd - boundary)); + } else { + const maxLength = durationFrames - Number(target.start || 0); + target.length = Math.max(minLength, Math.min(Number(target.length || 1) + dragDelta, maxLength)); + } + } else if (edge === "left") { + const oldStart = Number(target.start || 0); + const oldLength = Number(target.length || 1); + const targetEnd = oldStart + oldLength; + const prev = items[index - 1]; + if (prev) { + const prevStart = Number(prev.start || 0); + const boundary = Math.max(prevStart + minLength, Math.min(oldStart + dragDelta, targetEnd - minLength)); + prev.length = Math.round(boundary - prevStart); + target.start = Math.round(boundary); + target.length = Math.max(minLength, Math.round(targetEnd - boundary)); + } else { + const maxStart = targetEnd - minLength; + const nextStart = Math.max(0, Math.min(oldStart + dragDelta, maxStart)); + target.start = Math.round(nextStart); + target.length = Math.max(minLength, oldLength - (nextStart - oldStart)); + } + } + return items; + } + + function startTimelineDrag(event, seg, isAudio = false, edge = "center") { + event.preventDefault(); + event.stopPropagation(); + selectedId = seg.id; + stopPlayback(); + const startX = event.clientX; + const originalStart = Number(seg.start || 0); + dragState = { + kind: edge, + isAudio, + targetId: seg.id, + startX, + originalStart, + initial: cloneSegments(isAudio ? timeline.audioSegments : timeline.segments), + }; + event.currentTarget?.setPointerCapture?.(event.pointerId); + const captureTarget = event.currentTarget; + const pointerId = event.pointerId; + let finished = false; + + const onMove = (move) => { + move.preventDefault(); + if (!dragState) return; + if (typeof move.buttons === "number" && move.buttons === 0) { + finishDrag(); + return; + } + const rect = timelineBox.getBoundingClientRect(); + const widthPx = rect.width || 1; + const deltaFrames = Math.round(((move.clientX - startX) / widthPx) * getTotalFrames()); + let next; + if (edge === "center") { + const pointerFrame = Math.round(((move.clientX - rect.left) / Math.max(1, widthPx)) * getTotalFrames()); + next = applyCenterDragPhysics(dragState.initial, dragState.targetId, dragState.originalStart + deltaFrames, pointerFrame, getTotalFrames()); + } else { + next = edgeDragPreview(dragState.initial, dragState.targetId, deltaFrames, edge, getTotalFrames()); + } + if (isAudio) previewAudioSegments = next; + else previewSegments = next; + scheduleDraw(); + }; + + const finishDrag = () => { + if (finished) return; + finished = true; + window.removeEventListener("pointermove", onMove, true); + window.removeEventListener("pointerup", finishDrag, true); + window.removeEventListener("pointercancel", finishDrag, true); + window.removeEventListener("mouseup", finishDrag, true); + window.removeEventListener("blur", finishDrag, true); + captureTarget?.removeEventListener?.("lostpointercapture", finishDrag, true); + try { + if (captureTarget?.hasPointerCapture?.(pointerId)) captureTarget.releasePointerCapture(pointerId); + } catch (_) {} + if (dragState) { + if (isAudio && previewAudioSegments) timeline.audioSegments = previewAudioSegments; + if (!isAudio && previewSegments) timeline.segments = previewSegments; + } + if (!isAudio) { + const moved = (timeline.segments || []).find((item) => item.id === seg.id); + if (moved && isActionBridgeRelaySegment(moved)) syncActionBridgeSourceFromRelay(moved); + } + previewSegments = null; + previewAudioSegments = null; + dragState = null; + writeTimeline(); + draw(); + }; + + window.addEventListener("pointermove", onMove, { passive: false, capture: true }); + window.addEventListener("pointerup", finishDrag, { passive: false, capture: true }); + window.addEventListener("pointercancel", finishDrag, { passive: false, capture: true }); + window.addEventListener("mouseup", finishDrag, { passive: false, capture: true }); + window.addEventListener("blur", finishDrag, { passive: false, capture: true }); + captureTarget?.addEventListener?.("lostpointercapture", finishDrag, { passive: false, capture: true }); + scheduleDraw(); + } + + function duplicateVisualSegment(seg) { + 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 cloneLength = Math.max(1, Math.floor(oldLength / 2)); + if (oldLength > 1) { + source.length = Math.max(1, oldLength - cloneLength); + } else { + cloneLength = 1; + for (let i = index + 1; i < sorted.length; i += 1) sorted[i].start = Number(sorted[i].start || 0) + 1; + } + const clone = { + ...source, + id: newId("seg"), + start: Number(source.start || 0) + Number(source.length || 1), + length: cloneLength, + label: `${String(source.label || "shot").replace(/_dup\d*$/i, "")}_dup`, + }; + sorted.splice(index + 1, 0, clone); + timeline.segments = sorted; + selectedId = clone.id; + writeTimeline(); + draw(); + } + + function renderAudioWaveform(block, seg) { + const peaks = Array.isArray(seg.waveformPeaks) ? seg.waveformPeaks : []; + const shell = document.createElement("div"); + shell.style.cssText = [ + "position:absolute", + "left:8px", + "right:8px", + "top:6px", + "bottom:6px", + "border-radius:7px", + "overflow:hidden", + "background:linear-gradient(180deg,rgba(9,19,22,.92),rgba(22,15,10,.88))", + "border:1px solid rgba(244,213,158,.22)", + "box-shadow:inset 0 1px 0 rgba(255,255,255,.08), inset 0 -10px 18px rgba(0,0,0,.20)", + "pointer-events:none", + ].join(";"); + const grid = document.createElement("div"); + grid.style.cssText = [ + "position:absolute", + "inset:0", + "background-image:linear-gradient(90deg,rgba(244,213,158,.10) 1px,transparent 1px),linear-gradient(180deg,rgba(143,208,204,.08) 1px,transparent 1px)", + "background-size:16px 100%,100% 50%", + "opacity:.42", + ].join(";"); + shell.appendChild(grid); + const name = document.createElement("div"); + name.textContent = String(seg.fileName || seg.audioFile || "Audio").split(/[\\/]/).pop(); + name.style.cssText = "position:absolute;left:8px;top:4px;right:8px;color:#F4E5C4;font:9px/1 monospace;font-weight:900;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-shadow:0 1px 2px rgba(0,0,0,.85);z-index:2;"; + shell.appendChild(name); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 240 64"); + svg.setAttribute("preserveAspectRatio", "none"); + svg.style.cssText = "position:absolute;left:0;right:0;bottom:0;width:100%;height:100%;z-index:1;"; + const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + const grad = document.createElementNS("http://www.w3.org/2000/svg", "linearGradient"); + grad.setAttribute("id", `iamccs-audio-wave-${String(seg.id || "seg").replace(/[^a-zA-Z0-9_-]/g, "")}`); + grad.setAttribute("x1", "0"); + grad.setAttribute("x2", "1"); + grad.setAttribute("y1", "0"); + grad.setAttribute("y2", "0"); + [["0%", "#8FD0CC"], ["45%", "#F4D49E"], ["100%", "#D8792B"]].forEach(([offset, color]) => { + const stop = document.createElementNS("http://www.w3.org/2000/svg", "stop"); + stop.setAttribute("offset", offset); + stop.setAttribute("stop-color", color); + grad.appendChild(stop); + }); + defs.appendChild(grad); + svg.appendChild(defs); + const center = document.createElementNS("http://www.w3.org/2000/svg", "path"); + center.setAttribute("d", "M0 32 L240 32"); + center.setAttribute("stroke", "rgba(244,229,196,.32)"); + center.setAttribute("stroke-width", "1"); + svg.appendChild(center); + if (peaks.length) { + const count = 96; + const top = []; + const bottom = []; + 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 x = (i / Math.max(1, count - 1)) * 240; + const amp = 4 + peak * 22; + top.push(`${x.toFixed(2)} ${(32 - amp).toFixed(2)}`); + bottom.unshift(`${x.toFixed(2)} ${(32 + amp).toFixed(2)}`); + } + const body = document.createElementNS("http://www.w3.org/2000/svg", "path"); + body.setAttribute("d", `M${top.join(" L")} L${bottom.join(" L")} Z`); + body.setAttribute("fill", `url(#${grad.getAttribute("id")})`); + body.setAttribute("fill-opacity", ".46"); + body.setAttribute("stroke", "#F4D49E"); + body.setAttribute("stroke-width", "1.3"); + body.setAttribute("vector-effect", "non-scaling-stroke"); + svg.appendChild(body); + } else { + const empty = document.createElementNS("http://www.w3.org/2000/svg", "path"); + empty.setAttribute("d", "M0 36 C38 23 72 45 112 31 S184 20 240 34"); + empty.setAttribute("fill", "none"); + empty.setAttribute("stroke", "#8FD0CC"); + empty.setAttribute("stroke-opacity", ".45"); + empty.setAttribute("stroke-width", "2"); + svg.appendChild(empty); + } + shell.appendChild(svg); + block.appendChild(shell); + } + + function openAppendImagePicker(targetId = null) { + pendingImageTargetId = targetId; + pendingImageInsertFrame = null; + fileInput.click(); + } + + function textRelaySegment(start, length, label = "text_relay_slot") { + return { + id: newId("txt"), + type: "text", + textPlaceholder: true, + start: Math.max(0, Math.round(Number(start || 0))), + length: Math.max(1, Math.round(Number(length || defaultLen()))), + ref: 0, + label, + prompt: "", + note: "", + camera: "continuous cinematic action", + transition: "prompt_relay_text", + guideStrength: 0, + imageLockStrength: 0, + defaultForceSource: 0, + forceCustom: true, + use_guide: false, + use_prompt: false, + }; + } + + function syncActionBridgeSourceFromRelay(relay) { + if (!isActionBridgeRelaySegment(relay)) return false; + const source = (timeline.segments || []).find((item) => item.id === relay.actionBridgeSourceId); + if (!source) return false; + const prompt = String(relay.prompt || "").trim(); + source.step_transition_prompt = String(relay.prompt || ""); + source.step_transition_duration = Math.max(0, Math.round(Number(relay.length || 1)) / getFps()); + source.step_transition_enabled = Boolean(prompt); + if (prompt && String(source.step_transition_type || "off") === "off") source.step_transition_type = "action_beat"; + source.use_prompt = Boolean(prompt || source.use_prompt); + return true; + } + + function syncActionBridgeRelaySegment(source, options = {}) { + if (!isTimelineImageSegment(source)) return null; + const forceTiming = Boolean(options.forceTiming); + const sourceId = String(source.id || ""); + const type = String(source.step_transition_type || "off"); + const prompt = String(source.step_transition_prompt || "").trim(); + const seconds = Math.max(0, Number(source.step_transition_duration || 0) || 0); + const existing = (timeline.segments || []).find((item) => isActionBridgeRelaySegment(item) && String(item.actionBridgeSourceId) === sourceId); + const active = Boolean(source.step_transition_enabled && type !== "off" && prompt && seconds > 0); + if (!active) { + if (existing) timeline.segments = (timeline.segments || []).filter((item) => item.id !== existing.id); + return null; + } + const fps = getFps(); + const length = Math.max(1, Math.round(seconds * fps)); + const start = Math.max(0, Math.round(Number(source.start || 0) + Number(source.length || 1))); + let relay = existing; + if (!relay) { + (timeline.segments || []).forEach((item) => { + if (item.id !== source.id && Number(item.start || 0) >= start) { + item.start = Math.max(0, Math.round(Number(item.start || 0) + length)); + } + }); + relay = textRelaySegment(start, length, "action_bridge_relay"); + relay.actionBridgeSourceId = sourceId; + relay.actionBridgeManaged = true; + relay.transition = "prompt_relay_action_bridge"; + relay.camera = "continuous relay bridge"; + relay.use_prompt = true; + relay.prompt = String(source.step_transition_prompt || ""); + relay.note = String(source.step_transition_prompt || ""); + timeline.segments = (timeline.segments || []).concat(relay); + } else { + relay.type = "text"; + relay.textPlaceholder = true; + relay.actionBridgeManaged = true; + relay.use_prompt = true; + relay.transition = relay.transition || "prompt_relay_action_bridge"; + relay.camera = relay.camera || "continuous relay bridge"; + relay.prompt = String(source.step_transition_prompt || ""); + relay.note = String(source.step_transition_prompt || ""); + relay.label = relay.label || "action_bridge_relay"; + if (forceTiming) { + relay.start = start; + relay.length = length; + } + } + relay.length = forceTiming || !Number(relay.length || 0) ? length : Math.max(1, Math.round(Number(relay.length || length))); + relay.ref = 0; + relay.guideStrength = 0; + relay.imageLockStrength = 0; + relay.defaultForceSource = 0; + relay.forceCustom = true; + relay.use_guide = false; + ensureDurationForFrames(endOfSegments(timeline.segments)); + return relay; + } + + function syncAllActionBridgeRelaySegments(options = {}) { + const sources = sortedActionBridgeSources(timeline.segments || []); + const sourceIds = new Set(sources.map((item) => String(item.id || ""))); + timeline.segments = (timeline.segments || []).filter((item) => !isActionBridgeRelaySegment(item) || sourceIds.has(String(item.actionBridgeSourceId || ""))); + sources.forEach((source) => syncActionBridgeRelaySegment(source, options)); + } + + function createTailTextPlaceholder() { + const total = getTotalFrames(); + const cursor = Math.max(0, Math.min(endOfSegments(activeVisualSegments()), Math.max(0, total - 1))); + const length = Math.min(defaultLen(), Math.max(1, total - cursor)); + const seg = textRelaySegment(cursor, length); + timeline.segments = (timeline.segments || []).concat(seg).sort((a, b) => Number(a.start || 0) - Number(b.start || 0)); + selectedId = seg.id; + writeTimeline(); + draw(); + } + + function createPlaceholderAfterSegment(seg, kind = "image") { + 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 start = Number(source.start || 0) + Number(source.length || 1); + const next = sorted[index + 1]; + if (kind === "image" && next?.placeholder && Math.abs(Number(next.start || 0) - start) <= 1) { + selectedId = next.id; + draw(); + return; + } + if (kind === "text" && String(next?.type || "") === "text" && next?.textPlaceholder && Math.abs(Number(next.start || 0) - start) <= 1) { + selectedId = next.id; + draw(); + return; + } + const length = defaultLen(); + ensureDurationForFrames(endOfSegments(sorted) + length); + for (let i = index + 1; i < sorted.length; i += 1) { + sorted[i].start = Math.round(Number(sorted[i].start || 0) + length); + } + const placeholder = kind === "text" ? textRelaySegment(start, length) : { + id: newId("slot"), + type: "image", + placeholder: true, + start, + length, + 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(); + draw(); + } + + function openTimelineAddMenu(event, seg = null, targetId = null) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + root.querySelectorAll(".iamccs-v3-add-menu").forEach((item) => item.remove()); + const menu = document.createElement("div"); + menu.className = "iamccs-v3-add-menu"; + const rootRect = root.getBoundingClientRect(); + const x = Math.max(8, Math.min(rootRect.width - 188, Number(event?.clientX || rootRect.left + 24) - rootRect.left)); + const y = Math.max(8, Math.min(rootRect.height - 122, Number(event?.clientY || rootRect.top + 24) - rootRect.top)); + menu.style.cssText = [ + "position:absolute", + `left:${x}px`, + `top:${y}px`, + "width:180px", + "box-sizing:border-box", + `border:1px solid ${purple.border}`, + "border-radius:6px", + `background:${purple.valueBg}`, + `color:${purple.valueText}`, + "box-shadow:0 12px 28px rgba(0,0,0,.42)", + "padding:6px", + "display:grid", + "gap:5px", + "z-index:999", + "font:10px/1.15 monospace", + "font-weight:900", + ].join(";"); + const addChoice = (label, title, action) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = label; + btn.title = title; + btn.style.cssText = `height:28px;border:1px solid ${purple.borderSoft};border-radius:4px;background:#F4EFE6;color:#14110E;cursor:pointer;font:10px/1 monospace;font-weight:900;text-align:left;padding:0 9px;`; + btn.onpointerdown = (pointerEvent) => { pointerEvent.preventDefault(); pointerEvent.stopPropagation(); }; + btn.onclick = (clickEvent) => { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + menu.remove(); + action(); + }; + menu.appendChild(btn); + }; + addChoice("Text Relay Slot", "Create a resizable prompt-only segment for Prompt Relay", () => { + if (seg) createPlaceholderAfterSegment(seg, "text"); + else createTailTextPlaceholder(); + }); + addChoice("Image Slot", "Create or fill an image guide slot", () => { + if (seg) createPlaceholderAfterSegment(seg, "image"); + else openAppendImagePicker(targetId); + }); + addChoice("Audio", "Import audio at this timeline position", () => { + const frame = seg ? Number(seg.start || 0) + Number(seg.length || 0) : endOfSegments(activeVisualSegments()); + pendingAudioInsertFrame = Math.max(0, Math.round(frame)); + pendingAudioTrack = 0; + audioInput.click(); + }); + root.appendChild(menu); + const close = (closeEvent) => { + if (!menu.contains(closeEvent.target)) { + menu.remove(); + document.removeEventListener("pointerdown", close, true); + } + }; + setTimeout(() => document.addEventListener("pointerdown", close, true), 0); + return menu; + } + + function makeImagePlaceholderBlock() { + const total = getTotalFrames(); + const cursor = endOfSegments(activeVisualSegments()); + const start = Math.max(0, Math.min(cursor, Math.max(0, total - 1))); + const remaining = Math.max(1, total - start); + const length = Math.min(defaultLen(), remaining); + const normalWidth = Math.max(5, (length / Math.max(1, total)) * 100); + const left = cursor >= total ? Math.max(0, 100 - normalWidth) : (start / Math.max(1, total)) * 100; + const block = document.createElement("button"); + block.type = "button"; + block.title = "Add text, image or audio to the next empty slot"; + block.style.cssText = [ + "position:absolute", + `left:${left}%`, + `width:${normalWidth}%`, + "top:8px", + "height:222px", + "box-sizing:border-box", + `border:1px dashed ${purple.border}`, + "border-radius:4px", + "background:linear-gradient(180deg,rgba(244,239,231,.08),rgba(0,0,0,.12))", + `color:${purple.valueBg}`, + "cursor:pointer", + "display:flex", + "align-items:center", + "justify-content:center", + "font-size:30px", + "font-weight:900", + "box-shadow:inset 0 0 0 1px rgba(255,255,255,.05)", + "z-index:2", + ].join(";"); + block.textContent = "+"; + block.onpointerdown = (event) => { event.preventDefault(); event.stopPropagation(); }; + block.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + openTimelineAddMenu(event, null, null); + }; + block.ondblclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + openTimelineAddMenu(event, null, null); + }; + return protectControlDrag(block); + } + + function computeTimelineCanvasWidth(segments = activeVisualSegments()) { + const viewportWidth = Math.max(1, Number(timelineViewport?.clientWidth || 0) || (SHOTBOARD_V3_RIGID_WIDTH - 32)); + timelineMeterSeconds = clampTimelineMeterSeconds(timelineMeterSeconds); + const duration = Math.max(0.5, getDuration()); + const halfSecondSteps = Math.round((timelineMeterSeconds - duration) * 2); + const visualScale = Math.max(0.2, Math.min(8, Math.pow(1.18, halfSecondSteps))); + const meterWidth = viewportWidth * visualScale; + const minUsableWidth = Math.min(viewportWidth, 360); + return Math.max(minUsableWidth, Math.min(12000, Math.ceil(meterWidth))); + } + + function makeBlock(seg, isAudio = false) { + const total = getTotalFrames(); + const block = document.createElement("div"); + 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 imageHeight = 108; + const transitionLaneTop = 108; + const transitionLaneHeight = 20; + const promptTop = frameShellHeight + 10; + const promptHeight = 74; + const selected = selectedId === seg.id; + const color = isAudio ? purple.audio : (String(seg.type) === "text" ? purple.textBlock : purple.image2); + block.style.cssText = [ + "position:absolute", + `left:${left}%`, + `width:${width}%`, + `min-width:${isAudio ? 20 : 30}px`, + `top:${top}px`, + `height:${height}px`, + `background:${color}`, + `border:1px solid ${selected ? purple.accent : purple.borderSoft}`, + "border-radius:4px", + "box-sizing:border-box", + "overflow:hidden", + "cursor:grab", + "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;`; + 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.onpointerdown = (event) => { event.preventDefault(); event.stopPropagation(); }; + addAfter.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + openTimelineAddMenu(event, seg, null); + }; + block.appendChild(addAfter); + }; + if (!isAudio && String(seg.type || "image") !== "text") { + const refNumber = Number(seg.ref || 0); + const path = refNumber >= 1 ? refPaths()[Math.max(0, refNumber - 1)] : ""; + if (path) { + const img = document.createElement("img"); + img.src = previewUrlForPath(path); + 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); + const replaceImage = document.createElement("button"); + replaceImage.type = "button"; + replaceImage.textContent = "+"; + replaceImage.title = "Replace this image, keeping the same duration, prompt and timing"; + replaceImage.style.cssText = [ + "position:absolute", + "left:calc(50% + 12px)", + "top:54px", + "transform:translate(-50%,-50%)", + "width:28px", + "height:28px", + `border:1px solid ${purple.border}`, + "border-radius:999px", + `background:${purple.valueBg}`, + `color:${purple.valueText}`, + "font-size:18px", + "font-weight:900", + "line-height:1", + "cursor:pointer", + "box-shadow:0 3px 10px rgba(0,0,0,.38)", + "opacity:.78", + "z-index:8", + ].join(";"); + replaceImage.onpointerdown = (event) => { event.preventDefault(); event.stopPropagation(); }; + replaceImage.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + openAppendImagePicker(seg.id); + }; + block.appendChild(replaceImage); + } else { + const plus = document.createElement("button"); + 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.onpointerdown = (event) => { event.preventDefault(); event.stopPropagation(); }; + plus.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + openAppendImagePicker(seg.id); + }; + block.appendChild(plus); + } + } + if (!isAudio) appendAddAfterButton(); + if (isAudio) { + const removeAudio = document.createElement("button"); + removeAudio.type = "button"; + removeAudio.textContent = "X"; + removeAudio.title = "Remove this audio clip"; + removeAudio.style.cssText = `position:absolute;right:5px;top:5px;width:22px;height:22px;border:1px solid ${purple.danger};border-radius:999px;background:#6B302A;color:#FFF2E4;font-size:10px;font-weight:900;line-height:1;cursor:pointer;box-shadow:0 2px 7px rgba(0,0,0,.40);z-index:20;`; + removeAudio.onpointerdown = (event) => { event.preventDefault(); event.stopPropagation(); }; + removeAudio.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + timeline.audioSegments = (timeline.audioSegments || []).filter((item) => item.id !== seg.id); + selectedId = timeline.segments[0]?.id || null; + writeTimeline({ force: true }); + draw(); + }; + block.appendChild(removeAudio); + renderAudioWaveform(block, seg); + } + content.textContent = isAudio ? String(seg.name || "audio") : (String(seg.type || "image") === "text" ? String(seg.label || "text") : ""); + if (isAudio || String(seg.type || "image") === "text") block.appendChild(content); + if (!isAudio) { + const transitionLane = document.createElement("div"); + transitionLane.style.cssText = [ + "position:absolute", + "left:24px", + "right:0", + `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))", + `border-top:1px solid ${purple.borderSoft}`, + `border-bottom:1px solid ${purple.borderSoft}`, + "box-sizing:border-box", + "pointer-events:none", + "z-index:4", + ].join(";"); + block.appendChild(transitionLane); + } + if (!isAudio) { + const rail = document.createElement("div"); + rail.style.cssText = `position:absolute;left:0;top:0;width:24px;height:${frameShellHeight}px;display:grid;grid-template-rows:repeat(3,1fr);background:rgba(0,0,0,.45);z-index:15;`; + const railBtn = (label, titleText, action) => { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = label; + b.title = titleText; + b.style.cssText = "border:0;border-bottom:1px solid rgba(255,255,255,.22);background:transparent;color:#fff;font-size:10px;font-weight:900;cursor:pointer;padding:0;"; + b.onpointerdown = (event) => { event.preventDefault(); event.stopPropagation(); }; + b.onmousedown = (event) => { event.preventDefault(); event.stopPropagation(); }; + b.onclick = (event) => { event.stopPropagation(); action(); }; + return protectControlDrag(b); + }; + rail.append( + railBtn("E", "Open frame editor for this reference", () => { + selectedId = seg.id; + const refIndex = Math.max(0, Number(seg.ref || 1) - 1); + const path = refPaths()[refIndex]; + if (path) { + openReferenceFrameEditor(node, refIndex, path, (newPath) => { + replaceReferencePathAt(node, refIndex, newPath); + draw(); + }); + } else { + openAppendImagePicker(seg.id); + } + }), + railBtn("D", "Duplicate block", () => { + duplicateVisualSegment(seg); + }), + railBtn("X", "Delete block", () => { + rippleDeleteVisualSegment(seg); + }) + ); + block.appendChild(rail); + } + if (!isAudio) { + const caption = document.createElement("textarea"); + caption.value = String(seg.prompt || ""); + caption.placeholder = "Action in this segment..."; + 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.onpointerdown = (event) => event.stopPropagation(); + caption.onclick = (event) => event.stopPropagation(); + caption.ondblclick = (event) => event.stopPropagation(); + caption.oninput = () => { + seg.prompt = caption.value; + seg.use_prompt = Boolean(String(caption.value || "").trim()); + if (isActionBridgeRelaySegment(seg)) syncActionBridgeSourceFromRelay(seg); + syncSegmentTextPeers(seg.id, "prompt", caption.value, caption); + syncSegmentRelayPeers(seg.id, Boolean(seg.use_prompt), null); + writeTimeline(); + }; + caption.onchange = flushTimelineWrite; + caption.onblur = flushTimelineWrite; + protectControlDrag(caption); + 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.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); + + block.onpointerdown = (event) => { + if (event.target === resize || resize.contains(event.target) || event.target === resizeLeft || resizeLeft.contains(event.target)) 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 (!isAudio) { + block.ondblclick = (event) => { + if (event.target?.tagName === "TEXTAREA") return; + event.preventDefault(); + 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); + }; + } + block.onclick = () => { selectedId = seg.id; draw(); }; + return block; + } + + function drawActionLaneSegments(segments) { + const total = Math.max(1, getTotalFrames()); + const fps = getFps(); + actionTrack.innerHTML = ""; + const visualSegments = (segments || []) + .filter(isTimelineImageSegment) + .slice() + .sort((a, b) => Number(a.start || 0) - Number(b.start || 0)); + if (!visualSegments.length) { + const empty = document.createElement("div"); + empty.textContent = "Action Lane: add image frames, then write what happens between them."; + empty.style.cssText = `position:absolute;left:10px;right:10px;top:10px;height:26px;display:flex;align-items:center;justify-content:center;border:1px dashed ${purple.borderSoft};border-radius:6px;color:${purple.muted};font:10px/1 monospace;font-weight:800;`; + actionTrack.appendChild(empty); + return; + } + visualSegments.forEach((seg, index) => { + if (index >= visualSegments.length - 1) return; + const next = visualSegments[index + 1]; + const start = Math.max(0, Number(seg.start || 0)); + const nextStart = Math.max(start + 1, Number(next.start || (start + Number(seg.length || 1)))); + const widthFrames = Math.max(1, nextStart - start); + const active = Boolean(seg.step_transition_enabled || String(seg.step_transition_prompt || "").trim()); + const block = document.createElement("div"); + block.style.cssText = [ + "position:absolute", + `left:${(start / total) * 100}%`, + `width:${Math.max(0.8, (widthFrames / total) * 100)}%`, + "top:5px", + "height:60px", + "box-sizing:border-box", + `border:1px solid ${active ? "rgba(223,164,81,.74)" : "rgba(118,103,83,.44)"}`, + "border-radius:6px", + `background:${active ? "linear-gradient(135deg,rgba(58,43,25,.95),rgba(25,54,55,.92))" : "rgba(255,255,255,.035)"}`, + "display:grid", + "grid-template-columns:minmax(78px,94px) minmax(156px,2fr)", + "gap:5px", + "padding:4px", + "overflow:hidden", + "z-index:7", + "pointer-events:none", + ].join(";"); + block.title = `Action between ${String(seg.label || `frame ${index + 1}`)} and ${String(next.label || `frame ${index + 2}`)} | ${(widthFrames / fps).toFixed(2)}s`; + const type = document.createElement("select"); + STEP_TRANSITION_OPTIONS.forEach(({ value, label }) => { + const opt = document.createElement("option"); + opt.value = value; + opt.textContent = label; + type.appendChild(opt); + }); + type.value = STEP_TRANSITION_OPTIONS.some((item) => item.value === String(seg.step_transition_type || "off")) ? String(seg.step_transition_type || "off") : "action_beat"; + type.style.cssText = inputBase() + "height:42px;font-size:9px;font-weight:900;text-align:center;padding:0 4px;background:#F4EFE6;color:#111;"; + type.style.pointerEvents = "auto"; + type.onchange = () => { + seg.step_transition_type = type.value; + seg.step_transition_enabled = type.value !== "off" || Boolean(String(seg.step_transition_prompt || "").trim()); + seg.use_prompt = Boolean(seg.step_transition_enabled || String(seg.prompt || "").trim()); + if (seg.step_transition_enabled && Number(seg.step_transition_duration || 0) <= 0) { + seg.step_transition_duration = defaultStepTransitionSeconds(type.value, widthFrames / fps); + seg.step_transition_arrival = defaultStepTransitionArrival(type.value); + } + syncActionBridgeRelaySegment(seg, { forceTiming: true }); + writeTimeline(); + draw(); + }; + const prompt = document.createElement("textarea"); + prompt.value = String(seg.step_transition_prompt || ""); + prompt.placeholder = "Action to next frame..."; + prompt.rows = 1; + prompt.spellcheck = false; + prompt.dataset.iamccsV3SegmentId = String(seg.id); + prompt.dataset.iamccsV3Key = "step_transition_prompt"; + prompt.style.cssText = inputBase() + `height:42px;min-height:0;resize:none;font-size:${promptFontSize(9)};line-height:1.18;padding:6px 8px;background:#F4EFE6;color:#111;`; + prompt.style.pointerEvents = "auto"; + prompt.onpointerdown = (event) => event.stopPropagation(); + prompt.onclick = (event) => event.stopPropagation(); + prompt.ondblclick = (event) => event.stopPropagation(); + prompt.oninput = () => { + seg.step_transition_prompt = prompt.value; + const hasText = Boolean(String(prompt.value || "").trim()); + seg.step_transition_enabled = hasText || seg.step_transition_enabled; + if (hasText && String(seg.step_transition_type || "off") === "off") { + seg.step_transition_type = "action_beat"; + type.value = "action_beat"; + } + seg.use_prompt = Boolean(hasText || seg.step_transition_enabled || String(seg.prompt || "").trim()); + syncSegmentTextPeers(seg.id, "step_transition_prompt", prompt.value, prompt); + syncActionBridgeRelaySegment(seg, { forceTiming: true }); + writeTimeline(); + }; + prompt.onchange = flushTimelineWrite; + prompt.onblur = flushTimelineWrite; + protectControlDrag(type); + protectControlDrag(prompt); + block.append(type, prompt); + actionTrack.appendChild(block); + }); + } + + function drawStepTransitionBridges(segments) { + const total = Math.max(1, getTotalFrames()); + const visualSegments = (segments || []) + .filter(isTimelineImageSegment) + .slice() + .sort((a, b) => Number(a.start || 0) - Number(b.start || 0)); + visualSegments.forEach((seg, index) => { + if (!seg.step_transition_enabled || index >= visualSegments.length - 1) return; + const next = visualSegments[index + 1]; + const end = Math.max(0, Number(seg.start || 0) + Number(seg.length || 0)); + const nextStart = Math.max(0, Number(next?.start || end)); + const bridgeFrame = Math.max(0, Math.min(total, (end + nextStart) / 2)); + const marker = document.createElement("div"); + marker.title = `${stepTransitionLabel(seg.step_transition_type)}: ${String(seg.step_transition_prompt || "Step transition to next frame")}`; + marker.innerHTML = ` + `; + marker.style.position = "absolute"; + marker.style.left = `${(bridgeFrame / total) * 100}%`; + marker.style.top = "23px"; + marker.style.width = "176px"; + marker.style.height = "70px"; + marker.style.transform = "translateX(-50%)"; + marker.style.transformOrigin = "50% 0"; + marker.style.zIndex = "42"; + marker.style.pointerEvents = "none"; + marker.style.filter = "drop-shadow(0 2px 4px rgba(0,0,0,.48))"; + imageTrack.appendChild(marker); + const fps = getFps(); + const requested = Math.max(0, Number(seg.step_transition_duration || 0) || 0); + const windowFrames = Math.max(1, Math.round((requested > 0 ? requested : Math.max(0.1, (nextStart - Number(seg.start || 0)) / fps)) * fps)); + const bandStart = Math.max(0, Number(seg.start || 0)); + const bandEnd = Math.max(bandStart + 1, Math.min(total, bandStart + windowFrames)); + const band = document.createElement("div"); + band.style.cssText = [ + "position:absolute", + `left:${(bandStart / total) * 100}%`, + `width:${Math.max(0.4, ((bandEnd - bandStart) / total) * 100)}%`, + "top:4px", + "height:108px", + "box-sizing:border-box", + "border:1px solid rgba(223,164,81,.70)", + "border-radius:7px", + "background:linear-gradient(90deg, rgba(223,164,81,.12), rgba(41,132,142,.10))", + "box-shadow:inset 0 0 0 1px rgba(255,255,255,.05)", + "pointer-events:none", + "z-index:18", + ].join(";"); + const label = document.createElement("div"); + const seconds = (bandEnd - bandStart) / fps; + label.textContent = `${stepTransitionLabel(seg.step_transition_type)} ${seconds.toFixed(1)}s`; + label.style.cssText = "position:absolute;left:7px;top:6px;max-width:calc(100% - 14px);padding:2px 6px;border-radius:999px;background:rgba(30,25,20,.86);border:1px solid rgba(223,164,81,.55);color:#F4E5C4;font:9px/1.1 monospace;font-weight:900;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"; + band.appendChild(label); + band.title = `${stepTransitionLabel(seg.step_transition_type)} ${(bandStart / fps).toFixed(2)}s -> ${(bandEnd / fps).toFixed(2)}s`; + imageTrack.appendChild(band); + }); + } + + function rippleDeleteVisualSegment(target) { + const removedStart = Number(target?.start || 0); + const removedLength = Math.max(1, Math.round(Number(target?.length || 1))); + timeline.segments = (timeline.segments || []) + .filter((item) => item.id !== target.id) + .map((item) => { + if (Number(item.start || 0) > removedStart) { + return { ...item, start: Math.max(0, Math.round(Number(item.start || 0) - removedLength)) }; + } + return item; + }); + selectedId = timeline.segments[0]?.id || null; + writeTimeline(); + draw(); + } + + function drawBoxes() { + boxList.innerHTML = ""; + editBox.innerHTML = ""; + const makeField = (seg, labelText, key, type = "input", style = "") => { + const wrap = document.createElement("label"); + wrap.style.cssText = `display:flex;flex-direction:column;gap:4px;color:${purple.muted};font-size:10px;font-weight:800;text-align:center;min-width:0;${style}`; + const span = document.createElement("span"); + span.textContent = labelText; + span.style.cssText = "display:block;text-align:center;line-height:1.2;min-height:13px;overflow:visible;padding-bottom:1px;"; + if (type === "number") { + const isStrengthKey = key === "guideStrength" || key === "imageLockStrength"; + const step = isStrengthKey ? "0.01" : "1"; + const min = "0"; + const max = isStrengthKey ? "1" : null; + const ctrl = numberStepperControl(seg[key] ?? "", step, min, max, (value) => { + let shouldRedraw = true; + if (key === "start") { + const nextStart = Math.max(0, Math.round(Number(value || 0))); + timeline.segments = edgeDragPreview(timeline.segments, seg.id, nextStart - Number(seg.start || 0), "left", getTotalFrames()); + } + else if (key === "length") { + const nextLength = Math.max(1, Math.round(Number(value || 1))); + timeline.segments = edgeDragPreview(timeline.segments, seg.id, nextLength - Number(seg.length || 1), "right", getTotalFrames()); + } + else if (key === "ref") seg.ref = Math.max(1, Math.round(Number(value || 1))); + else if (key === "guideStrength") { + const nextStrength = Math.max(0, Math.min(1, Number(value || 0))); + seg.guideStrength = nextStrength; + seg.imageLockStrength = nextStrength; + seg.forceCustom = true; + delete seg.defaultForceSource; + shouldRedraw = false; + } + else if (key === "imageLockStrength") { + seg.imageLockStrength = Math.max(0, Math.min(1, Number(value || 0))); + shouldRedraw = false; + } + writeTimeline(); + if (shouldRedraw) draw(); + }, { liveInput: false }); + ctrl.style.minWidth = "0"; + ctrl.style.width = "100%"; + ctrl.style.gridTemplateColumns = "28px minmax(42px,1fr) 28px"; + ctrl.style.gap = "7px"; + ctrl.querySelectorAll("button").forEach((button) => { + button.style.minWidth = "28px"; + button.style.width = "28px"; + button.style.margin = "0"; + }); + styleValueControls(ctrl); + wrap.append(span, ctrl); + if (key === "length") { + const fps = getFps(); + const startFrame = Math.round(Number(seg.start || 0)); + const lenFrame = Math.max(1, Math.round(Number(seg.length || 1))); + const endFrame = startFrame + lenFrame; + const info = document.createElement("div"); + info.style.cssText = `display:grid;gap:2px;color:${purple.muted};font-size:9px;font-weight:800;line-height:1.1;text-align:center;white-space:nowrap;`; + const dur = document.createElement("span"); + dur.textContent = `${lenFrame}f / ${(lenFrame / fps).toFixed(2)}s`; + const range = document.createElement("span"); + range.textContent = `${(startFrame / fps).toFixed(2)}s -> ${(endFrame / fps).toFixed(2)}s`; + info.append(dur, range); + wrap.appendChild(info); + } + return wrap; + } + const input = type === "textarea" ? document.createElement("textarea") : document.createElement("input"); + input.value = seg[key] ?? ""; + input.dataset.iamccsV3SegmentId = String(seg.id); + input.dataset.iamccsV3Key = String(key); + input.style.cssText = inputBase() + `background:${purple.valueBg};border-color:${purple.border};color:${purple.valueText};font-weight:${type === "textarea" ? "700" : "800"};text-align:${type === "textarea" ? "left" : "center"};${type === "textarea" ? `min-height:54px;resize:vertical;font-size:${promptFontSize(11)};` : "height:28px;"}`; + input.oninput = () => { + let shouldRedraw = false; + if (key === "start") { + const nextStart = Math.max(0, Math.round(Number(input.value || 0))); + timeline.segments = edgeDragPreview(timeline.segments, seg.id, nextStart - Number(seg.start || 0), "left", getTotalFrames()); + shouldRedraw = true; + } + else if (key === "length") { + const nextLength = Math.max(1, Math.round(Number(input.value || 1))); + timeline.segments = edgeDragPreview(timeline.segments, seg.id, nextLength - Number(seg.length || 1), "right", getTotalFrames()); + shouldRedraw = true; + } + else if (key === "ref") { + seg.ref = Math.max(1, Math.round(Number(input.value || 1))); + shouldRedraw = true; + } + else if (key === "guideStrength") { + const nextStrength = Math.max(0, Math.min(1, Number(input.value || 0))); + seg.guideStrength = nextStrength; + seg.imageLockStrength = nextStrength; + seg.forceCustom = true; + delete seg.defaultForceSource; + shouldRedraw = true; + } + else if (key === "imageLockStrength") { + seg.imageLockStrength = Math.max(0, Math.min(1, Number(input.value || 0))); + shouldRedraw = true; + } + else { + seg[key] = input.value; + if (key === "prompt") { + seg.use_prompt = Boolean(String(input.value || "").trim()); + syncSegmentRelayPeers(seg.id, Boolean(seg.use_prompt), null); + } + if (type === "textarea") syncSegmentTextPeers(seg.id, key, input.value, input); + } + writeTimeline(); + if (shouldRedraw) draw(); + }; + input.onchange = flushTimelineWrite; + input.onblur = flushTimelineWrite; + protectControlDrag(input); + wrap.append(span, input); + return wrap; + }; + const makeStepTransitionControl = (seg, index, total) => { + const wrap = document.createElement("label"); + wrap.style.cssText = `display:grid;grid-template-columns:minmax(170px,210px) minmax(150px,1fr) minmax(170px,230px);gap:8px;align-items:stretch;color:${purple.muted};font-size:10px;font-weight:800;text-align:center;min-width:0;padding:7px;border:1px solid ${seg.step_transition_enabled ? "rgba(223,164,81,.70)" : purple.borderSoft};border-radius:7px;background:${seg.step_transition_enabled ? "rgba(73,50,28,.42)" : "rgba(255,255,255,.025)"};`; + const select = document.createElement("select"); + STEP_TRANSITION_OPTIONS.forEach(({ value, label }) => { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + select.appendChild(option); + }); + const currentType = String(seg.step_transition_type || (seg.step_transition_enabled ? "slow_dolly_in" : "off")); + select.value = STEP_TRANSITION_OPTIONS.some((item) => item.value === currentType) ? currentType : "slow_dolly_in"; + select.disabled = index >= total - 1; + select.title = index >= total - 1 ? "Last box has no next frame" : "Transition instruction from this box to the next box"; + select.style.cssText = inputBase() + `height:27px;line-height:27px;padding-top:0;padding-bottom:0;background:${purple.valueBg};border-color:${seg.step_transition_enabled ? "rgba(223,164,81,.80)" : purple.border};color:${purple.valueText};font-weight:800;text-align:center;`; + select.onpointerdown = (event) => event.stopPropagation(); + select.onchange = () => { + seg.step_transition_type = select.value; + seg.step_transition_enabled = select.value !== "off"; + if (seg.step_transition_enabled) { + seg.use_prompt = true; + if (Number(seg.step_transition_duration || 0) <= 0) { + seg.step_transition_duration = defaultStepTransitionSeconds(select.value, stepTransitionAvailableSeconds(seg)); + seg.step_transition_auto_fit = true; + seg.step_transition_arrival = defaultStepTransitionArrival(select.value); + } + else if (!seg.step_transition_arrival || seg.step_transition_arrival === "auto") { + seg.step_transition_arrival = defaultStepTransitionArrival(select.value); + } + } + syncActionBridgeRelaySegment(seg, { forceTiming: true }); + writeTimeline(); + draw(); + }; + const prompt = document.createElement("input"); + prompt.type = "text"; + prompt.placeholder = "optional positive motion note"; + prompt.value = String(seg.step_transition_prompt || ""); + prompt.disabled = index >= total - 1; + prompt.style.cssText = inputBase() + `height:24px;background:${purple.valueBg};border-color:${purple.border};color:${purple.valueText};font-weight:700;text-align:left;font-size:${promptFontSize(9)};`; + prompt.onpointerdown = (event) => event.stopPropagation(); + prompt.oninput = () => { + seg.step_transition_prompt = prompt.value; + syncSegmentTextPeers(seg.id, "step_transition_prompt", prompt.value, prompt); + if (String(prompt.value || "").trim() && String(seg.step_transition_type || "off") === "off") { + seg.step_transition_type = "slow_dolly_in"; + seg.step_transition_enabled = true; + seg.use_prompt = true; + select.value = "slow_dolly_in"; + } + syncActionBridgeRelaySegment(seg, { forceTiming: true }); + writeTimeline(); + }; + prompt.onchange = flushTimelineWrite; + prompt.onblur = flushTimelineWrite; + protectControlDrag(select); + protectControlDrag(prompt); + const durationSeconds = Math.max(0, Number(seg.step_transition_duration || 0) || 0); + const availableSeconds = stepTransitionAvailableSeconds(seg); + const durationWrap = document.createElement("div"); + durationWrap.style.cssText = "display:grid;grid-template-columns:minmax(82px,.72fr) minmax(96px,1fr);gap:6px;align-items:end;min-width:0;"; + const durationCtrl = numberStepperControl(durationSeconds, "0.1", "0", null, (value) => { + const nextSeconds = Math.max(0, Number(value || 0)); + seg.step_transition_duration = nextSeconds; + seg.step_transition_enabled = nextSeconds > 0 ? true : seg.step_transition_enabled; + if (nextSeconds > 0 && String(seg.step_transition_type || "off") === "off") { + seg.step_transition_type = "slow_dolly_in"; + seg.use_prompt = true; + } + syncActionBridgeRelaySegment(seg, { forceTiming: true }); + writeTimeline(); + draw(); + }, { liveInput: false }); + durationCtrl.style.gridTemplateColumns = "24px minmax(42px,1fr) 24px"; + durationCtrl.style.gap = "5px"; + durationCtrl.style.minWidth = "0"; + durationCtrl.querySelectorAll("button").forEach((button) => { + button.style.width = "24px"; + button.style.minWidth = "24px"; + }); + styleValueControls(durationCtrl); + const durationLabel = document.createElement("label"); + durationLabel.style.cssText = "display:flex;flex-direction:column;gap:3px;min-width:0;"; + const durationTitle = document.createElement("span"); + durationTitle.textContent = "Seconds"; + durationTitle.style.cssText = "display:flex;align-items:center;justify-content:center;height:11px;line-height:10px;font-size:8px;text-align:center;color:inherit;overflow:visible;"; + durationLabel.append(durationTitle, durationCtrl); + const arrivalSelect = document.createElement("select"); + STEP_TRANSITION_ARRIVAL_OPTIONS.forEach(({ value, label }) => { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + arrivalSelect.appendChild(option); + }); + arrivalSelect.value = STEP_TRANSITION_ARRIVAL_OPTIONS.some((item) => item.value === String(seg.step_transition_arrival || "auto")) ? String(seg.step_transition_arrival || "auto") : "auto"; + arrivalSelect.disabled = index >= total - 1; + arrivalSelect.title = "Where the arrival toward the next frame should happen inside the transition"; + arrivalSelect.style.cssText = inputBase() + `height:27px;line-height:27px;padding-top:0;padding-bottom:0;background:${purple.valueBg};border-color:${purple.border};color:${purple.valueText};font-weight:800;text-align:center;`; + arrivalSelect.onpointerdown = (event) => event.stopPropagation(); + arrivalSelect.onchange = () => { + seg.step_transition_arrival = arrivalSelect.value; + writeTimeline(); + draw(); + }; + protectControlDrag(arrivalSelect); + const arrivalLabel = document.createElement("label"); + arrivalLabel.style.cssText = "display:flex;flex-direction:column;gap:3px;min-width:0;"; + const arrivalTitle = document.createElement("span"); + arrivalTitle.textContent = "Arrival"; + arrivalTitle.style.cssText = "display:flex;align-items:center;justify-content:center;height:11px;line-height:10px;font-size:8px;text-align:center;color:inherit;overflow:visible;"; + arrivalLabel.append(arrivalTitle, arrivalSelect); + durationWrap.append(durationLabel, arrivalLabel); + const autoFit = document.createElement("label"); + autoFit.style.cssText = "display:flex;align-items:center;justify-content:center;gap:6px;color:#DFA451;font-size:8px;font-weight:900;line-height:1;"; + const autoCheck = document.createElement("input"); + autoCheck.type = "checkbox"; + autoCheck.checked = seg.step_transition_auto_fit !== false; + autoCheck.disabled = index >= total - 1; + autoCheck.style.cssText = `width:14px;height:14px;accent-color:${purple.accent};`; + autoCheck.onpointerdown = (event) => event.stopPropagation(); + autoCheck.onchange = () => { + seg.step_transition_auto_fit = Boolean(autoCheck.checked); + if (Number(seg.step_transition_duration || 0) > 0) syncActionBridgeRelaySegment(seg, { forceTiming: true }); + writeTimeline(); + draw(); + }; + autoFit.append(autoCheck, document.createTextNode("Auto fit next frame")); + autoFit.style.justifyContent = "flex-start"; + const applyTiming = document.createElement("button"); + applyTiming.type = "button"; + applyTiming.textContent = "Apply Timing"; + applyTiming.disabled = index >= total - 1 || !seg.step_transition_enabled; + applyTiming.title = "Apply the transition seconds to the real timeline by rippling the next frames."; + const justApplied = transitionAppliedStamp && Date.now() - transitionAppliedStamp < 1600; + applyTiming.style.cssText = `height:25px;border:1px solid ${justApplied ? "rgba(123,210,155,.84)" : "rgba(223,164,81,.72)"};border-radius:5px;background:${justApplied ? "rgba(45,103,67,.90)" : seg.step_transition_enabled ? "rgba(96,64,34,.88)" : "rgba(255,255,255,.035)"};color:${seg.step_transition_enabled ? "#F4E5C4" : purple.muted};font-size:9px;font-weight:900;cursor:pointer;transform:${justApplied ? "translateY(1px)" : "none"};box-shadow:${justApplied ? "inset 0 2px 4px rgba(0,0,0,.35)" : "inset 0 1px 0 rgba(255,255,255,.10)"};`; + applyTiming.onpointerdown = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + applyTiming.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + seg.step_transition_auto_fit = true; + const nextSeconds = Number(seg.step_transition_duration || 0) || defaultStepTransitionSeconds(seg.step_transition_type, stepTransitionAvailableSeconds(seg)); + seg.step_transition_duration = nextSeconds; + seg.step_transition_enabled = true; + if (String(seg.step_transition_type || "off") === "off") seg.step_transition_type = "slow_dolly_in"; + fitStepTransitionDuration(seg, nextSeconds); + syncActionBridgeRelaySegment(seg, { forceTiming: true }); + transitionAppliedStamp = Date.now(); + writeTimeline(); + draw(); + setTimeout(() => { + if (Date.now() - transitionAppliedStamp >= 1200) draw(); + }, 1300); + }; + protectControlDrag(applyTiming); + const hint = document.createElement("div"); + const desired = Math.max(0, Number(seg.step_transition_duration || 0) || 0); + const overflow = desired > 0 && availableSeconds > 0 ? Math.max(0, desired - availableSeconds) : 0; + const startFrame = Math.round(Number(seg.start || 0)); + const endFrame = startFrame + Math.max(1, Math.round((desired || availableSeconds || 0.1) * getFps())); + hint.textContent = overflow > 0 + ? `Needs +${overflow.toFixed(2)}s before next frame` + : (desired > 0 ? `${desired.toFixed(1)}s | ${(startFrame / getFps()).toFixed(2)}s -> ${(endFrame / getFps()).toFixed(2)}s | ${stepTransitionArrivalLabel(seg.step_transition_arrival)}` : "0 = use full segment timing"); + const left = document.createElement("div"); + left.style.cssText = "display:flex;flex-direction:column;gap:5px;min-width:0;"; + left.append( + makeStepTransitionHeader(Boolean(seg.step_transition_enabled), stepTransitionLabel(seg.step_transition_type), "box to next"), + select, + prompt + ); + const center = document.createElement("div"); + center.style.cssText = "display:flex;flex-direction:column;gap:6px;justify-content:center;min-width:0;"; + center.append(durationWrap, autoFit, applyTiming); + const monitor = document.createElement("div"); + const available = availableSeconds > 0 ? availableSeconds : desired; + const arrivalMode = String(seg.step_transition_arrival || defaultStepTransitionArrival(seg.step_transition_type)); + const holdPct = arrivalMode === "very_late" ? 42 : arrivalMode === "late" ? 28 : arrivalMode === "middle" ? 12 : 0; + const arrivePct = arrivalMode === "early" ? 38 : arrivalMode === "middle" ? 24 : 16; + const movePct = Math.max(18, 100 - holdPct - arrivePct); + monitor.style.cssText = [ + "position:relative", + "display:grid", + "grid-template-columns:1fr 1fr", + "gap:6px 9px", + "align-content:center", + "min-width:0", + "padding:8px 9px", + "border:1px solid rgba(120,168,166,.42)", + "border-radius:7px", + "background:linear-gradient(180deg, rgba(7,18,20,.88), rgba(21,25,25,.84))", + "box-shadow:inset 0 1px 0 rgba(255,255,255,.07), inset 0 -10px 18px rgba(0,0,0,.18)", + `color:${purple.text}`, + "font:9px/1.15 monospace", + "font-weight:900", + ].join(";"); + monitor.innerHTML = ` + Timing monitor + + + + + + ${justApplied ? `APPLIED` : ""} + ${desired > 0 ? desired.toFixed(1) : "auto"}s + ${overflow > 0 ? `+${overflow.toFixed(2)}s` : "fit"} + ${(startFrame / getFps()).toFixed(2)}s -> ${(endFrame / getFps()).toFixed(2)}s + ${Math.max(1, Math.round((desired || available || 0.1) * getFps()))}f + ${stepTransitionArrivalLabel(seg.step_transition_arrival)} + `; + monitor.title = hint.textContent; + wrap.append(left, center, monitor); + return wrap; + }; + const makeSegmentSummary = (seg, index, total) => { + const fps = getFps(); + const startFrame = Math.max(0, Math.round(Number(seg.start || 0))); + const lenFrame = Math.max(1, Math.round(Number(seg.length || 1))); + const endFrame = startFrame + lenFrame; + const activeStep = Boolean(seg.step_transition_enabled && index < total - 1); + const stepSeconds = Math.max(0, Number(seg.step_transition_duration || 0) || 0); + const summary = document.createElement("div"); + summary.style.cssText = [ + "align-self:start", + "display:flex", + "flex-direction:column", + "align-items:center", + "justify-content:center", + "gap:3px", + "min-width:0", + "padding:5px 8px", + `border:1px solid ${activeStep ? "rgba(223,164,81,.74)" : "rgba(120,112,98,.44)"}`, + "border-radius:6px", + `background:${activeStep ? "linear-gradient(180deg, rgba(64,43,25,.92), rgba(35,31,28,.78))" : "linear-gradient(180deg, rgba(52,50,47,.78), rgba(28,28,27,.52))"}`, + "box-shadow:inset 0 1px 0 rgba(255,255,255,.10), 0 4px 12px rgba(0,0,0,.14)", + "box-sizing:border-box", + "overflow:hidden", + "text-align:center", + ].join(";"); + const title = document.createElement("div"); + title.textContent = activeStep ? `${stepTransitionLabel(seg.step_transition_type)} -> next` : "Segment"; + title.style.cssText = `max-width:100%;padding:2px 7px;border-radius:999px;border:1px solid ${activeStep ? "rgba(223,164,81,.55)" : "rgba(143,208,204,.22)"};background:${activeStep ? "rgba(223,164,81,.12)" : "rgba(143,208,204,.07)"};color:${activeStep ? "#F4D49E" : purple.muted};font-size:7px;font-weight:900;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1;`; + const range = document.createElement("div"); + range.textContent = `${(startFrame / fps).toFixed(2)}s -> ${(endFrame / fps).toFixed(2)}s`; + range.style.cssText = `color:${purple.text};font-size:10px;font-weight:900;white-space:nowrap;line-height:1;`; + const meta = document.createElement("div"); + meta.textContent = `${lenFrame}f / ${(lenFrame / fps).toFixed(2)}s${activeStep ? ` | ${stepSeconds > 0 ? stepSeconds.toFixed(1) : "auto"}s move` : ""}`; + meta.style.cssText = `max-width:100%;color:${activeStep ? "#DFA451" : purple.muted};font-size:8px;font-weight:900;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1;`; + summary.append(title, range, meta); + return summary; + }; + const resizeRelayBridgeSeconds = (seg, seconds) => { + const fps = getFps(); + const nextLength = Math.max(1, Math.round(Math.max(0.05, Number(seconds || 0)) * fps)); + const oldStart = Math.round(Number(seg.start || 0)); + const oldLength = Math.max(1, Math.round(Number(seg.length || 1))); + const oldEnd = oldStart + oldLength; + const delta = nextLength - oldLength; + seg.length = nextLength; + if (delta !== 0) { + (timeline.segments || []).forEach((item) => { + if (item.id !== seg.id && Number(item.start || 0) >= oldEnd) { + item.start = Math.max(0, Math.round(Number(item.start || 0) + delta)); + } + }); + (timeline.audioSegments || []).forEach((item) => { + if (Number(item.start || 0) >= oldEnd) { + item.start = Math.max(0, Math.round(Number(item.start || 0) + delta)); + } + }); + ensureDurationForFrames(endOfSegments(timeline.segments)); + } + }; + const makeRelayBridgeCard = (seg, index) => { + const fps = getFps(); + const startFrame = Math.max(0, Math.round(Number(seg.start || 0))); + const lenFrame = Math.max(1, Math.round(Number(seg.length || 1))); + const endFrame = startFrame + lenFrame; + const card = document.createElement("div"); + card.style.cssText = [ + "display:grid", + "grid-template-columns:38px minmax(190px,240px) minmax(0,1fr) minmax(150px,190px) 32px", + "gap:10px", + "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)"}`, + "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)", + "box-sizing:border-box", + ].join(";"); + const badge = document.createElement("button"); + badge.type = "button"; + badge.textContent = "R"; + badge.title = "Select this relay bridge"; + badge.style.cssText = `align-self:center;justify-self:center;display:flex;align-items:center;justify-content:center;width:28px;height:28px;border:1px solid rgba(143,208,204,.64);border-radius:999px;background:rgba(7,18,20,.82);color:#CFF2EE;font-size:10px;font-weight:900;cursor:pointer;box-shadow:inset 0 1px 0 rgba(255,255,255,.10);`; + badge.onclick = () => { selectedId = seg.id; draw(); }; + const meta = document.createElement("div"); + meta.style.cssText = "display:flex;flex-direction:column;gap:5px;justify-content:center;min-width:0;"; + const title = document.createElement("div"); + title.textContent = "Relay Bridge"; + title.style.cssText = "display:flex;align-items:center;justify-content:center;height:18px;border-radius:999px;border:1px solid rgba(143,208,204,.48);background:rgba(41,132,142,.20);color:#CFF2EE;font-size:8px;font-weight:900;text-transform:uppercase;"; + const range = document.createElement("div"); + range.textContent = `${(startFrame / fps).toFixed(2)}s -> ${(endFrame / fps).toFixed(2)}s`; + range.style.cssText = `color:${purple.text};font:10px/1 monospace;font-weight:900;text-align:center;white-space:nowrap;`; + const name = document.createElement("input"); + name.type = "text"; + name.value = String(seg.label || "relay_bridge"); + name.title = "Relay bridge label"; + name.style.cssText = inputBase() + `height:22px;background:${purple.valueBg};border-color:rgba(143,208,204,.42);color:${purple.valueText};font:${promptFontSize(9)}/1 monospace;font-weight:800;text-align:center;`; + name.onpointerdown = (event) => event.stopPropagation(); + name.oninput = () => { + seg.label = name.value || "relay_bridge"; + writeTimeline(); + }; + protectControlDrag(name); + meta.append(title, range, name); + const prompt = document.createElement("textarea"); + prompt.value = String(seg.prompt || ""); + prompt.placeholder = "PromptRelay text between the previous frame and the next frame..."; + prompt.dataset.iamccsV3SegmentId = String(seg.id); + prompt.dataset.iamccsV3Key = "prompt"; + prompt.style.cssText = `width:100%;height:68px;min-height:68px;box-sizing:border-box;padding:7px 9px;background:${purple.valueBg};border:1px solid rgba(143,208,204,.44);border-radius:5px;color:${purple.valueText};font:${promptFontSize(10)}/1.26 monospace;font-weight:700;outline:none;resize:none;overflow-y:auto;box-shadow:inset 0 1px 0 rgba(255,255,255,.56);`; + prompt.onpointerdown = (event) => event.stopPropagation(); + prompt.oninput = () => { + seg.prompt = prompt.value; + seg.note = prompt.value; + seg.use_prompt = Boolean(String(prompt.value || "").trim()); + if (isActionBridgeRelaySegment(seg)) syncActionBridgeSourceFromRelay(seg); + syncSegmentTextPeers(seg.id, "prompt", prompt.value, prompt); + syncSegmentRelayPeers(seg.id, Boolean(seg.use_prompt), null); + writeTimeline(); + }; + prompt.onchange = flushTimelineWrite; + prompt.onblur = flushTimelineWrite; + protectControlDrag(prompt); + const timing = document.createElement("div"); + timing.style.cssText = "display:grid;grid-template-rows:auto auto;gap:7px;align-content:center;min-width:0;"; + const secondsTitle = document.createElement("div"); + secondsTitle.textContent = "Relay seconds"; + secondsTitle.style.cssText = `color:${purple.muted};font-size:8px;font-weight:900;text-align:center;text-transform:uppercase;`; + const seconds = numberStepperControl(lenFrame / fps, "0.1", "0.1", null, (value) => { + resizeRelayBridgeSeconds(seg, value); + if (String(seg.prompt || "").trim()) seg.use_prompt = true; + writeTimeline(); + draw(); + }, { liveInput: false }); + seconds.style.gridTemplateColumns = "24px minmax(54px,1fr) 24px"; + seconds.style.gap = "5px"; + seconds.querySelectorAll("button").forEach((button) => { + button.style.width = "24px"; + button.style.minWidth = "24px"; + }); + styleValueControls(seconds); + const status = document.createElement("div"); + const active = Boolean(seg.use_prompt !== false && String(seg.prompt || "").trim()); + status.textContent = active ? "PromptRelay ON" : "PromptRelay off"; + status.style.cssText = `display:flex;align-items:center;justify-content:center;height:22px;border-radius:5px;border:1px solid ${active ? "rgba(143,208,204,.58)" : purple.borderSoft};background:${active ? "rgba(41,132,142,.18)" : "rgba(0,0,0,.14)"};color:${active ? "#CFF2EE" : purple.muted};font:8px/1 monospace;font-weight:900;text-transform:uppercase;`; + timing.append(secondsTitle, seconds, status); + const actions = document.createElement("div"); + actions.style.cssText = "display:grid;gap:5px;align-content:start;"; + const remove = document.createElement("button"); + remove.type = "button"; + remove.textContent = "X"; + remove.title = "Delete this relay bridge"; + remove.style.cssText = `height:24px;border:1px solid ${purple.danger};border-radius:4px;background:#6B302A;color:#FFF2E4;font-size:10px;font-weight:900;cursor:pointer;`; + remove.onpointerdown = (event) => { event.preventDefault(); event.stopPropagation(); }; + remove.onclick = (event) => { + event.preventDefault(); + timeline.segments = (timeline.segments || []).filter((item) => item.id !== seg.id); + selectedId = timeline.segments.find((item) => String(item.type || "image") !== "text")?.id || null; + writeTimeline({ force: true }); + draw(); + }; + actions.append(protectControlDrag(remove)); + card.append(badge, meta, prompt, timing, actions); + return card; + }; + const orderedBoxSegments = (timeline.segments || []) + .filter((seg) => String(seg.type || "image") !== "audio") + .slice() + .sort((a, b) => Number(a.start || 0) - Number(b.start || 0)); + orderedBoxSegments.forEach((seg, index) => { + if (String(seg.type || "image") === "text") { + boxList.appendChild(makeRelayBridgeCard(seg, index)); + return; + } + const card = document.createElement("div"); + card.style.cssText = [ + "display:grid", + "grid-template-columns:32px minmax(650px,.82fr) minmax(0,1fr) 112px 30px", + "gap:8px", + "align-items:stretch", + "margin-bottom:6px", + "padding:8px 9px", + "min-height:96px", + `border:1px solid ${selectedId === seg.id ? purple.border : 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)", + "box-sizing:border-box", + ].join(";"); + const badge = document.createElement("button"); + badge.type = "button"; + badge.textContent = String(index + 1); + badge.title = "Select this box"; + badge.style.cssText = `align-self:center;justify-self:center;display:flex;align-items:center;justify-content:center;width:26px;height:26px;border:1px solid ${purple.border};border-radius:999px;background:${purple.button};color:${purple.text};font-size:10px;font-weight:900;cursor:pointer;box-shadow:inset 0 1px 0 rgba(255,255,255,.10);`; + badge.onclick = () => { selectedId = seg.id; draw(); }; + const relayWrap = document.createElement("label"); + relayWrap.style.cssText = `display:grid;grid-template-columns:34px minmax(0,1fr);grid-template-rows:auto auto;column-gap:6px;row-gap:4px;align-items:center;color:${purple.muted};font-size:9px;font-weight:800;`; + const relayLabel = document.createElement("span"); + relayLabel.textContent = "Relay"; + relayLabel.style.cssText = "grid-column:1;grid-row:1;text-align:center;align-self:end;"; + const relayToggle = document.createElement("input"); + relayToggle.type = "checkbox"; + relayToggle.checked = Boolean((seg.use_prompt !== false && String(seg.prompt || "").trim()) || seg.dialogue_pin || seg.image_lock || seg.motion_boost); + relayToggle.dataset.iamccsV3SegmentId = String(seg.id); + relayToggle.dataset.iamccsV3Key = "use_prompt"; + relayToggle.title = "Use this box prompt as a local PromptRelay beat"; + relayToggle.style.cssText = `width:18px;height:18px;accent-color:${purple.accent};cursor:pointer;`; + relayToggle.style.gridColumn = "1"; + relayToggle.style.gridRow = "2"; + relayToggle.style.justifySelf = "center"; + relayToggle.style.alignSelf = "start"; + relayToggle.onpointerdown = (event) => event.stopPropagation(); + relayToggle.onchange = () => { + seg.use_prompt = relayToggle.checked; + syncSegmentRelayPeers(seg.id, Boolean(seg.use_prompt), relayToggle); + writeTimeline(); + draw(); + }; + const localRelayActive = Boolean((seg.use_prompt !== false && String(seg.prompt || "").trim()) || seg.dialogue_pin || seg.image_lock || seg.motion_boost); + const relayStatus = document.createElement("span"); + relayStatus.textContent = localRelayActive ? "LOCAL" : "OFF"; + relayStatus.title = "PromptRelay status for this box"; + relayStatus.style.cssText = `display:flex;align-items:center;justify-content:center;min-width:44px;padding:2px 5px;border-radius:999px;border:1px solid ${localRelayActive ? "rgba(143,208,204,.62)" : purple.borderSoft};background:${localRelayActive ? "rgba(41,132,142,.24)" : "rgba(0,0,0,.12)"};color:${localRelayActive ? "#CFF2EE" : purple.muted};font-size:7px;font-weight:900;line-height:1;white-space:nowrap;`; + relayStatus.style.gridColumn = "2"; + relayStatus.style.gridRow = "1"; + relayStatus.style.alignSelf = "end"; + const dialogueHint = document.createElement("span"); + const looksDialogue = /["โ€œโ€]|\bsays?\b|\bspeak\b|\bdialog/i.test(String(seg.prompt || "")); + dialogueHint.textContent = seg.dialogue_pin ? "PIN" : looksDialogue ? "DIALOG" : "PROMPT"; + dialogueHint.title = seg.dialogue_pin ? "Dialogue pin is active for this box" : looksDialogue ? "This prompt appears to contain dialogue timing or spoken text" : "No obvious dialogue marker detected"; + dialogueHint.style.cssText = `display:flex;align-items:center;justify-content:center;min-width:44px;padding:2px 5px;border-radius:999px;border:1px solid ${looksDialogue || seg.dialogue_pin ? "rgba(223,164,81,.62)" : purple.borderSoft};background:${looksDialogue || seg.dialogue_pin ? "rgba(96,64,34,.28)" : "rgba(0,0,0,.10)"};color:${looksDialogue || seg.dialogue_pin ? "#F4D49E" : purple.muted};font-size:7px;font-weight:900;line-height:1;white-space:nowrap;`; + dialogueHint.style.gridColumn = "2"; + dialogueHint.style.gridRow = "2"; + dialogueHint.style.alignSelf = "start"; + relayWrap.append(relayLabel, relayToggle, relayStatus, dialogueHint); + const actions = document.createElement("div"); + actions.style.cssText = "display:grid;gap:5px;align-self:start;"; + const mini = (label, titleText, action) => { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = label; + b.title = titleText; + b.style.cssText = `height:24px;border:1px solid ${purple.borderSoft};border-radius:4px;background:${purple.button};color:${purple.text};font-size:10px;font-weight:900;cursor:pointer;display:flex;align-items:center;justify-content:center;`; + b.onpointerdown = (event) => { event.preventDefault(); event.stopPropagation(); }; + b.onmousedown = (event) => { event.preventDefault(); event.stopPropagation(); }; + b.onclick = (event) => { event.preventDefault(); action(); }; + return protectControlDrag(b); + }; + actions.append( + mini("D", "Duplicate", () => { + duplicateVisualSegment(seg); + }), + mini("X", "Delete", () => { + rippleDeleteVisualSegment(seg); + }), + mini("+", "Add empty image slot after this box", () => { + createPlaceholderAfterSegment(seg); + }) + ); + actions.style.alignSelf = "center"; + actions.style.alignContent = "center"; + actions.style.gridTemplateRows = "24px 24px 24px"; + actions.querySelectorAll("button").forEach((button) => { + button.style.height = "24px"; + }); + + const leftPane = document.createElement("div"); + leftPane.style.cssText = "display:flex;align-items:center;min-width:0;align-self:stretch;"; + const numericRow = document.createElement("div"); + numericRow.style.cssText = "display:grid;grid-template-columns:minmax(94px,104px) minmax(116px,126px) minmax(94px,104px) minmax(116px,132px) minmax(150px,1fr);gap:8px;align-items:center;min-width:0;width:100%;"; + numericRow.append( + makeField(seg, "Frame", "start", "number"), + makeField(seg, "Len", "length", "number"), + makeField(seg, "Ref", "ref", "number"), + makeField(seg, "Guide Strength", "guideStrength", "number"), + makeSegmentSummary(seg, index, timeline.segments.length) + ); + leftPane.append(numericRow); + + const rightPane = document.createElement("div"); + rightPane.style.cssText = "display:flex;align-items:center;min-width:0;align-self:stretch;padding-bottom:0;"; + const promptField = makeField(seg, "Action in segment", "prompt", "textarea"); + const promptHeader = document.createElement("div"); + promptHeader.style.cssText = "display:grid;grid-template-columns:minmax(0,1fr);gap:0;align-items:center;min-width:0;width:100%;"; + promptField.style.height = "auto"; + promptField.querySelector("textarea").style.minHeight = "58px"; + promptField.querySelector("textarea").style.height = "58px"; + promptField.querySelector("textarea").style.fontSize = promptFontSize(11); + relayWrap.style.alignSelf = "center"; + relayWrap.style.minHeight = "58px"; + relayWrap.style.padding = "5px 6px"; + relayWrap.style.border = `1px solid ${localRelayActive ? "rgba(143,208,204,.58)" : purple.borderSoft}`; + relayWrap.style.borderRadius = "6px"; + relayWrap.style.background = localRelayActive ? "linear-gradient(180deg,rgba(41,132,142,.18),rgba(0,0,0,.12))" : "linear-gradient(180deg,rgba(255,238,205,.05),rgba(0,0,0,.10))"; + promptHeader.append(promptField); + rightPane.append(promptHeader); + + card.append(badge, leftPane, rightPane, relayWrap, actions); + boxList.appendChild(card); + }); + + const selectedAudio = (timeline.audioSegments || []).find((seg) => seg.id === selectedId); + if (selectedAudio) { + const audioCard = document.createElement("div"); + audioCard.style.cssText = [ + "display:grid", + "grid-template-columns:150px 120px 120px 120px minmax(180px,1fr) minmax(180px,1fr) 32px", + "gap:10px", + "align-items:end", + "margin-top:8px", + "padding:10px", + `border:1px solid ${purple.audio}`, + "border-radius:6px", + "background:linear-gradient(180deg,rgba(57,44,30,.92),rgba(35,34,32,.92))", + "box-sizing:border-box", + ].join(";"); + const audioField = (labelText, key, type = "input", options = []) => { + const wrap = document.createElement("label"); + wrap.style.cssText = `display:flex;flex-direction:column;gap:4px;color:${purple.muted};font-size:9px;font-weight:900;text-align:center;min-width:0;`; + const span = document.createElement("span"); + span.textContent = labelText; + span.style.cssText = "line-height:1.1;"; + let input; + if (type === "select") { + input = document.createElement("select"); + options.forEach(([value, text]) => { + const opt = document.createElement("option"); + opt.value = value; + opt.textContent = text; + input.appendChild(opt); + }); + input.value = String(selectedAudio[key] || options[0]?.[0] || ""); + } else { + input = document.createElement("input"); + input.type = type === "number" ? "number" : "text"; + if (type === "number") input.step = key === "gain" ? "0.05" : "1"; + input.value = selectedAudio[key] ?? ""; + } + input.style.cssText = inputBase() + `height:28px;background:${purple.valueBg};border-color:${purple.border};color:${purple.valueText};font-weight:800;text-align:center;`; + input.onpointerdown = (event) => event.stopPropagation(); + input.onchange = input.oninput = () => { + if (type === "number") { + const raw = Number(input.value || 0); + selectedAudio[key] = key === "gain" ? Math.max(0, Math.min(2, raw)) : Math.max(0, Math.round(raw)); + } else { + selectedAudio[key] = input.value; + } + if (key === "start" || key === "length" || key === "track") { + selectedAudio.length = Math.max(1, Math.round(Number(selectedAudio.length || 1))); + selectedAudio.start = Math.max(0, Math.round(Number(selectedAudio.start || 0))); + selectedAudio.track = Math.max(0, Math.round(Number(selectedAudio.track || 0))); + } + writeTimeline(); + if (key === "start" || key === "length" || key === "track") draw(); + }; + protectControlDrag(input); + wrap.append(span, input); + return wrap; + }; + const linkOptions = [["", "Auto / none"]]; + (timeline.segments || []) + .filter((seg) => String(seg.type || "image") !== "audio" && !seg.placeholder) + .forEach((seg, idx) => linkOptions.push([String(seg.id), `${idx + 1}. ${String(seg.label || `shot_${idx + 1}`)}`])); + const remove = document.createElement("button"); + remove.type = "button"; + remove.textContent = "X"; + remove.title = "Remove selected audio clip"; + remove.style.cssText = `height:30px;border:1px solid ${purple.danger};border-radius:5px;background:#6B302A;color:${purple.text};font-size:10px;font-weight:900;cursor:pointer;`; + remove.onpointerdown = (event) => { event.preventDefault(); event.stopPropagation(); }; + remove.onclick = () => { + timeline.audioSegments = (timeline.audioSegments || []).filter((seg) => seg.id !== selectedAudio.id); + selectedId = timeline.segments[0]?.id || null; + writeTimeline({ force: true }); + draw(); + }; + audioCard.append( + audioField("Audio purpose", "purpose", "select", [["music", "Music / score"], ["dialogue", "Dialogue / lipsync"], ["sfx", "SFX"], ["lipsync_or_music", "Auto"]]), + audioField("Track", "track", "number"), + audioField("Start frame", "start", "number"), + audioField("Len frames", "length", "number"), + audioField("Gain", "gain", "number"), + audioField("Linked shot", "linkedVisualId", "select", linkOptions), + remove + ); + const title = document.createElement("div"); + title.textContent = `Selected audio: ${String(selectedAudio.name || selectedAudio.fileName || selectedAudio.audioFile || "clip")}`; + title.style.cssText = `grid-column:1 / -1;color:#F4D49E;font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:.02em;`; + audioCard.prepend(title); + boxList.appendChild(audioCard); + } + } + + function draw() { + if (!dragState) writeTimeline(); + drawRuler(); + updatePlayUI(); + timelineMeterSeconds = clampTimelineMeterSeconds(timelineMeterSeconds); + promptSizeReadout.textContent = `${Math.round(promptTextScale * 100)}%`; + const timelineZoomSteps = Math.round((timelineMeterSeconds - Math.max(0.5, getDuration())) * 2); + timelineMeterReadout.textContent = `${Math.round(Math.max(0.2, Math.min(8, Math.pow(1.18, timelineZoomSteps))) * 100)}%`; + promptArea.style.fontSize = promptFontSize(12); + timelineViewport.style.display = "block"; + playbar.style.display = "flex"; + imageTrack.innerHTML = ""; + actionTrack.innerHTML = ""; + audioTracks.innerHTML = ""; + const visualSegments = activeVisualSegments(); + const audioSegmentsForDraw = visibleAudioSegments(); + const trackHasRealAudio = (trackIndex) => audioSegmentsForDraw.some((seg) => + Number(seg?.track || 0) === Number(trackIndex || 0) && audioSegmentHasMedia(seg) && !seg.placeholder + ); + const canvasWidth = computeTimelineCanvasWidth(visualSegments); + const previousScrollLeft = Number(timelineViewport.scrollLeft || 0); + timelineCanvas.style.width = `${canvasWidth}px`; + 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`; + 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"};`; + const plus = document.createElement("button"); + plus.type = "button"; + plus.innerHTML = `+Audio`; + plus.title = "Import audio into this track"; + plus.style.cssText = [ + "position:sticky", + "left:8px", + "top:5px", + "width:118px", + "height:35px", + "box-sizing:border-box", + `border:1px dashed ${purple.border}`, + "border-radius:6px", + "background:linear-gradient(180deg,rgba(244,239,231,.92),rgba(213,204,190,.92))", + `color:${purple.valueText}`, + "display:grid", + "grid-template-columns:28px 1fr", + "gap:5px", + "align-items:center", + "justify-items:center", + "padding:0 9px", + "font-size:11px", + "font-weight:900", + "line-height:1", + "cursor:pointer", + "opacity:.94", + "z-index:12", + "box-shadow:0 3px 9px rgba(0,0,0,.34), inset 0 1px 0 rgba(255,255,255,.72)", + ].join(";"); + const plusIcon = plus.querySelector("span"); + if (plusIcon) plusIcon.style.cssText = "display:flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:999px;background:#1B1713;color:#F4EFE7;font-size:18px;font-weight:900;"; + const plusText = plus.querySelector("b"); + if (plusText) plusText.style.cssText = "display:block;justify-self:start;text-transform:uppercase;font-size:10px;letter-spacing:0;color:#181512;"; + plus.onpointerdown = (event) => { event.preventDefault(); event.stopPropagation(); }; + plus.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + pendingAudioTrack = i; + pendingAudioInsertFrame = 0; + audioInput.click(); + }; + if (!trackHasRealAudio(i)) lane.appendChild(plus); + audioTracks.appendChild(lane); + } + visualSegments.forEach((seg) => imageTrack.appendChild(makeBlock(seg, false))); + if (!visualSegments.length) imageTrack.appendChild(makeImagePlaceholderBlock()); + 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"; + 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`; + boxList.style.maxHeight = collapsed ? "0" : "300px"; + if (!collapsed && !dragState) drawBoxes(); + const desiredHeight = currentNodeHeight(); + 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]; + } + try { node.setDirtyCanvas?.(true, true); app.graph?.setDirtyCanvas?.(true, true); } catch {} + } + + function spreadVisualSegmentsAcrossDuration() { + const total = getTotalFrames(); + const sorted = (timeline.segments || []).slice().sort((a, b) => Number(a.start || 0) - Number(b.start || 0)); + if (!sorted.length) return; + const base = Math.max(1, Math.floor(total / sorted.length)); + let cursor = 0; + sorted.forEach((seg, index) => { + seg.start = cursor; + seg.length = index === sorted.length - 1 ? Math.max(1, total - cursor) : base; + cursor += seg.length; + }); + timeline.segments = sorted; + } + + function isSingleEmptyDefaultVisualSegment() { + if (refPaths().length || timeline.segments.length !== 1) return false; + const seg = timeline.segments[0] || {}; + return String(seg.type || "image") === "image" + && Number(seg.ref || 1) === 1 + && String(seg.label || "") === "ref_1" + && !String(seg.prompt || "").trim() + && !String(seg.note || "").trim(); + } + + function addUploadedImagesToTimeline(uploaded) { + if (!uploaded.length) return; + const current = getOwnReferencePaths(node); + const nextPaths = current.concat(uploaded); + setOwnReferencePaths(node, nextPaths); + if (isSingleEmptyDefaultVisualSegment()) timeline.segments = []; + const targetId = pendingImageTargetId; + pendingImageTargetId = null; + pendingImageInsertFrame = null; + let cursor = endOfSegments(timeline.segments); + let refOffset = 0; + if (targetId) { + const target = (timeline.segments || []).find((seg) => seg.id === targetId); + if (target) { + target.type = "image"; + target.placeholder = false; + target.ref = current.length + 1; + target.imageFile = uploaded[0] || target.imageFile || ""; + target.label = `ref_${current.length + 1}`; + target.use_guide = true; + target.guideStrength = Number(target.guideStrength ?? defaultForceWidget?.value ?? 0.25); + target.imageLockStrength = Number(target.guideStrength ?? defaultForceWidget?.value ?? 0.25); + target.defaultForceSource = Number(target.defaultForceSource ?? defaultForceWidget?.value ?? 0.25); + uploaded = uploaded.slice(1); + refOffset = 1; + cursor = endOfSegments(timeline.segments); + } + } + const requiredEnd = cursor + (uploaded.length * defaultLen()); + ensureDurationForFrames(requiredEnd); + uploaded.forEach((_, i) => { + const imageFile = uploaded[i]; + const room = Math.max(1, getTotalFrames() - Math.min(cursor, getTotalFrames() - 1)); + const length = Math.min(defaultLen(), room); + timeline.segments.push({ + id: newId("seg"), + type: "image", + start: Math.min(cursor, Math.max(0, getTotalFrames() - 1)), + length, + ref: current.length + refOffset + i + 1, + imageFile, + label: `ref_${current.length + refOffset + i + 1}`, + 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: true, + }); + cursor += length; + }); + if (endOfSegments(timeline.segments) > getTotalFrames() || timeline.segments.some((seg) => Number(seg.length || 0) <= 1)) { + spreadVisualSegmentsAcrossDuration(); + } + selectedId = timeline.segments[timeline.segments.length - 1]?.id || selectedId; + writeTimeline(); + draw(); + } + + fileInput.onchange = async (event) => { + const uploaded = await uploadShotboardImages(event.target.files); + addUploadedImagesToTimeline(uploaded); + fileInput.value = ""; + }; + addImageBtn.onclick = () => { + openAppendImagePicker(); + }; + openEditorBtn.onclick = () => { + toggleFullscreenEditor(root, node); + }; + addTextBtn.onclick = () => { + if (!refPaths().length && timeline.segments.length === 1 && String(timeline.segments[0]?.label || "") === "ref_1") { + const seg = timeline.segments[0]; + seg.type = "text"; + seg.start = 0; + seg.length = getTotalFrames(); + seg.label = "t2v_shot"; + seg.prompt = seg.prompt || "cinematic motion, coherent subject, stable camera"; + seg.camera = "cinematic motion"; + seg.transition = "continuous_motion"; + seg.guideStrength = 0; + seg.imageLockStrength = 0; + seg.use_guide = false; + selectedId = seg.id; + draw(); + return; + } + const start = Math.min(endOfSegments(timeline.segments), Math.max(0, getTotalFrames() - 1)); + const seg = { id: newId("text"), type: "text", start, length: Math.min(defaultLen(), Math.max(1, getTotalFrames() - start)), label: "text_beat", prompt: "camera continues the movement", camera: "cinematic motion", transition: "continuous_motion", guideStrength: 0, imageLockStrength: 0, use_guide: false }; + timeline.segments.push(seg); + selectedId = seg.id; + draw(); + }; + async function decodeAudioInfo(file) { + const fallbackFrames = Math.min(defaultLen() * 2, getTotalFrames()); + try { + const buffer = await file.arrayBuffer(); + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + if (!AudioContextClass) return { durationFrames: fallbackFrames, peaks: [] }; + const audioCtx = new AudioContextClass(); + const audioBuffer = await audioCtx.decodeAudioData(buffer.slice(0)); + if (typeof audioCtx.close === "function") audioCtx.close().catch(() => {}); + const durationFrames = Math.max(1, Math.ceil(Number(audioBuffer.duration || 0) * getFps())); + const channelData = audioBuffer.getChannelData(0); + const peakCount = 200; + const step = Math.max(1, Math.floor(channelData.length / peakCount)); + const peaks = []; + for (let i = 0; i < peakCount; i++) { + let peak = 0; + for (let j = 0; j < step; j++) { + peak = Math.max(peak, Math.abs(channelData[(i * step) + j] || 0)); + } + peaks.push(Number(peak.toFixed(4))); + } + return { durationFrames, peaks }; + } catch (err) { + console.warn("[IAMCCS Cine Shotboard V3] audio decode failed", err); + return { durationFrames: fallbackFrames, peaks: [] }; + } + } + + function nearestVisualSegmentId(frame) { + const visual = (timeline.segments || []) + .filter((seg) => String(seg.type || "image") !== "audio" && !seg.placeholder) + .slice() + .sort((a, b) => Number(a.start || 0) - Number(b.start || 0)); + if (!visual.length) return ""; + const f = Math.max(0, Math.round(Number(frame || 0))); + let best = visual[0]; + let bestDistance = Infinity; + for (const seg of visual) { + const start = Number(seg.start || 0); + const end = start + Number(seg.length || 1); + const distance = f >= start && f <= end ? 0 : Math.min(Math.abs(f - start), Math.abs(f - end)); + if (distance < bestDistance) { + best = seg; + bestDistance = distance; + } + } + return String(best?.id || ""); + } + + async function uploadAudioFiles(files, targetFrameStart = null, targetTrack = 0) { + const audioFiles = Array.from(files || []).filter((file) => String(file.type || "").startsWith("audio/")); + const track = Math.max(0, Math.round(Number(targetTrack || 0))); + let cursor = targetFrameStart == null + ? endOfSegments((timeline.audioSegments || []).filter((item) => Number(item.track || 0) === track && audioSegmentHasMedia(item) && !item.placeholder)) + : Math.max(0, Math.round(Number(targetFrameStart || 0))); + for (const file of audioFiles) { + try { + 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 || ""; + const audioFile = subfolder ? `${subfolder}/${filename}` : filename; + const info = await decodeAudioInfo(file); + const room = Math.max(1, getTotalFrames() - cursor); + const length = Math.min(Math.max(1, info.durationFrames), room); + removeEmptyAudioPlaceholdersInRange(track, cursor, length); + const seg = { + id: newId("aud"), + type: "audio", + start: cursor, + length, + track, + trimStart: 0, + audioDurationFrames: Math.max(1, info.durationFrames), + audioFile, + fileName: file.name, + name: file.name, + mime: file.type, + size: file.size, + waveformPeaks: info.peaks, + purpose: "lipsync_or_music", + gain: 1, + linkedVisualId: nearestVisualSegmentId(cursor), + }; + timeline.audioSegments.push(seg); + cursor += length; + } catch (err) { + console.error("[IAMCCS Cine Shotboard V3] audio upload failed", err); + } + } + timeline.audioSegments.sort((a, b) => (Number(a.track || 0) - Number(b.track || 0)) || (Number(a.start || 0) - Number(b.start || 0))); + writeTimeline({ force: true }); + draw(); + } + + audioInput.onchange = async (event) => { + await uploadAudioFiles(event.target.files || [], pendingAudioInsertFrame, pendingAudioTrack); + pendingAudioInsertFrame = null; + pendingAudioTrack = 0; + audioInput.value = ""; + }; + addAudioBtn.onclick = () => { + pendingAudioInsertFrame = null; + pendingAudioTrack = 0; + audioInput.click(); + }; + addTrackBtn.onclick = () => { timeline.audioTrackCount = Math.max(1, Number(timeline.audioTrackCount || 2)) + 1; draw(); }; + timelineBox.ondblclick = (event) => { + const rect = timelineBox.getBoundingClientRect(); + const frame = Math.round(((event.clientX - rect.left) / Math.max(1, rect.width)) * getTotalFrames()); + const y = event.clientY - rect.top; + if (y >= 308) { + pendingAudioInsertFrame = frame; + pendingAudioTrack = Math.max(0, Math.min(Math.max(1, Number(timeline.audioTrackCount || 2)) - 1, Math.floor((y - 308) / 45))); + audioInput.click(); + } else { + openAppendImagePicker(); + } + }; + scrub.oninput = () => { + playFrame = Math.round(Number(scrub.value || 0)); + if (isPlaying) { + playbackStartFrame = playFrame; + playbackStartTimestamp = performance.now(); + scheduleAudioFromFrame(playFrame); + } + draw(); + }; + playBtn.onclick = () => { + if (isPlaying) stopPlayback(); + else startPlayback(); + }; + loopBtn.onclick = () => { + isLooping = !isLooping; + updatePlayUI(); + }; + function rowsToSegments(importedRows) { + const fps = getFps(); + const sorted = (Array.isArray(importedRows) ? importedRows : []) + .filter((row) => row && typeof row === "object") + .map((row, index) => ({ + row, + index, + frame: Number.isFinite(Number(row.frame)) + ? Math.max(0, Math.round(Number(row.frame))) + : Math.max(0, Math.round(Number(row.second ?? row.seconds ?? row.time ?? 0) * fps)), + })) + .sort((a, b) => a.frame - b.frame || a.index - b.index); + return sorted.map((item, index) => { + const next = sorted[index + 1]; + const start = item.frame; + const end = next ? next.frame : getTotalFrames(); + 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 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" + ? row.forceCustom + : typeof row.force_custom === "boolean" + ? row.force_custom + : Number.isFinite(Number(row.force ?? row.strength)) && Math.abs(rowForceValue - defaultForceValue) > 0.0005; + return { + id: newId(isText ? "text" : "seg"), + type: isText ? "text" : "image", + 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}`), + 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), + forceCustom: Boolean(!isText && explicitForceCustom), + use_guide: !isText && row.use_guide !== false, + use_prompt: Boolean(prompt || row.step_transition_enabled || row.stepTransitionEnabled || row.dialogue_pin || row.dialoguePin || row.image_lock || row.imageLock || row.motion_boost || row.motionBoost), + dialogue_pin: Boolean(row.dialogue_pin || row.dialoguePin), + image_lock: Boolean(row.image_lock || row.imageLock), + motion_boost: Boolean(row.motion_boost || row.motionBoost), + clean_relay: Boolean(row.clean_relay || row.cleanRelay), + step_transition_enabled: Boolean(row.step_transition_enabled || row.stepTransitionEnabled), + step_transition_type: String(row.step_transition_type || row.stepTransitionType || "off"), + step_transition_prompt: String(row.step_transition_prompt || row.stepTransitionPrompt || ""), + step_transition_easing: String(row.step_transition_easing || row.stepTransitionEasing || "ease_in_out"), + step_transition_force_curve: String(row.step_transition_force_curve || row.stepTransitionForceCurve || "late_target"), + step_transition_duration: Math.max(0, Number(row.step_transition_duration ?? row.stepTransitionDuration ?? 0) || 0), + step_transition_arrival: String(row.step_transition_arrival || row.stepTransitionArrival || "auto"), + step_transition_auto_fit: (row.step_transition_auto_fit ?? row.stepTransitionAutoFit ?? true) !== false, + }; + }); + } + async function applyImportedBoard(data) { + const board = data?.board && typeof data.board === "object" ? data.board : data || {}; + if ((!Array.isArray(board.images) || !board.images.length) && Array.isArray(data?.images)) board.images = data.images; + 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 ["duration_seconds", "frame_rate", "default_force", "promptrelay_epsilon", "image_width", "image_height", "image_resize_method", "image_multiple_of", "img_compression", "guide_policy"]) { + if (Object.prototype.hasOwnProperty.call(board, name) && getWidget(node, name)) setWidgetValue(node, name, name === "image_resize_method" ? cineResizeMethodValue(board[name]) : board[name]); + } + const paths = await packagedReferencePathsForImport(board, (message) => { + showTimelineNotice(message, /failed/i.test(String(message || "")) ? "error" : "warn"); + }); + if (paths.length) setOwnReferencePaths(node, paths); + else clearOwnReferencePaths(node); + + let loadedTimeline = null; + const timelineText = String(board.timeline_data || data?.timeline_data || "").trim(); + if (timelineText) { + try { loadedTimeline = JSON.parse(timelineText); } catch {} + } + if (!loadedTimeline && board.timeline && typeof board.timeline === "object") loadedTimeline = board.timeline; + if (loadedTimeline && typeof loadedTimeline === "object" && Array.isArray(loadedTimeline.segments)) { + timeline = { + schema: "iamccs.cine.filmmaker_timeline", + schema_version: Number(loadedTimeline.schema_version || 1), + segments: loadedTimeline.segments.map((seg) => ({ ...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)), + }; + } else { + const rows = Array.isArray(board.rows) + ? board.rows + : Array.isArray(loadedTimeline?.rows) + ? loadedTimeline.rows + : parseShotboardTimelineString(timelineText); + const converted = rowsToSegments(rows); + if (converted.length) { + timeline = { + schema: "iamccs.cine.filmmaker_timeline", + schema_version: 1, + segments: converted, + audioSegments: [], + audioTrackCount: 2, + audioSyncMode: "timeline_audio", + generationStrategy: "single_timeline", + }; + } + } + selectedId = timeline.segments?.[0]?.id || null; + writeTimeline(); + draw(); + } + importBoardBtn.onclick = () => boardInput.click(); + boardInput.onchange = async (event) => { + const file = event.target.files?.[0]; + if (!file) return; + try { + await applyImportedBoard(await readJsonFile(file)); + } catch (err) { + console.error("[IAMCCS Cine Shotboard V3] board import failed", err); + } finally { + boardInput.value = ""; + } + }; + root.addEventListener("dragover", (event) => { + if (Array.from(event.dataTransfer?.items || []).some((item) => item.kind === "file")) event.preventDefault(); + }); + root.addEventListener("drop", async (event) => { + const files = Array.from(event.dataTransfer?.files || []); + const file = files.find((item) => /\.json$/i.test(item.name || "")); + const audioFiles = files.filter((item) => String(item.type || "").startsWith("audio/")); + const imageFiles = files.filter((item) => String(item.type || "").startsWith("image/")); + if (!file && !audioFiles.length && !imageFiles.length) return; + event.preventDefault(); + if (file) { + try { + await applyImportedBoard(await readJsonFile(file)); + } catch (err) { + console.error("[IAMCCS Cine Shotboard V3] board drop import failed", err); + } + return; + } + if (imageFiles.length) { + addUploadedImagesToTimeline(await uploadShotboardImages(imageFiles)); + return; + } + if (audioFiles.length) { + await uploadAudioFiles(audioFiles); + return; + } + }); + collapseBtn.onclick = () => { + collapsed = !collapsed; + node.properties = node.properties || {}; + node.properties.iamccs_v3_collapsed = collapsed; + const nextHeight = currentNodeHeight(); + node._iamccsCineMinSize = [SHOTBOARD_V3_RIGID_WIDTH, nextHeight]; + if (typeof node.setSize === "function") node.setSize([SHOTBOARD_V3_RIGID_WIDTH, nextHeight]); + draw(); + }; + saveBtn.onclick = () => { + writeTimeline({ force: true }); + const board = { + metadata: { schema: "iamccs.cine.filmmaker_board", schema_version: 1, saved_at: new Date().toISOString(), node_type: nodeClassName(node) }, + global_prompt: promptArea.value, + timeline_data: getWidget(node, "timeline_data")?.value || "", + image_paths: refPaths(), + settings: { + duration_seconds: durationWidget?.value, + frame_rate: fpsWidget?.value, + default_force: defaultForceWidget?.value, + promptrelay_epsilon: getWidget(node, "promptrelay_epsilon")?.value, + image_width: imageWidthWidget?.value, + image_height: imageHeightWidget?.value, + 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, + }, + }; + downloadJsonFile(board, safeBoardFilename("cine_filmmaker_v3")); + }; + savePackageBtn.onclick = async () => { + try { + savePackageBtn.disabled = true; + writeTimeline({ force: true }); + const timelineText = getWidget(node, "timeline_data")?.value || ""; + const board = { + metadata: { schema: "iamccs.cine.filmmaker_board", schema_version: 1, saved_at: new Date().toISOString(), node_type: nodeClassName(node) }, + global_prompt: promptArea.value, + timeline_data: timelineText, + timeline, + image_paths: refPaths(), + settings: { + duration_seconds: durationWidget?.value, + frame_rate: fpsWidget?.value, + default_force: defaultForceWidget?.value, + promptrelay_epsilon: getWidget(node, "promptrelay_epsilon")?.value, + image_width: imageWidthWidget?.value, + image_height: imageHeightWidget?.value, + 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, + }, + }; + await saveShotboardPackageFolder(board, "cine_filmmaker_v3", (message) => { + showTimelineNotice(message, message && /failed/i.test(message) ? "error" : "warn"); + }); + } catch (err) { + console.error("[IAMCCS Cine Shotboard V3] package save failed", err); + showTimelineNotice(`Save Package failed: ${err?.message || err}`, "error"); + } finally { + savePackageBtn.disabled = false; + } + }; + clearBtn.onclick = () => { + stopPlayback(); + promptArea.value = ""; + setWidgetValue(node, "global_prompt", ""); + timeline = { schema: "iamccs.cine.filmmaker_timeline", schema_version: 2, segments: [], audioSegments: [], audioTrackCount: 2, audioSyncMode: "timeline_audio", generationStrategy: "single_timeline", flfrealMode: "iamccs_enhanced", globalPromptOnly: false }; + selectedId = null; + clearOwnReferencePaths(node); + writeTimeline(); + draw(); + }; + + const widget = node.addDOMWidget("Cine Shotboard V3", "iamccs_cine_shotboard_v3", root, { serialize: false }); + node._iamccsCineShotboardV3Widget = widget; + const v3RigidWidth = SHOTBOARD_V3_RIGID_WIDTH; + widget.computeSize = (width) => { + return [ + Math.max(v3RigidWidth, Number(width || v3RigidWidth)), + currentNodeHeight(), + ]; + }; + lockNodeMinimumSize(node, [v3RigidWidth, currentNodeHeight()], { + lockResize: false, + lockWidth: true, + preferredSize: [v3RigidWidth, currentNodeHeight()], + }); + setTimeout(draw, 0); +} + +function renderCinePromptArchitect(node) { + if (node._iamccsCinePromptArchitectReady === CINE_VERSION) return; + node._iamccsCinePromptArchitectReady = CINE_VERSION; + const cp = { + node: "#2B2118", + nodeBg: "#17120D", + rootA: "#101914", + rootB: "#2A1A10", + rootC: "#3B2314", + panel: "rgba(18,27,21,.86)", + panel2: "rgba(44,30,18,.74)", + paper: "#FFFFFF", + ink: "#141414", + text: "#F4EFE6", + muted: "#C7B49B", + border: "rgba(199,151,94,.42)", + borderStrong: "rgba(226,172,95,.68)", + accent: "#5B7A55", + accent2: "#B06B35", + dark: "#182219", + danger: "#8A3434", + }; + node.color = cp.node; + node.bgcolor = cp.nodeBg; + node.boxcolor = cp.accent; + + const widgetNames = [ + "template", + "subject_identity", + "environment", + "lighting_weather", + "visual_style", + "camera_language", + "continuity_rules", + "shot_goal", + "movement_path", + "target_reveal", + "performance_or_emotion", + "audio_or_dialogue", + "avoid", + "duration_seconds", + "frame_rate", + "beat_count", + "beat_data", + ]; + widgetNames.forEach((name) => hideWidget(getWidget(node, name))); + + const root = document.createElement("div"); + root.style.cssText = [ + "box-sizing:border-box", + "width:100%", + "min-height:760px", + "padding:14px", + `background:linear-gradient(145deg,${cp.rootA} 0%,${cp.rootB} 54%,${cp.rootC} 100%)`, + `border:1px solid ${cp.borderStrong}`, + "border-radius:8px", + `color:${cp.text}`, + "font-family:Arial,sans-serif", + "font-size:12px", + "overflow:hidden", + "box-shadow:inset 0 1px 0 rgba(255,255,255,.06), inset 0 -28px 70px rgba(0,0,0,.22)", + ].join(";"); + + const widgetValue = (name, fallback = "") => { + const widget = getWidget(node, name); + const value = widget?.value; + if (value === undefined || value === null || String(value) === "") return fallback; + return value; + }; + const setValue = (name, value) => setWidgetValue(node, name, value); + const templates = [ + "continuous_dolly", + "future_keyframe", + "image_text_image", + "dialogue_lipsync", + "reveal", + "environmental_transition", + ]; + const defaultBeatRows = () => ([ + { + label: "opening_contract", + duration: 3, + image_role: "opening anchor", + action: "the camera establishes the subject and begins one continuous physical movement", + bridge: "carry the same camera movement into the next beat without a hard cut", + }, + { + label: "development", + duration: 3, + image_role: "motion checkpoint", + action: "the movement deepens; environment details react physically as the camera advances", + bridge: "arrive toward the next visual target only near the end of the beat", + }, + { + label: "arrival", + duration: 3, + image_role: "target keyframe", + action: "the shot arrives at the strongest visual target with coherent identity and space", + bridge: "", + }, + ]); + const normalizeBeat = (beat, index) => ({ + label: String(beat?.label || beat?.name || `beat_${index + 1}`), + duration: Math.max(0.1, Number(beat?.duration ?? beat?.seconds ?? 3) || 3), + image_role: String(beat?.image_role ?? beat?.image_hint ?? ""), + action: String(beat?.action ?? beat?.local_prompt ?? beat?.prompt ?? "the camera continues one clear cinematic action through this timed beat"), + bridge: String(beat?.bridge ?? beat?.step_transition_prompt ?? beat?.action_to_next ?? ""), + }); + const parseBeats = () => { + try { + const data = JSON.parse(String(widgetValue("beat_data", "") || "").trim() || "{}"); + const raw = Array.isArray(data) ? data : Array.isArray(data?.beats) ? data.beats : []; + const rows = raw.map(normalizeBeat).filter(Boolean); + if (rows.length) return rows; + } catch { + // Fall through to a clean starter structure. + } + return defaultBeatRows(); + }; + let beats = parseBeats(); + let architectTextScale = Math.max(0.85, Math.min(1.45, Number(node.properties?.iamccs_cineprompt_text_scale || 1) || 1)); + const architectFontSize = (base = 12) => `${Math.max(9, Math.round(Number(base || 12) * architectTextScale * 10) / 10)}px`; + + const paperTextStyle = (opts = {}) => [ + inputBase(), + "width:100%", + "box-sizing:border-box", + `background:${cp.paper}!important`, + `color:${cp.ink}!important`, + `border:1px solid ${cp.border}`, + "border-radius:6px", + "font-family:'Courier New',Courier,monospace", + "font-weight:700", + `font-size:${architectFontSize(opts.preview ? 11 : 12)}`, + "letter-spacing:0", + "line-height:1.42", + "box-shadow:inset 0 1px 3px rgba(0,0,0,.08)", + opts.multiline ? `min-height:${opts.height || 74}px;resize:vertical;` : "height:32px;", + ].join(";"); + const fieldLabel = (label, control, hint = "") => { + const wrap = document.createElement("label"); + wrap.style.cssText = "display:flex;flex-direction:column;gap:6px;min-width:0;"; + const top = document.createElement("div"); + top.style.cssText = `display:grid;gap:3px;color:${cp.muted};font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:0;`; + const text = document.createElement("span"); + text.textContent = label; + top.appendChild(text); + if (hint) { + const h = document.createElement("span"); + h.textContent = hint; + h.style.cssText = "font-size:10px;font-weight:800;opacity:.92;text-transform:none;line-height:1.35;color:#E5D0AA;"; + top.appendChild(h); + } + wrap.append(top, control); + return wrap; + }; + const makeTextarea = (name, label, height, hint = "") => { + const el = document.createElement("textarea"); + el.value = String(widgetValue(name, "")); + el.rows = Math.max(2, Math.round((height || 74) / 24)); + el.style.cssText = paperTextStyle({ multiline: true, height }); + el.dataset.iamccsCpaPaper = "1"; + el.dataset.iamccsCpaHeight = String(height || 74); + el.oninput = () => { + setValue(name, el.value); + refreshPreview(); + }; + protectControlDrag(el); + return fieldLabel(label, el, hint); + }; + const makeBeatTextarea = (beat, key, placeholder, height = 96) => { + const el = document.createElement("textarea"); + el.value = String(beat[key] || ""); + el.placeholder = placeholder || ""; + el.rows = 4; + el.style.cssText = paperTextStyle({ multiline: true, height }); + el.dataset.iamccsCpaPaper = "1"; + el.dataset.iamccsCpaHeight = String(height || 96); + el.oninput = () => { + beat[key] = el.value; + syncBeats(); + }; + protectControlDrag(el); + return el; + }; + const makePaperInput = (value, onInput, placeholder = "") => { + const el = document.createElement("input"); + el.type = "text"; + el.value = String(value || ""); + el.placeholder = placeholder; + el.style.cssText = paperTextStyle(); + el.dataset.iamccsCpaPaper = "1"; + el.oninput = () => onInput(el.value); + protectControlDrag(el); + return el; + }; + const refreshArchitectPaperStyles = () => { + root.querySelectorAll?.("[data-iamccs-cpa-paper='1']").forEach((el) => { + const multiline = el.tagName === "TEXTAREA"; + const height = Number(el.dataset.iamccsCpaHeight || 74); + const preview = el.dataset.iamccsCpaPreview === "1"; + el.style.cssText = paperTextStyle({ multiline, height, preview }) + (el.readOnly ? "opacity:.82;" : ""); + }); + }; + const makeSelect = (value, options, onChange) => { + const select = document.createElement("select"); + options.forEach((item) => { + const opt = document.createElement("option"); + opt.value = item; + opt.textContent = String(item).replace(/_/g, " "); + select.appendChild(opt); + }); + select.value = options.includes(String(value)) ? String(value) : options[0]; + select.style.cssText = [ + inputBase(), + "height:32px", + `background:${cp.dark}`, + "color:#FFFFFF", + `border:1px solid ${cp.accent}`, + "border-radius:6px", + "font-weight:900", + "text-transform:capitalize", + ].join(";"); + select.onchange = () => onChange(select.value); + return protectControlDrag(select); + }; + const makeDarkNumberInput = (value, min, max, step, onChange) => { + const input = document.createElement("input"); + input.type = "number"; + input.value = String(value); + input.min = String(min); + if (max != null) input.max = String(max); + input.step = String(step); + input.style.cssText = [ + inputBase(), + "height:32px", + `background:${cp.dark}`, + "color:#FFFFFF", + `border:1px solid ${cp.accent}`, + "border-radius:6px", + "font-weight:900", + "text-align:center", + "font-variant-numeric:tabular-nums", + ].join(";"); + input.onchange = input.oninput = () => onChange(Number(input.value || value)); + input._iamccsSetValue = (next) => { input.value = String(next); }; + return protectControlDrag(input); + }; + const makeBtn = (label, tone = "neutral") => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = label; + const bg = tone === "primary" ? cp.accent : tone === "danger" ? cp.danger : "#E8E5D9"; + const fg = tone === "neutral" ? cp.ink : "#FFFFFF"; + const border = tone === "neutral" ? cp.borderStrong : bg; + btn.style.cssText = [ + "height:32px", + "padding:0 12px", + `border:1px solid ${border}`, + "border-radius:6px", + `background:${bg}`, + `color:${fg}`, + "font-weight:900", + "cursor:pointer", + "white-space:nowrap", + ].join(";"); + return protectControlDrag(btn); + }; + const totalBeatSeconds = () => beats.reduce((sum, beat) => sum + Math.max(0.1, Number(beat.duration || 0)), 0); + const buildGlobalPreview = () => { + const parts = [ + widgetValue("shot_goal", ""), + widgetValue("subject_identity", ""), + widgetValue("environment", ""), + widgetValue("lighting_weather", ""), + widgetValue("visual_style", ""), + widgetValue("camera_language", ""), + widgetValue("movement_path", ""), + widgetValue("target_reveal", ""), + widgetValue("performance_or_emotion", ""), + widgetValue("audio_or_dialogue", ""), + widgetValue("continuity_rules", ""), + ].map((part) => String(part || "").trim()).filter(Boolean); + return parts.join(", ").replace(/\s+/g, " "); + }; + const buildLocalPreview = (beat, index) => { + const parts = [ + beat.action, + index < beats.length - 1 ? beat.bridge : "", + String(widgetValue("template", "continuous_dolly")) === "dialogue_lipsync" && String(widgetValue("audio_or_dialogue", "")).trim() + ? "lips sync naturally to the dialogue, diction is clear, facial expression remains alive and precise" + : "", + index === beats.length - 1 ? widgetValue("target_reveal", "") : "", + ].map((part) => String(part || "").trim()).filter(Boolean); + return parts.join(", ").replace(/\s+/g, " "); + }; + + let previewBox = null; + let status = null; + let list = null; + let durationCtrl = null; + let beatCountCtrl = null; + const refreshPreview = () => { + if (!previewBox) return; + const fps = Math.max(1, Number(widgetValue("frame_rate", 24)) || 24); + const duration = Math.max(0.1, Number(widgetValue("duration_seconds", totalBeatSeconds())) || totalBeatSeconds()); + const scale = duration / Math.max(0.1, totalBeatSeconds()); + const frames = beats.map((beat) => Math.max(1, Math.round(Math.max(0.1, Number(beat.duration || 0)) * scale * fps))); + if (frames.length) frames[frames.length - 1] = Math.max(1, frames[frames.length - 1] + Math.round(duration * fps) - frames.reduce((a, b) => a + b, 0)); + const locals = beats.map(buildLocalPreview); + previewBox.value = [ + "GLOBAL PROMPT", + buildGlobalPreview(), + "", + "LOCAL PROMPTS", + locals.map((text, index) => `${index + 1}. ${text}`).join("\n"), + "", + "SEGMENT LENGTHS", + frames.join(","), + ].join("\n"); + if (status) { + status.textContent = `Template: ${String(widgetValue("template", "continuous_dolly")).replace(/_/g, " ")} | Beats: ${beats.length} | Beat script: ${totalBeatSeconds().toFixed(1)}s | Output target: ${duration.toFixed(1)}s @ ${fps}fps`; + } + }; + const syncBeats = () => { + beats = beats.map(normalizeBeat); + setValue("beat_data", JSON.stringify({ beats }, null, 2)); + setValue("beat_count", beats.length); + beatCountCtrl?._iamccsSetValue?.(beats.length); + refreshPreview(); + }; + const fitDuration = () => { + const total = Number(totalBeatSeconds().toFixed(3)); + setValue("duration_seconds", total); + durationCtrl?._iamccsSetValue?.(total); + refreshPreview(); + }; + + const head = document.createElement("div"); + head.style.cssText = [ + "display:flex", + "align-items:center", + "gap:12px", + "padding:11px 12px", + `background:${cp.dark}`, + "color:#FFFFFF", + "border-radius:7px", + "border:1px solid rgba(0,0,0,.25)", + ].join(";"); + const titleBlock = document.createElement("div"); + titleBlock.style.cssText = "display:grid;gap:2px;flex:1;min-width:0;"; + const title = document.createElement("div"); + title.textContent = "IAMCCS CinePrompt Architect"; + title.style.cssText = "font-size:18px;font-weight:900;letter-spacing:0;"; + const subtitle = document.createElement("div"); + subtitle.textContent = "Build LTX 2.3 / PromptRelay-ready prompts, then send timing and text into Shotboard V3 or V2."; + subtitle.style.cssText = "font-size:11px;font-weight:800;color:#DDE5DE;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"; + titleBlock.append(title, subtitle); + const headActions = document.createElement("div"); + headActions.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;"; + head.append(titleBlock, headActions); + + const topGrid = document.createElement("div"); + topGrid.style.cssText = [ + "display:grid", + "grid-template-columns:minmax(250px,1.2fr) 150px 120px 120px 150px", + "gap:10px", + "margin-top:10px", + `padding:10px`, + `background:${cp.panel}`, + `border:1px solid ${cp.border}`, + "border-radius:7px", + ].join(";"); + const templateSelect = makeSelect(widgetValue("template", "continuous_dolly"), templates, (value) => { + setValue("template", value); + refreshPreview(); + }); + durationCtrl = makeDarkNumberInput(Number(widgetValue("duration_seconds", totalBeatSeconds() || 9)), 0.1, 600, 0.1, (value) => { + setValue("duration_seconds", Number(value)); + refreshPreview(); + }); + const fpsCtrl = makeDarkNumberInput(Number(widgetValue("frame_rate", 24)), 1, 120, 1, (value) => { + setValue("frame_rate", Number(value)); + refreshPreview(); + }); + beatCountCtrl = makeDarkNumberInput(beats.length, 1, 8, 1, (value) => { + const target = Math.max(1, Math.min(8, Number(value) || 1)); + while (beats.length < target) beats.push(normalizeBeat({}, beats.length)); + while (beats.length > target) beats.pop(); + syncBeats(); + drawBeats(); + }); + const fontSizeSelect = makeSelect( + architectTextScale <= 0.92 ? "small" : architectTextScale >= 1.18 ? "large" : "medium", + ["small", "medium", "large"], + (value) => { + architectTextScale = value === "small" ? 0.9 : value === "large" ? 1.22 : 1; + node.properties = node.properties || {}; + node.properties.iamccs_cineprompt_text_scale = architectTextScale; + refreshArchitectPaperStyles(); + drawBeats(); + } + ); + topGrid.append( + fieldLabel("Template", templateSelect), + fieldLabel("Duration", durationCtrl, "seconds"), + fieldLabel("Frame rate", fpsCtrl, "fps"), + fieldLabel("Beat count", beatCountCtrl), + fieldLabel("Text size", fontSizeSelect) + ); + + const structureGrid = document.createElement("div"); + structureGrid.style.cssText = [ + "display:grid", + "grid-template-columns:minmax(640px,1fr)", + "gap:12px", + "margin-top:10px", + `padding:12px`, + `background:linear-gradient(180deg,${cp.panel} 0%,${cp.panel2} 100%)`, + `border:1px solid ${cp.border}`, + "border-radius:7px", + ].join(";"); + const globalTitle = document.createElement("div"); + globalTitle.textContent = "Global prompt hierarchy"; + globalTitle.style.cssText = `color:${cp.text};font-size:13px;font-weight:900;text-transform:uppercase;letter-spacing:0;border-bottom:1px solid ${cp.border};padding-bottom:8px;`; + structureGrid.appendChild(globalTitle); + [ + ["shot_goal", "01. Shot goal", 72, "Define the promise of the shot. Example: \"one continuous dolly from a living face into the ocean inside the pupil\"."], + ["subject_identity", "02. Subject identity", 72, "Permanent identity anchors. Example: \"same adult man, same blue eye, calm attentive expression, natural skin texture\"."], + ["environment", "03. Environment", 72, "Where the shot physically exists. Example: \"dim studio face close-up that opens into a coastal ocean world\"."], + ["lighting_weather", "04. Lighting / weather", 72, "Stable light and atmosphere. Example: \"soft catchlights on the eye, overcast coastal light, gentle cloud movement\"."], + ["visual_style", "05. Visual style", 72, "Image language and realism level. Example: \"cinematic realism, moist macro detail, natural parallax, grounded camera physics\"."], + ["camera_language", "06. Camera language", 72, "The camera behavior. Example: \"slow forward dolly, macro push-in, optical tunnel movement through the pupil\"."], + ["movement_path", "07. Movement path", 72, "The exact travel path over time. Example: \"face to eye, eye to pupil, pupil to coastline, coastline to waves\"."], + ["target_reveal", "08. Target / reveal", 72, "What the shot is moving toward. Example: \"the pupil becomes a coastal sea view that fills the frame\"."], + ["performance_or_emotion", "09. Performance / emotion", 72, "Human or action performance. Example: \"tiny eye-focus shifts, faint breathing, calm concentration\"."], + ["audio_or_dialogue", "10. Audio / dialogue", 72, "Speech, music or sync intent. Example: \"low ocean rumble grows as the camera reaches the surf\"."], + ["continuity_rules", "11. Continuity rules", 72, "Positive continuity instructions. Example: \"every waypoint belongs to the same forward camera path with stable exposure\"."], + ["avoid", "12. Avoid / risk notes", 72, "Use sparingly for internal risk notes. Example: \"identity drift, frozen water, early arrival at the ocean\"."], + ].forEach(([name, label, height, hint]) => { + structureGrid.appendChild(makeTextarea(name, label, height, hint || "")); + }); + + status = document.createElement("div"); + status.style.cssText = [ + "margin-top:10px", + "height:34px", + "display:flex", + "align-items:center", + "padding:0 10px", + `background:${cp.dark}`, + "color:#FFFFFF", + "border-radius:6px", + "font-size:12px", + "font-weight:900", + "white-space:nowrap", + "overflow:hidden", + "text-overflow:ellipsis", + ].join(";"); + + const beatTitle = document.createElement("div"); + beatTitle.textContent = "PromptRelay beat builder"; + beatTitle.style.cssText = `margin-top:12px;margin-bottom:6px;font-size:13px;font-weight:900;color:${cp.text};text-transform:uppercase;letter-spacing:0;border-bottom:1px solid ${cp.border};padding-bottom:7px;`; + list = document.createElement("div"); + list.style.cssText = "display:flex;flex-direction:column;gap:9px;max-height:660px;overflow-y:auto;overflow-x:hidden;padding-right:4px;scrollbar-gutter:stable;"; + + const drawBeats = () => { + if (!list) return; + list.innerHTML = ""; + beats.forEach((beat, index) => { + const row = document.createElement("div"); + row.style.cssText = [ + "display:grid", + "grid-template-columns:82px minmax(190px,.6fr) minmax(360px,1fr) minmax(360px,1fr) minmax(360px,1fr)", + "gap:12px", + "align-items:stretch", + "padding:12px", + `background:${cp.panel2}`, + `border:1px solid ${cp.border}`, + "border-radius:7px", + "box-shadow:0 7px 22px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.04)", + ].join(";"); + const badge = document.createElement("div"); + badge.textContent = `BEAT\n${String(index + 1).padStart(2, "0")}`; + badge.style.cssText = [ + "white-space:pre-line", + "display:flex", + "align-items:center", + "justify-content:center", + "text-align:center", + `background:${cp.dark}`, + "color:#FFFFFF", + "border-radius:6px", + "font-weight:900", + "line-height:1.2", + ].join(";"); + const meta = document.createElement("div"); + meta.style.cssText = "display:grid;grid-template-rows:auto auto auto;gap:8px;min-width:0;"; + const labelInput = makePaperInput(beat.label, (value) => { + beat.label = value; + syncBeats(); + }, "beat label"); + const roleInput = makePaperInput(beat.image_role, (value) => { + beat.image_role = value; + syncBeats(); + }, "image role / anchor"); + const dur = numberStepperControl(Number(beat.duration || 3), "0.1", "0.1", "120", (value) => { + beat.duration = Math.max(0.1, Number(value) || 0.1); + syncBeats(); + }, { liveInput: false }); + dur.style.gridTemplateColumns = "30px minmax(70px,1fr) 30px"; + styleValueControls(dur); + meta.append( + fieldLabel("Label", labelInput, "Short timeline name. Example: \"pupil_to_ocean\"."), + fieldLabel("Image role", roleInput, "What this waypoint represents. Example: \"macro eye anchor\" or \"wave endpoint\"."), + fieldLabel("Beat length", dur, "Seconds for this beat. Use this as the main timing control.") + ); + const action = makeBeatTextarea(beat, "action", "Describe exactly what happens in this timed beat.", 128); + const bridge = makeBeatTextarea(beat, "bridge", "Optional: how this beat moves into the next beat.", 128); + const localPreview = document.createElement("textarea"); + localPreview.readOnly = true; + localPreview.value = buildLocalPreview(beat, index); + localPreview.style.cssText = paperTextStyle({ multiline: true, height: 128, preview: true }) + "opacity:.82;"; + localPreview.dataset.iamccsCpaPaper = "1"; + localPreview.dataset.iamccsCpaPreview = "1"; + localPreview.dataset.iamccsCpaHeight = "128"; + protectControlDrag(localPreview); + row.append( + badge, + meta, + fieldLabel("Action text", action, "Visible movement for this beat. Example: \"the camera moves closer to the pupil while iris fibers expand with parallax\"."), + fieldLabel("Bridge / relay", bridge, "How this beat arrives into the next one. Example: \"the pupil grows into a coastal horizon at the end of the beat\"."), + fieldLabel("Local prompt preview", localPreview, "Read-only preview of the text that will be sent to PromptRelay for this beat.") + ); + list.appendChild(row); + }); + refreshPreview(); + }; + + const previewTitle = document.createElement("div"); + previewTitle.textContent = "Generated prompt preview"; + previewTitle.style.cssText = `margin-top:12px;margin-bottom:6px;font-size:13px;font-weight:900;color:${cp.text};text-transform:uppercase;letter-spacing:0;border-bottom:1px solid ${cp.border};padding-bottom:7px;`; + previewBox = document.createElement("textarea"); + previewBox.readOnly = true; + previewBox.style.cssText = paperTextStyle({ multiline: true, height: 220 }) + "min-height:220px;"; + previewBox.dataset.iamccsCpaPaper = "1"; + previewBox.dataset.iamccsCpaHeight = "220"; + protectControlDrag(previewBox); + + root.append(head, topGrid, structureGrid, status, beatTitle, list, previewTitle, previewBox); + const widget = node.addDOMWidget("IAMCCS CinePrompt Architect", "iamccs_cineprompt_architect", root, { serialize: false }); + widget.computeSize = (width) => [Math.max(1720, Number(width || 1720)), 1180]; + lockNodeMinimumSize(node, [1720, 1220], { lockResize: false, preferredSize: [1720, 1240] }); + syncBeats(); + drawBeats(); +} + +function renderBoardMaker(node) { + if (node._iamccsBoardMakerReady === CINE_VERSION) return; + node._iamccsBoardMakerReady = CINE_VERSION; + const bm = { + node: "#3A1014", + nodeBg: "#211012", + rootA: "#19080A", + rootB: "#0E0607", + panel: "#240D10", + panelDark: "#170709", + card: "#251011", + paperCard: "#F3E7E0", + border: "#7D2B35", + borderSoft: "#51202A", + accent: "#A93645", + accentBorder: "#F08A95", + danger: "#762A30", + dangerBorder: "#D85A65", + muted: "#E4B7BB", + text: "#FFF1F2", + valueBg: "#120608", + }; + node.color = bm.node; + node.bgcolor = bm.nodeBg; + node.boxcolor = bm.accent; + const root = document.createElement("div"); + root.style.cssText = [ + "box-sizing:border-box", + "width:100%", + "min-height:700px", + "padding:12px", + `background:linear-gradient(180deg,${bm.rootA} 0%,${bm.rootB} 100%)`, + `border:1px solid ${bm.border}`, + "border-radius:8px", + "box-shadow:inset 0 1px 0 rgba(255,255,255,.06)", + "overflow:hidden", + `color:${bm.text}`, + "font-family:Arial,sans-serif", + "font-size:12px", + ].join(";"); + + const globalWidget = getWidget(node, "global_prompt"); + const durationWidget = getWidget(node, "duration_seconds"); + const fpsWidget = getWidget(node, "frame_rate"); + const imageWidthWidget = getWidget(node, "image_width"); + const imageHeightWidget = getWidget(node, "image_height"); + const forceWidget = getWidget(node, "default_force"); + const guidePolicyWidget = getWidget(node, "guide_policy"); + const boardNameWidget = getWidget(node, "board_name"); + const dataWidget = getWidget(node, "board_data"); + ["global_prompt", "duration_seconds", "frame_rate", "image_width", "image_height", "default_force", "guide_policy", "board_name", "board_data"].forEach((name) => hideWidget(getWidget(node, name))); + + const defaultRows = () => ([ + { label: "box_1", duration: 3, local_prompt: "opening beat, establish subject and camera direction", bridge: "camera continues into the next beat through one connected movement", camera: "slow push-in", transition: "continuous_motion", force: Number(forceWidget?.value || 0.28), use_guide: true, use_prompt: true }, + { label: "box_2", duration: 3, local_prompt: "second beat, deepen action and maintain visual continuity", bridge: "movement carries the viewer toward the final beat", camera: "continuous dolly-in", transition: "continuous_motion", force: Number(forceWidget?.value || 0.28), use_guide: true, use_prompt: true }, + { label: "box_3", duration: 3, local_prompt: "final beat, arrive at the strongest visual target", bridge: "", camera: "macro push-in", transition: "continuous_motion", force: Number(forceWidget?.value || 0.28), use_guide: true, use_prompt: true }, + ]); + const normalizeMakerRow = (row, index) => ({ + label: String(row?.label || row?.name || `box_${index + 1}`), + duration: Math.max(0.1, Number(row?.duration ?? row?.seconds ?? row?.len ?? 3) || 3), + local_prompt: String(row?.local_prompt ?? row?.relay_prompt ?? row?.prompt ?? ""), + bridge: String(row?.bridge ?? row?.step_transition_prompt ?? row?.action_to_next ?? row?.note ?? ""), + camera: String(row?.camera || "continuous dolly-in"), + transition: String(row?.transition || "continuous_motion"), + force: Math.max(0, Math.min(1, Number(row?.force ?? row?.guide_strength ?? forceWidget?.value ?? 0.28) || 0)), + image_hint: String(row?.image_hint ?? row?.image_note ?? row?.image_name ?? ""), + use_guide: row?.use_guide !== false, + use_prompt: row?.use_prompt !== false, + }); + const parseRows = () => { + try { + const data = JSON.parse(String(dataWidget?.value || "").trim() || "{}"); + const raw = Array.isArray(data) ? data : Array.isArray(data?.rows) ? data.rows : []; + const parsed = raw.map(normalizeMakerRow).filter(Boolean); + return parsed.length ? parsed : defaultRows(); + } catch { + return defaultRows(); + } + }; + let rows = parseRows(); + let boxStyleMode = String(node.properties?.iamccs_boardmaker_box_style || "dark") === "paper" ? "paper" : "dark"; + let boxTextScale = Math.max(0.85, Math.min(1.7, Number(node.properties?.iamccs_boardmaker_text_scale || 1) || 1)); + const boardMakerBoxTextCss = () => boxStyleMode === "paper" + ? "background:#F8F1EC!important;border-color:#B9949A!important;color:#050505!important;font-family:'Courier New',Courier,monospace!important;font-weight:700!important;" + : `background:${bm.valueBg};border-color:${bm.border};color:${bm.text};`; + const boardMakerBoxTextFontCss = (opts = {}) => opts.boxText + ? `font-size:${Math.round(12 * boxTextScale)}px;line-height:1.38;` + : ""; + const boardMakerTextBoxStyle = (opts = {}) => inputBase() + `width:100%;box-sizing:border-box;${boardMakerBoxTextCss()}${boardMakerBoxTextFontCss({ boxText: true })}border-radius:6px;${opts.multiline ? `min-height:${opts.height || 58}px;resize:vertical;` : "height:32px;"}`; + let refreshBoardMakerTextStyles = () => { + root.querySelectorAll?.("[data-iamccs-boardmaker-textbox='1']").forEach((el) => { + const multiline = el.tagName === "TEXTAREA"; + const height = Number(el.dataset.iamccsHeight || 58); + el.style.cssText = boardMakerTextBoxStyle({ multiline, height }); + }); + }; + + const makeBtn = (label, tone = "neutral") => { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = label; + const bg = tone === "primary" ? bm.accent : tone === "danger" ? bm.danger : "#321216"; + const border = tone === "primary" ? bm.accentBorder : tone === "danger" ? bm.dangerBorder : bm.border; + b.style.cssText = `height:32px;padding:0 12px;border:1px solid ${border};border-radius:6px;background:${bg};color:${bm.text};font-weight:900;cursor:pointer;white-space:nowrap;box-shadow:inset 0 1px 0 rgba(255,255,255,.08);`; + return protectControlDrag(b); + }; + const makeText = (value, onInput, opts = {}) => { + const el = document.createElement(opts.multiline ? "textarea" : "input"); + if (!opts.multiline) el.type = "text"; + el.value = value || ""; + el.placeholder = opts.placeholder || ""; + el.rows = opts.rows || 2; + if (opts.boxText) { + el.dataset.iamccsBoardmakerTextbox = "1"; + el.dataset.iamccsHeight = String(opts.height || 58); + el.style.cssText = boardMakerTextBoxStyle(opts); + } else { + const colorCss = `background:${bm.valueBg};border-color:${bm.border};color:${bm.text};`; + el.style.cssText = inputBase() + `width:100%;box-sizing:border-box;${colorCss}border-radius:6px;${opts.multiline ? `min-height:${opts.height || 58}px;resize:vertical;line-height:1.35;` : "height:32px;"}`; + } + el.oninput = () => onInput(el.value); + protectControlDrag(el); + return el; + }; + const makeSelect = (value, options, onChange) => { + const el = document.createElement("select"); + options.forEach((item) => { + const opt = document.createElement("option"); + opt.value = item; + opt.textContent = item.replace(/_/g, " "); + el.appendChild(opt); + }); + el.value = options.includes(String(value)) ? String(value) : options[0]; + el.style.cssText = inputBase() + `height:32px;background:${bm.valueBg};border-color:${bm.border};color:${bm.text};border-radius:6px;`; + el.onchange = () => onChange(el.value); + return protectControlDrag(el); + }; + const sync = () => { + const cleanRows = rows.map(normalizeMakerRow); + rows = cleanRows; + setWidgetValue(node, "board_data", JSON.stringify({ rows: cleanRows }, null, 2)); + setWidgetValue(node, "global_prompt", globalPrompt.value); + setWidgetValue(node, "board_name", boardName.value); + setWidgetValue(node, "duration_seconds", Number(durationControlValue())); + setWidgetValue(node, "frame_rate", Number(fpsControlValue())); + setWidgetValue(node, "image_width", Number(imageWidthControlValue())); + setWidgetValue(node, "image_height", Number(imageHeightControlValue())); + setWidgetValue(node, "default_force", Number(defaultForceValue())); + setWidgetValue(node, "guide_policy", guidePolicy.value); + }; + const totalRowsDuration = () => rows.reduce((sum, row) => sum + Math.max(0.1, Number(row.duration || 0)), 0); + let durationControlValue = () => Number(durationWidget?.value || 9); + let fpsControlValue = () => Number(fpsWidget?.value || 24); + let imageWidthControlValue = () => Number(imageWidthWidget?.value || 768); + let imageHeightControlValue = () => Number(imageHeightWidget?.value || 432); + let defaultForceValue = () => Number(forceWidget?.value || 0.28); + const makerRowsFromBoard = (data) => { + const board = boardFromWorkflowJson(data) || data || {}; + const rawRows = Array.isArray(board.rows) ? board.rows : parseShotboardTimelineString(String(board.timeline_data || "")); + const images = Array.isArray(board.images) ? board.images : []; + return rawRows.map((row, index) => { + const next = rawRows[index + 1] || null; + const currentSecond = Number(row?.second ?? 0); + const nextSecond = next ? Number(next?.second ?? currentSecond + 3) : null; + const inferredDuration = Number(row?.duration ?? row?.len ?? row?.seconds ?? (nextSecond != null ? nextSecond - currentSecond : 3)); + const imageInfo = images.find((img) => Number(img?.ref) === Number(row?.ref ?? index + 1)) || null; + return normalizeMakerRow({ + ...row, + duration: Math.max(0.1, Number.isFinite(inferredDuration) ? inferredDuration : 3), + local_prompt: row?.relay_prompt ?? row?.local_prompt ?? row?.prompt ?? "", + bridge: row?.step_transition_prompt ?? row?.bridge ?? row?.note ?? "", + image_hint: imageInfo?.name || imageInfo?.path || row?.image_hint || "", + }, index); + }).filter(Boolean); + }; + + const collectBoard = () => { + sync(); + const fps = Math.max(1, Number(fpsControlValue()) || 24); + const imageWidth = Math.max(64, Math.round(Number(imageWidthControlValue()) || 768)); + const imageHeight = Math.max(64, Math.round(Number(imageHeightControlValue()) || 432)); + const totalDuration = Math.max(0.1, Number(durationControlValue()) || 0, totalRowsDuration()); + let cursor = 0; + const shotRows = rows.map((row, index) => { + const duration = Math.max(0.1, Number(row.duration || 0) || 0.1); + const bridge = String(row.bridge || "").trim(); + const local = String(row.local_prompt || "").trim(); + const shot = { + _ui_id: `boardmaker_${index + 1}_${String(row.label || `box_${index + 1}`).replace(/[^\w.-]+/g, "_")}`, + second: Number(cursor.toFixed(3)), + frame: Math.round(cursor * fps), + ref: index + 1, + force: Math.max(0, Math.min(1, Number(row.force || defaultForceValue()) || 0)), + image_lock_strength: Math.max(0, Math.min(1, Number(row.force || defaultForceValue()) || 0)), + use_guide: row.use_guide !== false, + use_prompt: row.use_prompt !== false && Boolean(local || bridge), + label: String(row.label || `box_${index + 1}`), + camera: String(row.camera || "continuous dolly-in"), + transition: String(row.transition || "continuous_motion"), + image_hint: String(row.image_hint || ""), + note: bridge, + relay_prompt: local, + use_relay_modifiers: false, + camera_relay_mode: "off", + transition_relay_mode: "off", + relay_addon_position: "after", + relay_modifier_text: "", + step_transition_enabled: Boolean(bridge && index < rows.length - 1), + step_transition_type: bridge && index < rows.length - 1 ? "action_beat" : "off", + step_transition_prompt: index < rows.length - 1 ? bridge : "", + step_transition_easing: "ease_in_out", + step_transition_force_curve: "balanced", + step_transition_duration: index < rows.length - 1 ? duration : 0, + step_transition_arrival: "auto", + step_transition_auto_fit: true, + duration, + }; + cursor += duration; + return shot; + }); + const timelineData = JSON.stringify({ rows: shotRows }, null, 2); + const boardNameClean = String(boardName.value || "iamccs_boardmaker_board").trim() || "iamccs_boardmaker_board"; + return { + metadata: { + schema: "iamccs.cine.shotboard.board", + schema_version: 1, + cine_ui_version: "2026-05-19-boardmaker-1", + saved_at: new Date().toISOString(), + node_type: "IAMCCS_BoardMaker", + board_name: boardNameClean, + image_storage: "manual_after_import", + notes: "BoardMaker exports prompts and timing only. Add reference images manually in Shotboard after import.", + }, + global_prompt: globalPrompt.value, + prompt: globalPrompt.value, + timeline_data: timelineData, + rows: shotRows, + settings: { + duration_seconds: totalDuration, + frame_rate: fps, + guide_policy: guidePolicy.value, + min_guide_gap_seconds: 0, + max_guides: Math.max(1, shotRows.length), + default_force: Number(defaultForceValue()) || 0.28, + promptrelay_epsilon: 0.6, + ltx_round_mode: "up_8n_plus_1", + image_width: imageWidth, + image_height: imageHeight, + image_resize_method: "crop", + image_multiple_of: 32, + img_compression: 0, + }, + duration_seconds: totalDuration, + frame_rate: fps, + guide_policy: guidePolicy.value, + default_force: Number(defaultForceValue()) || 0.28, + image_width: imageWidth, + image_height: imageHeight, + image_paths: "", + images: [], + }; + }; + + const head = document.createElement("div"); + head.style.cssText = `display:flex;align-items:center;gap:10px;margin-bottom:10px;padding:10px;border:1px solid ${bm.border};border-radius:7px;background:${bm.panel};`; + const titleBlock = document.createElement("div"); + titleBlock.style.cssText = "display:grid;gap:2px;flex:1;min-width:0;"; + const title = document.createElement("div"); + title.textContent = "IAMCCS_BoardMaker"; + title.style.cssText = `font-size:17px;font-weight:900;color:${bm.text};letter-spacing:0;`; + const subtitle = document.createElement("div"); + subtitle.textContent = "Script assistant for Shotboard V2/V3 boards: timing, prompts, relay beats first; images are added manually after import."; + subtitle.style.cssText = `font-size:11px;font-weight:800;color:${bm.muted};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`; + titleBlock.append(title, subtitle); + const actions = document.createElement("div"); + actions.style.cssText = "display:flex;align-items:center;gap:7px;flex-wrap:wrap;justify-content:flex-end;"; + const addBtn = makeBtn("Add Beat", "primary"); + const fitBtn = makeBtn("Fit Duration"); + const styleBtn = makeBtn(boxStyleMode === "paper" ? "Dark Boxes" : "Paper Boxes"); + styleBtn.title = "Switch prompt boxes between the dark assistant style and white Courier New paper boxes."; + const textSizeWrap = document.createElement("div"); + textSizeWrap.style.cssText = `height:32px;display:flex;align-items:center;gap:5px;padding:0 6px;border:1px solid ${bm.border};border-radius:6px;background:${bm.panelDark};`; + const textSizeLabel = document.createElement("span"); + textSizeLabel.style.cssText = `min-width:42px;text-align:center;color:${bm.muted};font-size:10px;font-weight:900;`; + const refreshTextSizeLabel = () => { textSizeLabel.textContent = `${Math.round(boxTextScale * 100)}%`; }; + const textSizeBtn = (label, delta) => { + const btn = makeBtn(label); + btn.style.width = "28px"; + btn.style.height = "24px"; + btn.style.padding = "0"; + btn.title = label === "+" ? "Increase prompt text size" : "Decrease prompt text size"; + btn.onclick = () => { + boxTextScale = Math.max(0.85, Math.min(1.7, Math.round((boxTextScale + delta) * 100) / 100)); + node.properties = node.properties || {}; + node.properties.iamccs_boardmaker_text_scale = boxTextScale; + refreshTextSizeLabel(); + refreshBoardMakerTextStyles(); + draw(); + }; + return btn; + }; + refreshTextSizeLabel(); + textSizeWrap.append(textSizeBtn("-", -0.1), textSizeLabel, textSizeBtn("+", 0.1)); + const openEditorBtn = makeBtn("Open Editor"); + openEditorBtn.title = "Open this BoardMaker in a full-frame editor view."; + const importBtn = makeBtn("Import Board"); + importBtn.title = "Import an existing Shotboard board JSON into BoardMaker for editing."; + const exportBtn = makeBtn("Export Board", "primary"); + const clearBtn = makeBtn("Reset", "danger"); + const importInput = document.createElement("input"); + importInput.type = "file"; + importInput.accept = ".json,application/json"; + importInput.style.display = "none"; + root.appendChild(importInput); + actions.append(styleBtn, textSizeWrap, openEditorBtn, importBtn, addBtn, fitBtn, exportBtn, clearBtn); + head.append(titleBlock, actions); + root.addEventListener("iamccs:cine-fullscreen", (event) => { + openEditorBtn.textContent = event.detail?.open ? "Close Editor" : "Open Editor"; + }); + + const globalPrompt = makeText(String(globalWidget?.value || ""), (value) => { setWidgetValue(node, "global_prompt", value); }, { multiline: true, rows: 4, height: 88, placeholder: "Global prompt for the whole board...", boxText: true }); + const topGrid = document.createElement("div"); + topGrid.style.cssText = `display:grid;grid-template-columns:minmax(320px,1fr) 190px 130px 150px 150px 170px 230px;gap:12px;margin:9px 0;padding:10px;border:1px solid ${bm.borderSoft};border-radius:7px;background:${bm.panelDark};`; + const labelWrap = (label, control) => { + const wrap = document.createElement("label"); + wrap.style.cssText = `display:flex;flex-direction:column;gap:5px;color:${bm.muted};font-size:10px;font-weight:900;min-width:0;`; + const span = document.createElement("span"); + span.textContent = label; + wrap.append(span, control); + return wrap; + }; + const boardName = makeText(String(boardNameWidget?.value || "iamccs_boardmaker_board"), (value) => { setWidgetValue(node, "board_name", value); }, { placeholder: "board name" }); + const durationCtrl = numberStepperControl(Number(durationWidget?.value || totalRowsDuration() || 9), "0.1", "0.1", null, (value) => { + durationControlValue = () => Number(value); + setWidgetValue(node, "duration_seconds", Number(value)); + }, { liveInput: false }); + styleValueControls(durationCtrl); + const fpsCtrl = numberStepperControl(Number(fpsWidget?.value || 24), "1", "1", null, (value) => { + fpsControlValue = () => Number(value); + setWidgetValue(node, "frame_rate", Number(value)); + }, { liveInput: false }); + styleValueControls(fpsCtrl); + const widthCtrl = numberStepperControl(Number(imageWidthWidget?.value || 768), "32", "64", null, (value) => { + imageWidthControlValue = () => Number(value); + setWidgetValue(node, "image_width", Number(value)); + }, { liveInput: false }); + styleValueControls(widthCtrl); + const heightCtrl = numberStepperControl(Number(imageHeightWidget?.value || 432), "32", "64", null, (value) => { + imageHeightControlValue = () => Number(value); + setWidgetValue(node, "image_height", Number(value)); + }, { liveInput: false }); + styleValueControls(heightCtrl); + const forceCtrl = numberStepperControl(Number(forceWidget?.value || 0.28), "0.01", "0", "1", (value) => { + defaultForceValue = () => Number(value); + setWidgetValue(node, "default_force", Number(value)); + }, { liveInput: false }); + styleValueControls(forceCtrl); + const guidePolicy = makeSelect(String(guidePolicyWidget?.value || "every_checked_row"), ["every_checked_row", "first_last", "all", "none"], (value) => setWidgetValue(node, "guide_policy", value)); + topGrid.append( + labelWrap("Board name", boardName), + labelWrap("Total duration", durationCtrl), + labelWrap("FPS", fpsCtrl), + labelWrap("Project width", widthCtrl), + labelWrap("Project height", heightCtrl), + labelWrap("Default force", forceCtrl), + labelWrap("Guide policy", guidePolicy) + ); + + const status = document.createElement("div"); + status.style.cssText = `min-height:34px;display:flex;align-items:center;gap:8px;padding:5px 8px;margin:9px 0;border:1px solid ${bm.border};border-radius:7px;background:${bm.valueBg};color:${bm.muted};font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`; + const list = document.createElement("div"); + list.style.cssText = "display:flex;flex-direction:column;gap:8px;max-height:780px;overflow-y:auto;overflow-x:hidden;padding-right:4px;scrollbar-gutter:stable;"; + const promptPanel = document.createElement("div"); + promptPanel.style.cssText = `padding:10px;border:1px solid ${bm.borderSoft};border-radius:7px;background:${bm.panelDark};`; + promptPanel.appendChild(labelWrap("Global prompt", globalPrompt)); + + const draw = () => { + list.innerHTML = ""; + rows.forEach((row, index) => { + const card = document.createElement("div"); + card.style.cssText = `display:grid;grid-template-columns:76px 170px minmax(300px,340px) minmax(390px,1fr) minmax(390px,1fr) 92px;gap:12px;align-items:stretch;padding:12px;border:1px solid ${bm.border};border-radius:7px;background:${bm.card}!important;box-shadow:inset 0 1px 0 rgba(255,255,255,.04);`; + const badge = document.createElement("div"); + badge.textContent = `BEAT\n${String(index + 1).padStart(2, "0")}\nref ${index + 1}`; + badge.style.cssText = `white-space:pre-line;display:flex;align-items:center;justify-content:center;text-align:center;border:1px solid ${bm.accentBorder};border-radius:6px;background:linear-gradient(180deg,${bm.accent} 0%,${bm.valueBg} 100%);color:${bm.text};font-weight:900;line-height:1.25;`; + const dur = numberStepperControl(row.duration, "0.1", "0.1", null, (value) => { + row.duration = Math.max(0.1, Number(value) || 0.1); + sync(); + status.textContent = `Beats: ${rows.length} | Script duration: ${totalRowsDuration().toFixed(1)}s`; + }, { liveInput: false }); + dur.style.gridTemplateColumns = "30px minmax(70px,1fr) 30px"; + dur.style.gap = "8px"; + styleValueControls(dur); + const beatColumn = document.createElement("div"); + beatColumn.style.cssText = "display:grid;grid-template-rows:auto auto;gap:9px;align-content:start;min-width:0;"; + const imageHint = makeText(row.image_hint, (value) => { + row.image_hint = value; + sync(); + }, { placeholder: "optional image / ref note" }); + imageHint.style.borderStyle = "dashed"; + imageHint.title = "Optional planning note only. Images can still be added manually after importing this board into Shotboard V2/V3."; + const imageWrap = labelWrap("Image placeholder", imageHint); + beatColumn.append(labelWrap("Beat duration", dur), imageWrap); + const meta = document.createElement("div"); + meta.style.cssText = "display:grid;gap:6px;"; + const labelInput = makeText(row.label, (value) => { row.label = value; sync(); }, { placeholder: "label" }); + const cameraSelect = makeSelect(row.camera, ["slow push-in", "continuous dolly-in", "macro push-in", "tracking shot", "crane descent", "orbit move", "locked-off camera"], (value) => { row.camera = value; sync(); }); + const transitionSelect = makeSelect(row.transition, ["continuous_motion", "match_cut", "soft_morph", "hard_cut"], (value) => { row.transition = value; sync(); }); + const rowForce = numberStepperControl(row.force, "0.01", "0", "1", (value) => { + row.force = Math.max(0, Math.min(1, Number(value) || 0)); + sync(); + }, { liveInput: false }); + rowForce.style.gridTemplateColumns = "30px minmax(90px,1fr) 30px"; + rowForce.style.gap = "8px"; + styleValueControls(rowForce); + meta.append(labelWrap("Label", labelInput), labelWrap("Camera", cameraSelect), labelWrap("Transition", transitionSelect), labelWrap("Guide force", rowForce)); + const local = makeText(row.local_prompt, (value) => { row.local_prompt = value; sync(); }, { multiline: true, rows: 6, height: 148, placeholder: "Local prompt for this beat...", boxText: true }); + const bridge = makeText(row.bridge, (value) => { row.bridge = value; sync(); }, { multiline: true, rows: 6, height: 148, placeholder: "Action/bridge to next beat...", boxText: true }); + const rowActions = document.createElement("div"); + rowActions.style.cssText = "display:grid;grid-template-rows:32px 32px 1fr;gap:7px;align-content:start;"; + const duplicate = makeBtn("Copy"); + duplicate.style.padding = "0 8px"; + duplicate.onclick = () => { + const copy = normalizeMakerRow({ ...row, label: `${row.label || `box_${index + 1}`}_copy` }, index + 1); + rows.splice(index + 1, 0, copy); + sync(); + draw(); + }; + const del = makeBtn("x", "danger"); + del.style.height = "32px"; + del.onclick = () => { + rows.splice(index, 1); + if (!rows.length) rows = defaultRows(); + sync(); + draw(); + }; + rowActions.append(duplicate, del); + card.append(badge, beatColumn, meta, labelWrap("Local prompt", local), labelWrap("Relay / bridge to next", bridge), rowActions); + list.appendChild(card); + }); + refreshBoardMakerTextStyles(); + status.textContent = `Beats: ${rows.length} | Script duration: ${totalRowsDuration().toFixed(1)}s | Target: ${Number(durationControlValue() || 0).toFixed(1)}s | Export creates a Shotboard V2/V3 board, then images are added manually.`; + }; + + addBtn.onclick = () => { + rows.push({ label: `beat_${rows.length + 1}`, duration: 3, image_hint: "", local_prompt: "describe this beat with concrete cinematic action", bridge: "carry the same movement into the next visual beat", camera: "continuous dolly-in", transition: "continuous_motion", force: Number(defaultForceValue()) || 0.28, use_guide: true, use_prompt: true }); + sync(); + draw(); + }; + fitBtn.onclick = () => { + const sum = Number(totalRowsDuration().toFixed(3)); + durationControlValue = () => sum; + durationCtrl._iamccsSetValue?.(sum); + setWidgetValue(node, "duration_seconds", sum); + sync(); + status.textContent = `Total duration fitted to ${sum}s.`; + }; + clearBtn.onclick = () => { + rows = defaultRows(); + sync(); + draw(); + }; + styleBtn.onclick = () => { + boxStyleMode = boxStyleMode === "paper" ? "dark" : "paper"; + node.properties = node.properties || {}; + node.properties.iamccs_boardmaker_box_style = boxStyleMode; + styleBtn.textContent = boxStyleMode === "paper" ? "Dark Boxes" : "Paper Boxes"; + refreshBoardMakerTextStyles(); + draw(); + }; + openEditorBtn.onclick = () => { toggleFullscreenEditor(root, node); }; + importBtn.onclick = () => importInput.click(); + importInput.onchange = async (event) => { + const file = event.target.files?.[0]; + importInput.value = ""; + if (!file) return; + try { + const raw = await readJsonFile(file); + const board = boardFromWorkflowJson(raw) || raw || {}; + const settings = board.settings && typeof board.settings === "object" ? board.settings : {}; + const boardValue = (name) => Object.prototype.hasOwnProperty.call(board, name) ? board[name] : settings[name]; + const importedRows = makerRowsFromBoard(board); + rows = importedRows.length ? importedRows : defaultRows(); + const importedPrompt = board.global_prompt ?? board.prompt ?? board.globalPrompt ?? ""; + globalPrompt.value = String(importedPrompt || ""); + setWidgetValue(node, "global_prompt", globalPrompt.value); + const importedName = board?.metadata?.board_name || board?.metadata?.name || file.name.replace(/\.json$/i, "") || "iamccs_boardmaker_board"; + boardName.value = String(importedName || "iamccs_boardmaker_board"); + setWidgetValue(node, "board_name", boardName.value); + const duration = Number(boardValue("duration_seconds") ?? totalRowsDuration()); + if (Number.isFinite(duration) && duration > 0) { + durationControlValue = () => duration; + durationCtrl._iamccsSetValue?.(duration); + setWidgetValue(node, "duration_seconds", duration); + } + const fps = Number(boardValue("frame_rate") ?? fpsControlValue()); + if (Number.isFinite(fps) && fps > 0) { + fpsControlValue = () => fps; + fpsCtrl._iamccsSetValue?.(fps); + setWidgetValue(node, "frame_rate", fps); + } + const width = Number(boardValue("image_width") ?? imageWidthControlValue()); + if (Number.isFinite(width) && width >= 64) { + imageWidthControlValue = () => width; + widthCtrl._iamccsSetValue?.(width); + setWidgetValue(node, "image_width", width); + } + const height = Number(boardValue("image_height") ?? imageHeightControlValue()); + if (Number.isFinite(height) && height >= 64) { + imageHeightControlValue = () => height; + heightCtrl._iamccsSetValue?.(height); + setWidgetValue(node, "image_height", height); + } + const force = Number(boardValue("default_force") ?? defaultForceValue()); + if (Number.isFinite(force)) { + defaultForceValue = () => force; + forceCtrl._iamccsSetValue?.(force); + setWidgetValue(node, "default_force", force); + } + if (boardValue("guide_policy") != null) { + guidePolicy.value = String(boardValue("guide_policy")); + setWidgetValue(node, "guide_policy", guidePolicy.value); + } + sync(); + draw(); + status.textContent = `Imported ${rows.length} beats from ${file.name} at ${Number(imageWidthControlValue())}x${Number(imageHeightControlValue())}.`; + } catch (err) { + console.error("[IAMCCS_BoardMaker] import failed", err); + status.textContent = `Import failed: ${err?.message || err}`; + } + }; + exportBtn.onclick = async () => { + try { + exportBtn.disabled = true; + const board = collectBoard(); + const filename = safeBoardFilename(board.metadata.board_name || "iamccs_boardmaker_board"); + await saveBoardJsonToChosenFolder(board, filename, (message) => { status.textContent = message; }); + } catch (err) { + console.error("[IAMCCS_BoardMaker] export failed", err); + status.textContent = `Export failed: ${err?.message || err}`; + } finally { + exportBtn.disabled = false; + } + }; + + root.append(head, promptPanel, topGrid, status, list); + const widget = node.addDOMWidget("IAMCCS_BoardMaker", "iamccs_boardmaker", root, { serialize: false }); + widget.computeSize = (width) => [Math.max(1560, Number(width || 1560)), 1160]; + lockNodeMinimumSize(node, [1560, 1200], { lockResize: false, preferredSize: [1560, 1220] }); + sync(); + draw(); +} + function renderForNode(node) { const klass = nodeClassName(node); try { @@ -3830,6 +10229,10 @@ function renderForNode(node) { if (klass === "IAMCCS_CineShotboardLite") { lockNodeMinimumSize(node, SHOTBOARD_LITE_NODE_MIN_SIZE, { lockResize: true }); } + if (klass === "IAMCCS_CineShotboardPlannerV3") { + const collapsed = Boolean(node.properties?.iamccs_v3_collapsed); + lockNodeMinimumSize(node, [SHOTBOARD_V3_RIGID_WIDTH, collapsed ? SHOTBOARD_V3_COLLAPSED_HEIGHT : SHOTBOARD_V3_OPEN_HEIGHT], { lockResize: false, lockWidth: true }); + } if (klass === "IAMCCS_CineShotboardTimelinePro" || klass === "IAMCCS_CineShotboardPlannerPro" || klass === "IAMCCS_CineShotboardPlannerProV2" || klass === "IAMCCS_CineShotboardPlannerProLegacy") { lockNodeMinimumSize(node, SHOTBOARD_NODE_MIN_SIZE, { lockResize: true }); } @@ -3837,10 +10240,13 @@ function renderForNode(node) { if (klass === "IAMCCS_CinePromptRelayLatentShapeSync") renderPromptRelayShapeSync(node); if (klass === "IAMCCS_CineLTXSequencer") renderKeyframeEditor(node); if (klass === "IAMCCS_CinePromptRelayTimeline") renderPromptRelayEditor(node); + if (klass === "IAMCCS_CineShotboardPlannerV3") renderShotboardV3(node); if (klass === "IAMCCS_CineShotboardLite") renderShotboardLite(node); if (klass === "IAMCCS_CineShotboardTimelinePro" || klass === "IAMCCS_CineShotboardPlannerPro" || klass === "IAMCCS_CineShotboardPlannerProV2" || klass === "IAMCCS_CineShotboardPlannerProLegacy") renderShotboardPro(node); if (klass === "IAMCCS_CineFLFEngineSimple") renderCineFLFEngineSimple(node); if (klass === "IAMCCS_CineInfo") renderCineInfo(node); + if (klass === "IAMCCS_CinePromptArchitect") renderCinePromptArchitect(node); + if (klass === "IAMCCS_BoardMaker") renderBoardMaker(node); if (klass === "IAMCCS_CineMusicVideoPlanner") renderCineMusicVideoPlanner(node); } catch (err) { showRenderError(node, err); @@ -3849,7 +10255,7 @@ function renderForNode(node) { function scheduleRender(node) { 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_CineShotboardPlannerProLegacy" && klass !== "IAMCCS_CineFLFEngineSimple" && klass !== "IAMCCS_CineInfo" && klass !== "IAMCCS_CineMusicVideoPlanner") return; + 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); } @@ -3858,7 +10264,7 @@ app.registerExtension({ name: "iamccs.cine.timeline.ui", async beforeRegisterNodeDef(nodeType, nodeData) { const name = String(nodeData?.name || nodeData?.class_type || ""); - if (name !== "IAMCCS_CineLTXSequencer" && name !== "IAMCCS_CinePromptRelayLatentShapeSync" && name !== "IAMCCS_CinePromptRelayTimeline" && name !== "IAMCCS_CineShotboardLite" && name !== "IAMCCS_CineShotboardTimelinePro" && name !== "IAMCCS_CineShotboardPlannerPro" && name !== "IAMCCS_CineShotboardPlannerProV2" && name !== "IAMCCS_CineShotboardPlannerProLegacy" && name !== "IAMCCS_CineFLFEngineSimple" && name !== "IAMCCS_CineInfo" && name !== "IAMCCS_CineMusicVideoPlanner") return; + if (name !== "IAMCCS_CineLTXSequencer" && name !== "IAMCCS_CinePromptRelayLatentShapeSync" && name !== "IAMCCS_CinePromptRelayTimeline" && name !== "IAMCCS_CineShotboardLite" && name !== "IAMCCS_CineShotboardTimelinePro" && name !== "IAMCCS_CineShotboardPlannerPro" && name !== "IAMCCS_CineShotboardPlannerProV2" && name !== "IAMCCS_CineShotboardPlannerV3" && name !== "IAMCCS_CineShotboardPlannerProLegacy" && name !== "IAMCCS_CineFLFEngineSimple" && name !== "IAMCCS_CineInfo" && name !== "IAMCCS_CinePromptArchitect" && name !== "IAMCCS_BoardMaker" && name !== "IAMCCS_CineMusicVideoPlanner") return; if (nodeType.prototype._iamccsCineTimelineWrapped) return; nodeType.prototype._iamccsCineTimelineWrapped = true; const onNodeCreated = nodeType.prototype.onNodeCreated; @@ -3883,10 +10289,12 @@ document.addEventListener("iamccs:planner_rows_updated", (ev) => { const plannerNode = app.graph?.getNodeById(nodeId); if (!plannerNode) return; const klass = String(plannerNode?.comfyClass || plannerNode?.type || ""); - if (klass !== "IAMCCS_CineShotboardLite" && klass !== "IAMCCS_CineShotboardPlannerPro" && klass !== "IAMCCS_CineShotboardPlannerProV2" && klass !== "IAMCCS_CineShotboardPlannerProLegacy" && klass !== "IAMCCS_CineShotboardTimelinePro") return; + if (klass !== "IAMCCS_CineShotboardLite" && klass !== "IAMCCS_CineShotboardPlannerPro" && klass !== "IAMCCS_CineShotboardPlannerProV2" && klass !== "IAMCCS_CineShotboardPlannerV3" && klass !== "IAMCCS_CineShotboardPlannerProLegacy" && klass !== "IAMCCS_CineShotboardTimelinePro") return; // Clear render guard so renderShotboardPro rebuilds the table plannerNode._iamccsCineShotboardReady = false; + plannerNode._iamccsCineShotboardVersion = ""; plannerNode._iamccsCineShotboardLiteReady = false; + plannerNode._iamccsCineShotboardV3Ready = false; + plannerNode._iamccsCineShotboardV3Version = ""; scheduleRender(plannerNode); }); - diff --git a/web/iamccs_detail_atelier_ui.js b/web/iamccs_detail_atelier_ui.js new file mode 100644 index 0000000..74e6d87 --- /dev/null +++ b/web/iamccs_detail_atelier_ui.js @@ -0,0 +1,308 @@ +// IAMCCS Detail Atelier UI +// Frontend helper for live loop estimates and "Apply Auto" on the Advanced LINX node. + +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +const ADVANCED_TYPE = "IAMCCS_DetailAtelierAdvanced"; +const SAMPLER_TYPE = "IAMCCS_DetailAtelierSampler"; +const PREVIEW_WIDGET = "iamccs_detail_atelier_live_preview"; +const APPLY_BUTTON = "iamccs_detail_atelier_apply_auto"; +const REFRESH_BUTTON = "iamccs_detail_atelier_refresh"; + +const PRESETS = { + "8GB": { + preview: { temporal_tile_size: 32, temporal_overlap: 16, guiding_strength: 0.85, temporal_overlap_cond_strength: 0.45, cond_image_strength: 1.0 }, + balanced: { temporal_tile_size: 40, temporal_overlap: 16, guiding_strength: 0.90, temporal_overlap_cond_strength: 0.50, cond_image_strength: 1.0 }, + quality: { temporal_tile_size: 40, temporal_overlap: 16, guiding_strength: 0.95, temporal_overlap_cond_strength: 0.55, cond_image_strength: 1.0 }, + }, + "12GB": { + preview: { temporal_tile_size: 40, temporal_overlap: 16, guiding_strength: 0.90, temporal_overlap_cond_strength: 0.45, cond_image_strength: 1.0 }, + balanced: { temporal_tile_size: 48, temporal_overlap: 16, guiding_strength: 1.00, temporal_overlap_cond_strength: 0.50, cond_image_strength: 1.0 }, + quality: { temporal_tile_size: 56, temporal_overlap: 24, guiding_strength: 1.00, temporal_overlap_cond_strength: 0.55, cond_image_strength: 1.0 }, + }, + "16GB": { + preview: { temporal_tile_size: 48, temporal_overlap: 16, guiding_strength: 0.95, temporal_overlap_cond_strength: 0.45, cond_image_strength: 1.0 }, + balanced: { temporal_tile_size: 56, temporal_overlap: 24, guiding_strength: 1.00, temporal_overlap_cond_strength: 0.50, cond_image_strength: 1.0 }, + quality: { temporal_tile_size: 64, temporal_overlap: 24, guiding_strength: 1.00, temporal_overlap_cond_strength: 0.60, cond_image_strength: 1.0 }, + }, + "24GB": { + preview: { temporal_tile_size: 56, temporal_overlap: 24, guiding_strength: 1.00, temporal_overlap_cond_strength: 0.45, cond_image_strength: 1.0 }, + balanced: { temporal_tile_size: 64, temporal_overlap: 24, guiding_strength: 1.00, temporal_overlap_cond_strength: 0.55, cond_image_strength: 1.0 }, + quality: { temporal_tile_size: 80, temporal_overlap: 32, guiding_strength: 1.00, temporal_overlap_cond_strength: 0.60, cond_image_strength: 1.0 }, + }, +}; + +function nodeClass(node) { + return node?.comfyClass || node?.type || ""; +} + +function widget(node, name) { + return (node?.widgets || []).find((w) => w?.name === name || w?.label === name) || null; +} + +function setWidget(node, name, value) { + const w = widget(node, name); + if (!w) return false; + w.value = value; + try { + w.callback?.(value); + } catch { + // ignore + } + return true; +} + +function clamp(v, min, max) { + const n = Number(v); + if (!Number.isFinite(n)) return min; + return Math.max(min, Math.min(max, n)); +} + +function effectiveVramPreset(vramGb, fallback = "12GB") { + const n = Number(vramGb); + if (!Number.isFinite(n)) return fallback; + if (n <= 8.5) return "8GB"; + if (n <= 12.5) return "12GB"; + if (n <= 16.5) return "16GB"; + return "24GB"; +} + +function getGraphNodeById(id) { + try { + return app.graph?.getNodeById?.(id) || app.graph?._nodes_by_id?.[id] || null; + } catch { + return null; + } +} + +function linkedTarget(node) { + try { + const out = node.outputs?.find((o) => o?.type === "IAMCCS_DETAIL_ATELIER_LINX") || node.outputs?.[0]; + const linkId = Array.isArray(out?.links) ? out.links[0] : null; + if (linkId == null) return null; + const link = app.graph?.links?.[linkId]; + if (!link) return null; + const targetId = link.target_id ?? link[3]; + return getGraphNodeById(targetId); + } catch { + return null; + } +} + +function linkedAdvancedNode(node) { + try { + const inp = (node.inputs || []).find((i) => i?.type === "IAMCCS_DETAIL_ATELIER_LINX"); + const linkId = inp?.link; + if (linkId == null) return null; + const link = app.graph?.links?.[linkId]; + if (!link) return null; + const sourceId = link.origin_id ?? link[1]; + return getGraphNodeById(sourceId); + } catch { + return null; + } +} + +function readLoaderContext() { + const ctx = { frames: null, fps: null, width: null, height: null }; + try { + const nodes = app.graph?._nodes || []; + const loader = nodes.find((n) => nodeClass(n) === "VHS_LoadVideoPath"); + const info = nodes.find((n) => nodeClass(n) === "VHS_VideoInfoLoaded"); + const scaler = nodes.find((n) => nodeClass(n) === "ImageScaleToMaxDimension"); + + const loaderFrames = widget(loader, "frame_load_cap")?.value ?? loader?.widgets_values?.frame_load_cap; + const loaderFps = widget(loader, "force_rate")?.value ?? loader?.widgets_values?.force_rate; + ctx.frames = Number(loaderFrames) > 0 ? Number(loaderFrames) : null; + ctx.fps = Number(loaderFps) > 0 ? Number(loaderFps) : null; + + const infoFps = widget(info, "fps")?.value ?? widget(info, "frame_rate")?.value; + if (!ctx.fps && Number(infoFps) > 0) ctx.fps = Number(infoFps); + + const largest = widget(scaler, "largest_size")?.value ?? widget(scaler, "max_dimension")?.value; + if (Number(largest) > 0) { + ctx.width = Number(largest); + ctx.height = Math.round(Number(largest) * 9 / 16); + } + } catch { + // best effort only + } + return ctx; +} + +function estimateChunks(frames, temporalTileSize, temporalOverlap, timeScale = 8) { + const f = Number(frames); + if (!Number.isFinite(f) || f <= 0) return null; + const latentFrames = Math.max(1, Math.ceil(f / timeScale)); + const latentTile = Math.max(1, Math.floor(Number(temporalTileSize) / timeScale)); + let latentOverlap = Math.max(0, Math.floor(Number(temporalOverlap) / timeScale)); + if (latentOverlap >= latentTile) latentOverlap = Math.max(0, latentTile - 1); + const step = Math.max(1, latentTile - latentOverlap); + const chunks = latentFrames <= latentTile ? 1 : Math.ceil((latentFrames - latentTile) / step) + 1; + return { chunks, latentFrames, latentTile, latentOverlap, step }; +} + +async function probeHardware(ctx) { + try { + const params = new URLSearchParams(); + if (ctx.width) params.set("width", String(Math.round(ctx.width))); + if (ctx.height) params.set("height", String(Math.round(ctx.height))); + if (ctx.frames) params.set("frames", String(Math.round(ctx.frames))); + if (ctx.fps) params.set("fps", String(ctx.fps)); + const res = await api.fetchApi(`/api/iamccs/hw_probe?${params.toString()}`); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +function chooseAutoValues(hw, ctx, quality = "balanced", fallbackVram = "12GB") { + const vramGb = hw?.hardware?.cuda_total_vram_gb ?? hw?.cuda_total_vram_gb ?? null; + const ramGb = hw?.hardware?.system_ram_gb ?? hw?.system_ram_gb ?? null; + const vram = effectiveVramPreset(vramGb, fallbackVram); + const q = PRESETS[vram]?.[quality] ? quality : "balanced"; + const base = { ...(PRESETS[vram]?.[q] || PRESETS["12GB"].balanced) }; + + const lowRam = Number(ramGb) > 0 && Number(ramGb) <= 40 && (vram === "8GB" || vram === "12GB"); + if (lowRam || vram === "8GB" || vram === "12GB") { + base.horizontal_tiles = 1; + base.vertical_tiles = 1; + base.spatial_overlap = 1; + base.spatial_tiling_mode = "force_off"; + if (vram === "12GB") { + base.temporal_tile_size = Math.min(base.temporal_tile_size, 48); + base.temporal_overlap = Math.min(base.temporal_overlap, 16); + } + if (vram === "8GB") { + base.temporal_tile_size = Math.min(base.temporal_tile_size, 32); + base.temporal_overlap = Math.min(base.temporal_overlap, 16); + } + } else { + base.horizontal_tiles = 0; + base.vertical_tiles = 0; + base.spatial_overlap = 0; + base.spatial_tiling_mode = "inherit"; + } + + const chunks = estimateChunks(ctx.frames, base.temporal_tile_size, base.temporal_overlap); + return { + vram, + quality: q, + vramGb, + ramGb, + lowRam, + chunks, + values: base, + }; +} + +function ensurePreview(node) { + let w = widget(node, PREVIEW_WIDGET); + if (w) return w; + w = node.addWidget("text", "Atelier Live", "", () => {}, { multiline: true }); + w.name = PREVIEW_WIDGET; + w.serialize = false; + try { + w.inputEl?.setAttribute?.("readonly", true); + } catch { + // ignore + } + return w; +} + +function setPreview(node, text) { + const w = ensurePreview(node); + w.value = text; + node.properties = node.properties || {}; + node.properties[PREVIEW_WIDGET] = text; + try { + app.graph.setDirtyCanvas(true, false); + } catch { + // ignore + } +} + +async function refreshAdvanced(node, apply = false) { + const ctx = readLoaderContext(); + const target = linkedTarget(node); + const fallbackVram = widget(target, "vram_preset")?.value || "12GB"; + const quality = widget(target, "quality_mode")?.value || "balanced"; + const hw = await probeHardware(ctx); + const plan = chooseAutoValues(hw, ctx, quality, fallbackVram); + + if (apply) { + setWidget(node, "enabled", true); + setWidget(node, "temporal_tile_size", plan.values.temporal_tile_size); + setWidget(node, "temporal_overlap", plan.values.temporal_overlap); + setWidget(node, "guiding_strength", plan.values.guiding_strength); + setWidget(node, "temporal_overlap_cond_strength", plan.values.temporal_overlap_cond_strength); + setWidget(node, "cond_image_strength", plan.values.cond_image_strength); + setWidget(node, "horizontal_tiles", plan.values.horizontal_tiles ?? 0); + setWidget(node, "vertical_tiles", plan.values.vertical_tiles ?? 0); + setWidget(node, "spatial_overlap", plan.values.spatial_overlap ?? 0); + setWidget(node, "spatial_tiling_mode", plan.values.spatial_tiling_mode || "inherit"); + } + + const seconds = ctx.frames && ctx.fps ? (ctx.frames / ctx.fps).toFixed(2) : "?"; + const hwLine = plan.vramGb ? `${plan.vram} detected (${Number(plan.vramGb).toFixed(1)}GB VRAM)` : `${plan.vram} fallback`; + const ramLine = plan.ramGb ? `${Number(plan.ramGb).toFixed(1)}GB RAM` : "RAM ?"; + const chunkLine = plan.chunks ? `${plan.chunks.chunks} chunks approx, latent tile ${plan.chunks.latentTile}, step ${plan.chunks.step}` : "chunks ?"; + setPreview( + node, + [ + `Auto: ${hwLine}, ${ramLine}`, + `Video: ${ctx.frames || "?"} frames @ ${ctx.fps || "?"} fps (${seconds}s)`, + `Loop: ${plan.values.temporal_tile_size}/${plan.values.temporal_overlap}, ${chunkLine}`, + `Tiles: ${plan.values.horizontal_tiles ?? 0}x${plan.values.vertical_tiles ?? 0}, overlap ${plan.values.spatial_overlap ?? 0}`, + apply ? "Applied to Advanced overrides." : "Use Apply Auto to write these values.", + ].join("\n") + ); +} + +function installAdvanced(node) { + ensurePreview(node); + if (!widget(node, APPLY_BUTTON)) { + const btn = node.addWidget("button", "Apply Auto", null, () => { + refreshAdvanced(node, true); + }); + btn.name = APPLY_BUTTON; + btn.serialize = false; + } + if (!widget(node, REFRESH_BUTTON)) { + const btn = node.addWidget("button", "Refresh Estimate", null, () => { + refreshAdvanced(node, false); + }); + btn.name = REFRESH_BUTTON; + btn.serialize = false; + } + setTimeout(() => refreshAdvanced(node, false), 250); +} + +function installSampler(node) { + const advanced = linkedAdvancedNode(node); + if (advanced) setTimeout(() => refreshAdvanced(advanced, false), 250); +} + +app.registerExtension({ + name: "iamccs.detail_atelier.ui", + + async beforeRegisterNodeDef(nodeType, nodeData) { + const name = nodeData?.name; + if (name !== ADVANCED_TYPE && name !== SAMPLER_TYPE) return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = onNodeCreated?.apply(this, arguments); + if (name === ADVANCED_TYPE) installAdvanced(this); + if (name === SAMPLER_TYPE) installSampler(this); + return r; + }; + }, + + async nodeCreated(node) { + if (nodeClass(node) === ADVANCED_TYPE) installAdvanced(node); + if (nodeClass(node) === SAMPLER_TYPE) installSampler(node); + }, +}); diff --git a/web/iamccs_workflow_persist_cleanup.js b/web/iamccs_workflow_persist_cleanup.js index 6d034dd..a4e0da9 100644 --- a/web/iamccs_workflow_persist_cleanup.js +++ b/web/iamccs_workflow_persist_cleanup.js @@ -1,437 +1,16 @@ import { app } from "../../scripts/app.js"; -const LEGACY_LOCAL_KEYS = [ - "workflow", - "Comfy.OpenWorkflowsPaths", - "Comfy.ActiveWorkflowIndex", -]; - -const LEGACY_PREFIXES = [ - "Comfy.Workflow.Drafts", - "Comfy.Workflow.DraftOrder", -]; - -const V2_PREFIX = { - draftIndex: "Comfy.Workflow.DraftIndex.v2:", - draftPayload: "Comfy.Workflow.Draft.v2:", - lastActivePath: "Comfy.Workflow.LastActivePath:", - lastOpenPaths: "Comfy.Workflow.LastOpenPaths:", -}; - -const MAX_DRAFTS_PER_WORKSPACE = 4; -const MAX_DRAFT_CHARS_PER_WORKSPACE = 2_500_000; -const STORAGE_PATCH_FLAG = "__iamccsWorkflowDraftQuotaPatchApplied"; -const STORAGE_PROTOTYPE_PATCH_FLAG = "__iamccsWorkflowDraftQuotaPrototypePatchApplied"; - -function safeParseJson(json) { - if (!json) return null; - try { - return JSON.parse(json); - } catch { - return null; - } -} - -function listStorageKeys(storage) { - const keys = []; - try { - for (let index = 0; index < storage.length; index += 1) { - const key = storage.key(index); - if (key) keys.push(key); - } - } catch { - return []; - } - return keys; -} - -function removeKey(storage, key) { - try { - storage.removeItem(key); - return true; - } catch { - return false; - } -} - -function getPayloadKey(workspaceId, draftKey) { - return `${V2_PREFIX.draftPayload}${workspaceId}:${draftKey}`; -} - -function extractWorkspaceIdFromKey(key) { - if (typeof key !== "string") return null; - if (key.startsWith(V2_PREFIX.draftIndex)) { - return key.slice(V2_PREFIX.draftIndex.length); - } - if (key.startsWith(V2_PREFIX.draftPayload)) { - const rest = key.slice(V2_PREFIX.draftPayload.length); - const separatorIndex = rest.indexOf(":"); - return separatorIndex === -1 ? null : rest.slice(0, separatorIndex); - } - if (key.startsWith(V2_PREFIX.lastActivePath)) { - return key.slice(V2_PREFIX.lastActivePath.length); - } - if (key.startsWith(V2_PREFIX.lastOpenPaths)) { - return key.slice(V2_PREFIX.lastOpenPaths.length); - } - return null; -} - -function isDraftStorageKey(key) { - return typeof key === "string" && ( - key.startsWith(V2_PREFIX.draftIndex) - || key.startsWith(V2_PREFIX.draftPayload) - || key.startsWith(V2_PREFIX.lastActivePath) - || key.startsWith(V2_PREFIX.lastOpenPaths) - || LEGACY_LOCAL_KEYS.includes(key) - || LEGACY_PREFIXES.some((prefix) => key.startsWith(prefix)) - ); -} - -function isQuotaExceededError(error) { - if (!error) return false; - const name = String(error.name || ""); - const message = String(error.message || "").toLowerCase(); - return name === "QuotaExceededError" - || name === "NS_ERROR_DOM_QUOTA_REACHED" - || message.includes("quota") - || message.includes("storage is full"); -} - -function cleanupWorkspaceTrackingStorage(localStorageRef, workspaceId = null) { - let removed = 0; - for (const key of listStorageKeys(localStorageRef)) { - const isTrackingKey = key.startsWith(V2_PREFIX.lastActivePath) - || key.startsWith(V2_PREFIX.lastOpenPaths); - if (!isTrackingKey) continue; - - if (workspaceId !== null && extractWorkspaceIdFromKey(key) !== workspaceId) { - continue; - } - - if (removeKey(localStorageRef, key)) removed += 1; - } - return removed; -} - -function cleanupLegacyWorkflowStorage(localStorageRef) { - let removed = 0; - for (const key of LEGACY_LOCAL_KEYS) { - if (removeKey(localStorageRef, key)) removed += 1; - } - - for (const key of listStorageKeys(localStorageRef)) { - if (LEGACY_PREFIXES.some((prefix) => key.startsWith(prefix))) { - if (removeKey(localStorageRef, key)) removed += 1; - } - } - - return removed; -} - -function cleanupWorkspaceDrafts(localStorageRef, workspaceId) { - const indexKey = `${V2_PREFIX.draftIndex}${workspaceId}`; - const rawIndex = localStorageRef.getItem(indexKey); - if (!rawIndex) return { removed: 0, rewritten: false }; - - const index = safeParseJson(rawIndex); - if (!index || typeof index !== "object" || !Array.isArray(index.order) || typeof index.entries !== "object" || index.entries == null) { - let removed = removeKey(localStorageRef, indexKey) ? 1 : 0; - const payloadPrefix = `${V2_PREFIX.draftPayload}${workspaceId}:`; - for (const key of listStorageKeys(localStorageRef)) { - if (key.startsWith(payloadPrefix) && removeKey(localStorageRef, key)) { - removed += 1; - } - } - return { removed, rewritten: false }; - } - - const payloadPrefix = `${V2_PREFIX.draftPayload}${workspaceId}:`; - const seenPayloads = new Set(); - const keptEntries = {}; - const keptOrder = []; - let keptChars = 0; - let keptCount = 0; - let removed = 0; - - const newestFirst = [...index.order].reverse(); - for (const draftKey of newestFirst) { - const entry = index.entries[draftKey]; - if (!entry || typeof entry.path !== "string") continue; - - const payloadKey = getPayloadKey(workspaceId, draftKey); - const payloadJson = localStorageRef.getItem(payloadKey); - if (!payloadJson) continue; - - const payloadChars = payloadJson.length; - const canKeep = keptCount < MAX_DRAFTS_PER_WORKSPACE && ( - keptCount === 0 || keptChars + payloadChars <= MAX_DRAFT_CHARS_PER_WORKSPACE - ); - - if (canKeep) { - keptEntries[draftKey] = entry; - keptOrder.unshift(draftKey); - keptChars += payloadChars; - keptCount += 1; - seenPayloads.add(payloadKey); - } else if (removeKey(localStorageRef, payloadKey)) { - removed += 1; - } - } - - for (const key of listStorageKeys(localStorageRef)) { - if (key.startsWith(payloadPrefix) && !seenPayloads.has(key)) { - if (removeKey(localStorageRef, key)) removed += 1; - } - } - - const nextIndex = { - ...index, - updatedAt: Date.now(), - order: keptOrder, - entries: keptEntries, - }; - - const nextIndexJson = JSON.stringify(nextIndex); - const rewritten = nextIndexJson !== rawIndex; - if (rewritten) { - try { - localStorageRef.setItem(indexKey, nextIndexJson); - } catch { - // If index rewrite fails, leaving the trimmed payload set is still better - // than keeping localStorage saturated with stale drafts. - } - } - - return { removed, rewritten }; -} - -function cleanupWorkflowPersistenceStorage() { - try { - const localStorageRef = window.localStorage; - if (!localStorageRef) return; - - let removed = cleanupLegacyWorkflowStorage(localStorageRef); - removed += cleanupWorkspaceTrackingStorage(localStorageRef); - let rewritten = 0; - - const workspaceIds = new Set(); - for (const key of listStorageKeys(localStorageRef)) { - if (key.startsWith(V2_PREFIX.draftIndex)) { - workspaceIds.add(key.slice(V2_PREFIX.draftIndex.length)); - } - } - - for (const workspaceId of workspaceIds) { - const result = cleanupWorkspaceDrafts(localStorageRef, workspaceId); - removed += result.removed; - if (result.rewritten) rewritten += 1; - } - - if (removed || rewritten) { - console.info("[IAMCCS] Workflow draft storage cleanup applied", { - removedKeys: removed, - rewrittenIndexes: rewritten, - }); - } - } catch (error) { - console.warn("[IAMCCS] Workflow draft storage cleanup skipped", error); - } -} - -function recoverDraftStorage(localStorageRef, key) { - let removed = cleanupLegacyWorkflowStorage(localStorageRef); - let rewritten = 0; - - const workspaceIds = new Set(); - const workspaceId = extractWorkspaceIdFromKey(key); - if (workspaceId) workspaceIds.add(workspaceId); - - for (const storageKey of listStorageKeys(localStorageRef)) { - if (storageKey.startsWith(V2_PREFIX.draftIndex)) { - workspaceIds.add(storageKey.slice(V2_PREFIX.draftIndex.length)); - } - } - - for (const currentWorkspaceId of workspaceIds) { - removed += cleanupWorkspaceTrackingStorage(localStorageRef, currentWorkspaceId); - const result = cleanupWorkspaceDrafts(localStorageRef, currentWorkspaceId); - removed += result.removed; - if (result.rewritten) rewritten += 1; - } - - return { removed, rewritten }; -} - -function aggressiveRecoverDraftStorage(localStorageRef) { - let removed = cleanupLegacyWorkflowStorage(localStorageRef); - removed += cleanupWorkspaceTrackingStorage(localStorageRef); - - for (const storageKey of listStorageKeys(localStorageRef)) { - if ( - storageKey.startsWith(V2_PREFIX.draftIndex) - || storageKey.startsWith(V2_PREFIX.draftPayload) - ) { - if (removeKey(localStorageRef, storageKey)) removed += 1; - } - } - - return { removed, rewritten: 0 }; -} - -function isStorageLike(storageRef) { - return storageRef != null - && typeof storageRef.getItem === "function" - && typeof storageRef.setItem === "function" - && typeof storageRef.removeItem === "function"; -} - -function isLocalStorageRef(storageRef) { - try { - return storageRef === window.localStorage; - } catch { - return false; - } -} - -function shouldSuppressDraftQuotaFailure(key) { - if (!isDraftStorageKey(key)) return false; - return typeof key === "string" && ( - key.startsWith(V2_PREFIX.draftPayload) - || key.startsWith(V2_PREFIX.draftIndex) - || key.startsWith(V2_PREFIX.lastActivePath) - || key.startsWith(V2_PREFIX.lastOpenPaths) - || LEGACY_LOCAL_KEYS.includes(key) - || LEGACY_PREFIXES.some((prefix) => key.startsWith(prefix)) - ); -} - -function handleDraftQuotaSetItem(originalSetItem, storageRef, key, value, error) { - if (!isDraftStorageKey(key) || !isQuotaExceededError(error) || !isStorageLike(storageRef)) { - throw error; - } - - const canRecover = isLocalStorageRef(storageRef); - const recovery = canRecover ? recoverDraftStorage(storageRef, key) : { removed: 0, rewritten: 0 }; - console.warn("[IAMCCS] Workflow draft quota reached, attempting recovery", { - key, - removedKeys: recovery.removed, - rewrittenIndexes: recovery.rewritten, - storage: canRecover ? "localStorage" : "other", - }); - - try { - const result = Reflect.apply(originalSetItem, storageRef, [key, value]); - console.info("[IAMCCS] Workflow draft save recovered after storage cleanup", { - key, - removedKeys: recovery.removed, - rewrittenIndexes: recovery.rewritten, - storage: canRecover ? "localStorage" : "other", - }); - return result; - } catch (retryError) { - if (!isQuotaExceededError(retryError)) { - console.error("[IAMCCS] Workflow draft save failed after cleanup with non-quota error", { - key, - removedKeys: recovery.removed, - rewrittenIndexes: recovery.rewritten, - retryError, - }); - throw retryError; - } - - const aggressiveRecovery = canRecover - ? aggressiveRecoverDraftStorage(storageRef) - : { removed: 0, rewritten: 0 }; - - if (aggressiveRecovery.removed) { - console.warn("[IAMCCS] Workflow draft quota still exceeded, applying aggressive recovery", { - key, - removedKeys: aggressiveRecovery.removed, - storage: canRecover ? "localStorage" : "other", - }); - } - - try { - const result = Reflect.apply(originalSetItem, storageRef, [key, value]); - console.info("[IAMCCS] Workflow draft save recovered after aggressive cleanup", { - key, - removedKeys: recovery.removed + aggressiveRecovery.removed, - rewrittenIndexes: recovery.rewritten, - storage: canRecover ? "localStorage" : "other", - }); - return result; - } catch (finalRetryError) { - if (!isQuotaExceededError(finalRetryError) || !shouldSuppressDraftQuotaFailure(key)) { - console.error("[IAMCCS] Workflow draft save still exceeds storage quota after aggressive cleanup", { - key, - removedKeys: recovery.removed + aggressiveRecovery.removed, - rewrittenIndexes: recovery.rewritten, - retryError: finalRetryError, - }); - throw finalRetryError; - } - - console.warn("[IAMCCS] Workflow draft save skipped after quota recovery failed", { - key, - removedKeys: recovery.removed + aggressiveRecovery.removed, - rewrittenIndexes: recovery.rewritten, - storage: canRecover ? "localStorage" : "other", - }); - return undefined; - } - } -} - -function installDraftStorageQuotaGuard() { - try { - const localStorageRef = window.localStorage; - if (!localStorageRef || localStorageRef[STORAGE_PATCH_FLAG]) return; - - const storagePrototype = Object.getPrototypeOf(localStorageRef); - if (!storagePrototype || storagePrototype[STORAGE_PROTOTYPE_PATCH_FLAG]) { - Object.defineProperty(localStorageRef, STORAGE_PATCH_FLAG, { - value: true, - configurable: false, - enumerable: false, - writable: false, - }); - return; - } - - const originalSetItem = storagePrototype.setItem; - if (typeof originalSetItem !== "function") return; - - storagePrototype.setItem = function patchedSetItem(key, value) { - try { - return Reflect.apply(originalSetItem, this, [key, value]); - } catch (error) { - return handleDraftQuotaSetItem(originalSetItem, this, key, value, error); - } - }; - - Object.defineProperty(storagePrototype, STORAGE_PROTOTYPE_PATCH_FLAG, { - value: true, - configurable: false, - enumerable: false, - writable: false, - }); - - Object.defineProperty(localStorageRef, STORAGE_PATCH_FLAG, { - value: true, - configurable: false, - enumerable: false, - writable: false, - }); - } catch (error) { - console.warn("[IAMCCS] Workflow draft quota guard skipped", error); - } -} - +// IAMCCS intentionally does not touch ComfyUI workflow persistence. +// +// 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. +// +// 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. app.registerExtension({ name: "iamccs.workflow_persist_cleanup", - async setup() { - cleanupWorkflowPersistenceStorage(); - installDraftStorageQuotaGuard(); - }, + async setup() {}, });