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() {},
});