Bug fixed and new nodes utilities added - Node Update 1.4.9

This commit is contained in:
IAMCCS
2026-06-25 01:44:44 +02:00
parent 202eee7a10
commit cd433590f8
17 changed files with 6264 additions and 334 deletions
+32
View File
@@ -284,6 +284,7 @@ from .audio.audio_control_efx import IAMCCS_ControlAudEfx
from .audio.audio_control_efx_panel import IAMCCS_ControlAudEfxPanel
from .audio.dialogue_tag_editor import IAMCCS_DialogueTagEditor, IAMCCS_DialogueAudioBoardBridge
from .audio.cine_audio_info import IAMCCS_CineAudioInfo
from .audio.audio_phase_lazy import IAMCCS_AudioPhaseLazyGate
from .iamccs_ideogram_storyboard_frame_designer import (
IAMCCS_StoryboardFrameDesigner,
IAMCCS_StoryboardFrameDesignerV2,
@@ -393,6 +394,16 @@ except Exception as exc:
IAMCCS_ScailExtends = None
IAMCCS_ScailExtendPlan = None
try:
from .iamccs_wananimate_extends import (
IAMCCS_WanAnimateExtends,
IAMCCS_WanAnimateExtendPlan,
)
except Exception as exc:
logging.warning("IAMCCS WanAnimate Extends nodes unavailable: %s", exc)
IAMCCS_WanAnimateExtends = None
IAMCCS_WanAnimateExtendPlan = None
# Nodi principali
NODE_CLASS_MAPPINGS = {
"IAMCCS_WanLoRAStack": IAMCCS_WanLoRAStack,
@@ -591,6 +602,7 @@ NODE_CLASS_MAPPINGS = {
"IAMCCS_DialogueTagEditor": IAMCCS_DialogueTagEditor,
"IAMCCS_DialogueAudioBoardBridge": IAMCCS_DialogueAudioBoardBridge,
"IAMCCS_CineAudioInfo": IAMCCS_CineAudioInfo,
"IAMCCS_AudioPhaseLazyGate": IAMCCS_AudioPhaseLazyGate,
"IAMCCS_StoryboardFrameDesigner": IAMCCS_StoryboardFrameDesigner,
"IAMCCS_StoryboardFrameDesignerV2": IAMCCS_StoryboardFrameDesignerV2,
"IAMCCS_IdeoInfo": IAMCCS_IdeoInfo,
@@ -675,6 +687,11 @@ NODE_CLASS_MAPPINGS = {
"IAMCCS_ScailExtendPlan": IAMCCS_ScailExtendPlan,
} if IAMCCS_ScailExtends is not None else {}),
**({
"IAMCCS_WanAnimateExtends": IAMCCS_WanAnimateExtends,
"IAMCCS_WanAnimateExtendPlan": IAMCCS_WanAnimateExtendPlan,
} if IAMCCS_WanAnimateExtends is not None else {}),
# QwenVL First/Last Frame (registered only if QwenVL is installed)
**({"IAMCCS_QWEN_VL_FLF": IAMCCS_QWEN_VL_FLF,
"IAMCCS_QWEN_VL_FLF_Advanced": IAMCCS_QWEN_VL_FLF_Advanced,
@@ -685,6 +702,8 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_ScailExtends": "IAMCCS SCAIL Extends",
"IAMCCS_ScailExtendPlan": "IAMCCS SCAIL Extend Plan",
"IAMCCS_WanAnimateExtends": "IAMCCS WanAnimate Extends",
"IAMCCS_WanAnimateExtendPlan": "IAMCCS WanAnimate Extend Plan",
"IAMCCS_ScailIdentitySeeder": "IAMCCS SCAIL Identity Seeder",
"IAMCCS_ScailIdentityTracker": "IAMCCS SCAIL Identity Tracker",
"IAMCCS_ScailMultiReference": "IAMCCS SCAIL Multi-Reference (experimental)",
@@ -918,6 +937,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_DialogueTagEditor": "IAMCCS Dialogue Tag Editor",
"IAMCCS_DialogueAudioBoardBridge": "IAMCCS Dialogue AudioBoard Bridge",
"IAMCCS_CineAudioInfo": "IAMCCS CineAudioInfo",
"IAMCCS_AudioPhaseLazyGate": "IAMCCS Audio Phase Lazy Gate",
"IAMCCS_StoryboardFrameDesigner": "IAMCCS StoryboardFrame + TextInFrame Director",
"IAMCCS_StoryboardFrameDesignerV2": "IAMCCS StoryboardFrame V2 + Image Canvas i2i",
"IAMCCS_IdeoInfo": "IDEO_INFO",
@@ -1677,6 +1697,18 @@ def setup_api_routes() -> None:
logging.getLogger("IAMCCS.API").warning("Could not setup IAMCCS API routes: %r", e)
# IAMCCS Shotboard V4 / Motion Sketch optional module
try:
from .cine_shotboard_v4 import (
NODE_CLASS_MAPPINGS as _IAMCCS_SHOTBOARD_V4_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as _IAMCCS_SHOTBOARD_V4_NODE_DISPLAY_NAME_MAPPINGS,
)
NODE_CLASS_MAPPINGS.update(_IAMCCS_SHOTBOARD_V4_NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(_IAMCCS_SHOTBOARD_V4_NODE_DISPLAY_NAME_MAPPINGS)
except Exception as e:
logging.getLogger("IAMCCS").warning("IAMCCS Shotboard V4 MotionSketch nodes unavailable: %r", e)
# Setup API routes when extension loads
setup_api_routes()
+109
View File
@@ -0,0 +1,109 @@
import copy
import json
import time
SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX"
def _base_linx(phase, enabled):
return {
"schema": "iamccs_audio_phase_lazy_gate",
"version": 1,
"phase": str(phase or "dialogue_to_audioboard"),
"enabled": bool(enabled),
"resources": {},
"meta": {
"created_at": time.time(),
"truth": "IAMCCS lazy gates evaluate only the active audio/video phase and pass CineLinx/audio timeline payloads without changing their contents.",
},
}
def _clone_linx(cine_linx):
if isinstance(cine_linx, dict):
try:
return copy.deepcopy(cine_linx)
except Exception:
return dict(cine_linx)
if isinstance(cine_linx, str) and cine_linx.strip():
try:
parsed = json.loads(cine_linx)
if isinstance(parsed, dict):
return parsed
except Exception:
pass
return None
class IAMCCS_AudioPhaseLazyGate:
"""
Lazy CineLinx/audio phase gate for splitting large IAMCCS audio workflows into explicit stages.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"phase": ([
"dialogue_to_audioboard",
"publish_to_shotboard",
"video_from_shotboard",
], {"default": "dialogue_to_audioboard"}),
"enabled": ("BOOLEAN", {"default": True, "label_on": "ACTIVE", "label_off": "BYPASS"}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE, {"lazy": True}),
"audio_timeline_json": ("STRING", {"forceInput": True, "lazy": True}),
},
}
RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "BOOLEAN", "STRING")
RETURN_NAMES = ("cine_linx", "audio_timeline_json", "enabled", "report")
FUNCTION = "run"
CATEGORY = "IAMCCS/Audio Tools"
def check_lazy_status(self, phase, enabled=True, cine_linx=None, audio_timeline_json=None):
if not enabled:
return []
requested = []
if cine_linx is None:
requested.append("cine_linx")
return requested
def run(self, phase, enabled=True, cine_linx=None, audio_timeline_json=""):
phase = str(phase or "dialogue_to_audioboard")
if not enabled:
payload = _base_linx(phase, False)
report = {
"node": "IAMCCS_AudioPhaseLazyGate",
"phase": phase,
"enabled": False,
"truth": "Disabled lazy gate did not request upstream lazy inputs.",
}
return (payload, "", False, json.dumps(report, ensure_ascii=False, indent=2))
payload = _clone_linx(cine_linx) or _base_linx(phase, True)
resources = payload.setdefault("resources", {})
timeline_json = str(audio_timeline_json or resources.get("cine_audio_timeline_json") or resources.get("audio_timeline_json") or "")
resources["iamccs_audio_phase_lazy_gate"] = {
"phase": phase,
"enabled": True,
"has_audio_timeline_json": bool(timeline_json.strip()),
"updated_at": time.time(),
}
if timeline_json.strip():
resources["cine_audio_timeline_json"] = timeline_json
resources["audio_timeline_json"] = timeline_json
chain = payload.setdefault("iamccs_phase_chain", [])
if isinstance(chain, list):
chain.append({"node": "IAMCCS_AudioPhaseLazyGate", "phase": phase, "enabled": True})
report = {
"node": "IAMCCS_AudioPhaseLazyGate",
"phase": phase,
"enabled": True,
"has_cine_linx": cine_linx is not None,
"has_audio_timeline_json": bool(timeline_json.strip()),
"truth": "Enabled lazy gate passes CineLinx and audio timeline payloads unchanged except for phase metadata.",
}
return (payload, timeline_json, True, json.dumps(report, ensure_ascii=False, indent=2))
+105 -9
View File
@@ -107,6 +107,10 @@ def _speaker_order(dialogue: Dict[str, Any]) -> List[str]:
def _dialogue_speaker_stem_start_frames(dialogue: Dict[str, Any], fps: float) -> Dict[str, int]:
settings = dialogue.get("settings") if isinstance(dialogue.get("settings"), dict) else {}
template = dialogue.get("audio_board_template") if isinstance(dialogue.get("audio_board_template"), dict) else {}
if bool(settings.get("speaker_stems_zero_start") or template.get("speakerStemsZeroStart")):
return {key: 0 for key in _speaker_order(dialogue)}
explicit = dialogue.get("speaker_stem_start_frames") if isinstance(dialogue.get("speaker_stem_start_frames"), dict) else {}
if explicit:
out = {}
@@ -205,6 +209,18 @@ def _line_to_srt(index: int, start: float, end: float, text: str) -> str:
return f"{index}\n{_srt_timestamp(start)} --> {_srt_timestamp(end)}\n{text.strip()}\n\n"
def _speaker_stem_srt_local_zero(dialogue: Dict[str, Any]) -> bool:
"""Keep generated per-speaker WAVs local, while timeline lanes store the real offset."""
settings = dialogue.get("settings") if isinstance(dialogue.get("settings"), dict) else {}
template = dialogue.get("audio_board_template") if isinstance(dialogue.get("audio_board_template"), dict) else {}
for key in ("speaker_stem_srt_local_zero", "speakerStemSrtLocalZero"):
if key in settings:
return bool(settings.get(key))
if key in template:
return bool(template.get(key))
return True
def _strip_inline_tts_tags(text: str) -> str:
# In plain dialogue mode, keep only words meant to be spoken. Metadata stays in cine_linx.
clean = re.sub(r"<[^>]+>", "", str(text or ""))
@@ -212,11 +228,52 @@ def _strip_inline_tts_tags(text: str) -> str:
return re.sub(r"\s+", " ", clean).strip()
def _strip_angle_tts_tags(text: str) -> str:
clean = re.sub(r"<[^>]+>", "", str(text or ""))
return re.sub(r"\s+", " ", clean).strip()
def _has_indextts_character_tags(text: str) -> bool:
return bool(re.search(r"\[[^\]\n:]+:[^\]\n]+\]", str(text or "")))
def _line_text(line: Dict[str, Any], mode: str) -> str:
text = str(line.get("ttsText") or line.get("text") or line.get("dialogueText") or "").strip()
speaker = str(line.get("speaker") or line.get("speakerName") or "").strip()
if mode == "plain_dialogue":
return _strip_inline_tts_tags(text)
if mode == "index_tts_text_emotion":
return _strip_inline_tts_tags(text)
if mode == "index_tts_character_tags":
clean = _strip_angle_tts_tags(text)
if _has_indextts_character_tags(clean):
return clean
clean = _strip_inline_tts_tags(clean)
emotion = str(line.get("emotion") or "none").strip()
tag = speaker or str(line.get("speakerName") or "Speaker").strip() or "Speaker"
if emotion and emotion.lower() != "none":
tag = f"{tag}:{emotion}"
return f"[{tag}] {clean}".strip()
if mode == "chatterbox_v2_tokens":
clean = _strip_inline_tts_tags(text)
para = str(line.get("paralinguistic") or line.get("para") or "none").strip()
style = str(line.get("style") or "none").strip().lower()
tokens = []
token_map = {
"Breathing": "<inhale>",
"Laughter": "<laughter>",
"Sigh": "<sigh>",
"Surprise-oh": "<gasp>",
"Uhm": "<UM>",
}
if para in token_map:
tokens.append(token_map[para])
if "whisper" in style:
tokens.append("<whisper>")
if "murmur" in style or "mumble" in style:
tokens.append("<mumble>")
prefix = " ".join(tokens).strip()
return f"{prefix} {clean}".strip()
if speaker and not text.startswith("[") and mode in {"speaker_tags", "tts_audio_suite_tags"}:
text = f"[{speaker}|en] {text}"
if mode == "tts_audio_suite_tags":
@@ -235,8 +292,19 @@ def _line_text(line: Dict[str, Any], mode: str) -> str:
def _export_speaker_srts(dialogue: Dict[str, Any], segments: List[Dict[str, Any]], fps: float, mode: str) -> Dict[str, str]:
order = _speaker_order(dialogue)
grouped: Dict[str, List[str]] = {key: [] for key in order}
settings = dialogue.get("settings") if isinstance(dialogue.get("settings"), dict) else {}
template = dialogue.get("audio_board_template") if isinstance(dialogue.get("audio_board_template"), dict) else {}
zero_start_stems = _speaker_stem_srt_local_zero(dialogue)
lines = dialogue.get("export_lines") if isinstance(dialogue.get("export_lines"), list) else dialogue.get("lines")
if isinstance(lines, list) and lines:
offsets: Dict[str, float] = {}
if zero_start_stems:
for line in lines:
if not isinstance(line, dict):
continue
key = str(line.get("speaker") or line.get("speakerName") or order[0])
start = max(0.0, _safe_float(line.get("start", 0.0), 0.0))
offsets[key] = min(offsets.get(key, start), start)
counters: Dict[str, int] = {}
for line in lines:
if not isinstance(line, dict):
@@ -244,12 +312,23 @@ def _export_speaker_srts(dialogue: Dict[str, Any], segments: List[Dict[str, Any]
key = str(line.get("speaker") or line.get("speakerName") or order[0])
if key not in grouped:
grouped[key] = []
start = _safe_float(line.get("start", 0.0), 0.0)
absolute_start = _safe_float(line.get("start", 0.0), 0.0)
duration = _safe_float(line.get("duration", 0.0), 0.0)
end = _safe_float(line.get("end", start + duration), start + max(0.8, duration))
absolute_end = _safe_float(line.get("end", absolute_start + duration), absolute_start + max(0.8, duration))
offset = offsets.get(key, 0.0)
start = max(0.0, absolute_start - offset)
end = max(start + 0.2, absolute_end - offset)
counters[key] = counters.get(key, 0) + 1
grouped[key].append(_line_to_srt(counters[key], start, max(end, start + 0.2), _line_text(line, mode)))
grouped[key].append(_line_to_srt(counters[key], start, end, _line_text(line, mode)))
else:
offsets: Dict[str, float] = {}
if zero_start_stems:
for seg in segments:
if not isinstance(seg, dict):
continue
key = str(seg.get("speaker") or seg.get("speakerName") or order[0])
start = max(0.0, _safe_int(seg.get("start", 0), 0) / max(1.0, fps))
offsets[key] = min(offsets.get(key, start), start)
counters: Dict[str, int] = {}
for seg in segments:
if not isinstance(seg, dict):
@@ -257,8 +336,9 @@ def _export_speaker_srts(dialogue: Dict[str, Any], segments: List[Dict[str, Any]
key = str(seg.get("speaker") or seg.get("speakerName") or order[0])
if key not in grouped:
grouped[key] = []
start = _safe_int(seg.get("start", 0), 0) / max(1.0, fps)
absolute_start = _safe_int(seg.get("start", 0), 0) / max(1.0, fps)
length = max(1, _safe_int(seg.get("length", 1), 1)) / max(1.0, fps)
start = max(0.0, absolute_start - offsets.get(key, 0.0))
counters[key] = counters.get(key, 0) + 1
grouped[key].append(_line_to_srt(counters[key], start, start + length, _line_text(seg, mode)))
return {key: "".join(parts).strip() for key, parts in grouped.items()}
@@ -420,6 +500,9 @@ class IAMCCS_CineAudioInfo:
"frame_rate": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}),
"tts_text_mode": ([
"tts_audio_suite_tags",
"index_tts_text_emotion",
"index_tts_character_tags",
"chatterbox_v2_tokens",
"speaker_tags",
"plain_dialogue",
], {"default": "tts_audio_suite_tags"}),
@@ -430,7 +513,7 @@ class IAMCCS_CineAudioInfo:
"attach_to_first_lane",
], {"default": "slice_master_by_existing_lanes"}),
"save_subfolder": ("STRING", {"default": "IAMCCS_generated_audio", "multiline": False}),
"file_prefix": ("STRING", {"default": "dialogue_tts_master", "multiline": False}),
"file_prefix": ("STRING", {"default": "dialogue_tts_single_master", "multiline": False}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
@@ -461,9 +544,14 @@ class IAMCCS_CineAudioInfo:
speaker_order = _speaker_order(dialogue)
speaker_a_srt = speaker_srts.get(speaker_order[0], "") if speaker_order else ""
speaker_b_srt = speaker_srts.get(speaker_order[1], "") if len(speaker_order) > 1 else ""
effective_lane_injection_mode = str(lane_injection_mode)
if str(mode) == "export_speaker_stems":
tts_srt = speaker_a_srt
# Keep tts_srt as the master dialogue export. Speaker-specific outputs
# must remain the only source for A/B stems, otherwise B can silently
# fall back to Speaker A in existing graphs.
tts_text = "\n".join(part.strip() for part in [speaker_a_srt, speaker_b_srt] if part.strip())
if str(mode) == "inject_speaker_stems" and effective_lane_injection_mode == "slice_master_by_existing_lanes":
effective_lane_injection_mode = "speaker_full_timeline_clips"
duration_frames = _max_end_frames(segments)
duration_seconds = float(duration_frames) / fps if duration_frames else _safe_float(timeline.get("duration_seconds", outputs.get("duration_seconds", 0.0)), 0.0)
@@ -486,7 +574,7 @@ class IAMCCS_CineAudioInfo:
audio_frames = max(1, int(math.ceil(audio_duration * fps)))
duration_frames = max(duration_frames, audio_frames)
duration_seconds = max(duration_seconds, audio_duration)
if str(lane_injection_mode) == "speaker_full_timeline_clips" or not segments:
if effective_lane_injection_mode == "speaker_full_timeline_clips" or not segments:
speaker_start_frames = _dialogue_speaker_stem_start_frames(dialogue, fps)
for source_seg in source_segments:
key = str(source_seg.get("speaker") or source_seg.get("speakerName") or "")
@@ -555,7 +643,7 @@ class IAMCCS_CineAudioInfo:
segments = [{
"id": "dialogue_tts_master",
"type": "audio",
"name": "Dialogue TTS Master",
"name": "Dialogue TTS Single Master",
"track": 0,
"start": 0,
"length": audio_frames,
@@ -628,7 +716,8 @@ class IAMCCS_CineAudioInfo:
resources.update({
"cine_audio_info": {
"mode": str(mode),
"lane_injection_mode": str(lane_injection_mode),
"lane_injection_mode": effective_lane_injection_mode,
"requested_lane_injection_mode": str(lane_injection_mode),
"tts_text_mode": str(tts_text_mode),
"generated_audio_file": injected_file,
"speaker_stem_files": dict(([(str(speaker_order[0]), file_a if "file_a" in locals() else "")] if speaker_order else []) + ([(str(speaker_order[1]), file_b if "file_b" in locals() else "")] if len(speaker_order) > 1 else [])),
@@ -685,12 +774,19 @@ class IAMCCS_CineAudioInfo:
report = json.dumps({
"node": "IAMCCS_CineAudioInfo",
"mode": str(mode),
"lane_injection_mode": effective_lane_injection_mode,
"segments": len(segments),
"tracks": track_count,
"has_media": bool(has_media),
"audio_file": injected_file,
"speaker_stem_files": resources.get("cine_audio_info", {}).get("speaker_stem_files", {}),
"duration_seconds": float(duration_seconds),
"tts_srt_chars": len(tts_srt),
"speaker_a_srt_chars": len(resources["cine_dialogue_speaker_a_srt"]),
"speaker_b_srt_chars": len(resources["cine_dialogue_speaker_b_srt"]),
"speaker_srts_identical": bool(resources["cine_dialogue_speaker_a_srt"] and resources["cine_dialogue_speaker_a_srt"] == resources["cine_dialogue_speaker_b_srt"]),
"speaker_a_srt_preview": str(resources["cine_dialogue_speaker_a_srt"])[:120],
"speaker_b_srt_preview": str(resources["cine_dialogue_speaker_b_srt"])[:120],
"truth": "CineAudioInfo exports dialogue SRT to TTS and injects generated AUDIO back into Shotboard-compatible AudioBoard lanes through cine_linx.",
}, ensure_ascii=False, indent=2)
+14 -12
View File
@@ -241,7 +241,7 @@ def _format_dialogue_text(line: Dict[str, Any], speaker: Dict[str, Any], engine_
def _build_dialogue_export(data: Dict[str, Any], frame_rate: float, speech_wpm: float, min_line_seconds: float, default_gap_seconds: float, output_mode: str, inline_edit_mode: str) -> Dict[str, Any]:
global_prompt = str(data.get("global_prompt") or data.get("prompt") or "").strip()
if not global_prompt:
global_prompt = "cinematic night interior, two men in field and reverse-field dialogue, natural audio-driven lip sync, subtle listening reactions, no subtitles, no visible text"
global_prompt = "cinematic field and reverse-field dialogue, hard cut coverage, one dominant speaking face per shot, visible mouth movement, natural audio-driven performance, silent listener reaction, stable identities, coherent eyelines"
speakers = data.get("speakers") if isinstance(data.get("speakers"), list) else []
if not speakers:
speakers = copy.deepcopy(DEFAULT_DIALOGUE["speakers"])
@@ -306,12 +306,14 @@ def _build_dialogue_export(data: Dict[str, Any], frame_rate: float, speech_wpm:
export_lines = lines
settings_data = data.get("settings") if isinstance(data.get("settings"), dict) else {}
zero_start_stems = bool(settings_data.get("speaker_stems_zero_start", False)) and not single_track_mode
stem_offsets: Dict[str, float] = {}
if zero_start_stems:
for line in export_lines:
key = str(line.get("speaker") or line.get("speaker_name") or "A")
start = max(0.0, _float(line.get("start"), 0.0))
stem_offsets[key] = min(stem_offsets.get(key, start), start)
# Speaker stem media should be generated on its own local timebase so the WAV
# does not contain leading silence. The AudioBoard lane start keeps the real
# timeline position unless the user explicitly enables A+B @ 0.
stem_media_offsets: Dict[str, float] = {}
for line in export_lines:
key = str(line.get("speaker") or line.get("speaker_name") or "A")
start = max(0.0, _float(line.get("start"), 0.0))
stem_media_offsets[key] = min(stem_media_offsets.get(key, start), start)
engine_profile = _resolve_engine_profile(data.get("settings", {}).get("engine_profile"))
master_srt_parts: List[str] = []
@@ -329,9 +331,9 @@ def _build_dialogue_export(data: Dict[str, Any], frame_rate: float, speech_wpm:
master_srt_parts.append(_line_to_srt(index, start, end, formatted))
tagged_parts.append(formatted)
key = str(speaker.get("id") or line.get("speaker") or "A")
stem_offset = stem_offsets.get(key, 0.0) if zero_start_stems else 0.0
stem_start = max(0.0, start - stem_offset)
stem_end = max(stem_start + 0.08, end - stem_offset)
stem_media_offset = stem_media_offsets.get(key, 0.0)
stem_start = max(0.0, start - stem_media_offset)
stem_end = max(stem_start + 0.08, end - stem_media_offset)
stem_srt.setdefault(key, []).append(_line_to_srt(len(stem_srt.get(key, [])) + 1, stem_start, stem_end, formatted))
stem_text.setdefault(key, []).append(formatted)
visual_start_frames = int(round(start * float(frame_rate)))
@@ -365,7 +367,7 @@ def _build_dialogue_export(data: Dict[str, Any], frame_rate: float, speech_wpm:
"length": length_frames,
"ref": int(line.get("ref", 1)),
"label": str(line.get("label") or f"{key}_{index:02d}"),
"prompt": str(line.get("local_prompt") or line.get("shot_prompt") or f"cut to speaker {key}, natural dialogue performance, lip sync driven by external dialogue audio, coherent eyeline"),
"prompt": str(line.get("local_prompt") or line.get("shot_prompt") or f"hard cut, Speaker {key} close-up, Speaker {key} speaks clearly, visible mouth movement, coherent eyeline"),
"dialogue": f'{key}: "{line.get("text", "")}"',
"audio_or_dialogue": f'{key}: "{line.get("text", "")}"',
"dialogue_pin": True,
@@ -447,7 +449,7 @@ def _build_dialogue_export(data: Dict[str, Any], frame_rate: float, speech_wpm:
class IAMCCS_DialogueTagEditor:
"""App-style dialogue/tag planner that writes one cine_linx payload for TTS, AudioBoard and Shotboard."""
DEFAULT_DATA = json.dumps({"schema": "iamccs.dialogue_tag_editor", "schema_version": 2, "global_prompt": "cinematic night interior, two men in field and reverse-field dialogue, natural audio-driven lip sync, controlled tension, subtle breathing and listening reactions, no subtitles, no visible text", "settings": {"engine_profile": "tts_audio_suite_chatterbox", "output_mode": "speaker_stems_for_overlap", "speaker_stems_zero_start": False, "inline_edit_mode": "metadata_only", "default_gap_seconds": 0.12, "text_theme": "light_boxes", "font_zoom": 1.0}, "speakers": [{"id": "A", "name": "Man A", "voice": "speaker_a_low_tense", "reference_text": "Keep your voice low. We do not know who is listening.", "language": "en"}, {"id": "B", "name": "Man B", "voice": "speaker_b_controlled_whisper", "reference_text": "Good. Now we finally have something worth protecting.", "language": "en"}], "lines": [{"id": "line_001", "speaker": "A", "text": "You said the signal was dead. Then why is that receiver still blinking?", "emotion": "tense", "style": "low", "paralinguistic": "Breathing", "overlap_after": 0.18, "ref": 1, "track": 0, "local_prompt": "field shot on Man A, tense close-up, controlled suspicion, natural lip sync driven by external dialogue audio"}, {"id": "line_002", "speaker": "B", "text": "Because someone on the other side wants us to think we are alone.", "emotion": "serious", "style": "whisper", "paralinguistic": "none", "overlap_after": 0.12, "ref": 2, "track": 1, "local_prompt": "reverse field shot on Man B, quiet answer, guarded fear under control, lips follow the external dialogue audio"}, {"id": "line_003", "speaker": "A", "text": "If we open that door, we may be giving them exactly what they came for.", "emotion": "fearful", "style": "dry", "paralinguistic": "Sigh", "overlap_after": 0.1, "ref": 1, "track": 0, "local_prompt": "cut back to Man A, tighter frame, fear hidden behind discipline, coherent eyeline, audio-driven speaking performance"}, {"id": "line_004", "speaker": "B", "text": "Then we do not open it. We make them knock twice.", "emotion": "coldness", "style": "authority", "paralinguistic": "none", "overlap_after": 0.0, "ref": 2, "track": 1, "local_prompt": "reverse close-up on Man B, decisive final line, controlled authority, natural lip sync driven by external audio"}]}, indent=2, ensure_ascii=False)
DEFAULT_DATA = json.dumps({"schema": "iamccs.dialogue_tag_editor", "schema_version": 2, "global_prompt": "cinematic field and reverse-field dialogue, hard cut coverage, one dominant speaking face per shot, visible mouth movement, natural audio-driven performance, silent listener reaction, stable identities, coherent eyelines", "settings": {"engine_profile": "tts_audio_suite_chatterbox", "output_mode": "speaker_stems_for_overlap", "speaker_stems_zero_start": False, "inline_edit_mode": "metadata_only", "default_gap_seconds": 0.12, "text_theme": "light_boxes", "font_zoom": 1.0}, "speakers": [{"id": "A", "name": "Man A", "voice": "speaker_a_low_tense", "reference_text": "Keep your voice low. We do not know who is listening.", "language": "en"}, {"id": "B", "name": "Man B", "voice": "speaker_b_controlled_whisper", "reference_text": "Good. Now we finally have something worth protecting.", "language": "en"}], "lines": [{"id": "line_001", "speaker": "A", "text": "You said the signal was dead. Then why is that receiver still blinking?", "emotion": "tense", "style": "low", "paralinguistic": "Breathing", "overlap_after": 0.18, "ref": 1, "track": 0, "local_prompt": "hard cut, Man A close-up, Man A speaks clearly, visible mouth movement, tense controlled delivery, Man B listens quietly"}, {"id": "line_002", "speaker": "B", "text": "Because someone on the other side wants us to think we are alone.", "emotion": "serious", "style": "whisper", "paralinguistic": "none", "overlap_after": 0.12, "ref": 2, "track": 1, "local_prompt": "hard cut, Man B close-up, Man B speaks clearly, visible mouth movement, guarded quiet answer, Man A listens quietly"}, {"id": "line_003", "speaker": "A", "text": "If we open that door, we may be giving them exactly what they came for.", "emotion": "fearful", "style": "dry", "paralinguistic": "Sigh", "overlap_after": 0.1, "ref": 1, "track": 0, "local_prompt": "hard cut, Man A tighter close-up, Man A speaks clearly, visible mouth movement, fear held under discipline"}, {"id": "line_004", "speaker": "B", "text": "Then we do not open it. We make them knock twice.", "emotion": "coldness", "style": "authority", "paralinguistic": "none", "overlap_after": 0.0, "ref": 2, "track": 1, "local_prompt": "hard cut, Man B close-up, Man B speaks clearly, visible mouth movement, decisive controlled authority"}]}, indent=2, ensure_ascii=False)
@classmethod
def INPUT_TYPES(cls):
+612 -2
View File
@@ -432,8 +432,60 @@ def _apply_active_take(
outputs["max_frames"] = int(duration_frames)
def _take_audio_lane_name(take_index: Any) -> str:
take = max(1, _safe_int(take_index, 1))
return f"A{take}"
def _take_timeline_id(take_index: Any) -> str:
take = max(1, _safe_int(take_index, 1))
return f"T{take:02d}"
def _make_take_audio_contract(takes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
contract: List[Dict[str, Any]] = []
for idx, take in enumerate(takes):
take_index = max(1, _safe_int(take.get("take_index", idx + 1), idx + 1))
timeline_id = str(take.get("timeline_id") or _take_timeline_id(take_index))
audio_lane = str(take.get("audio_lane") or _take_audio_lane_name(take_index))
audio_track_index = max(0, _safe_int(take.get("audio_track_index", take_index - 1), take_index - 1))
contract.append({
"take_index": take_index,
"timeline_id": timeline_id,
"audio_lane": audio_lane,
"audio_track_index": audio_track_index,
"mapping": f"{timeline_id}->{audio_lane}",
"rule": "one_indexed_audio_lane_per_timeline",
})
return contract
def _make_sequence_plan(index: Dict[str, Any]) -> Dict[str, Any]:
takes = index.get("takes") if isinstance(index.get("takes"), list) else []
contract = _make_take_audio_contract(takes)
return {
"schema": "iamccs.multigeneration.sequence_plan",
"schema_version": 1,
"source": "IAMCCS_MultiTimelineBridge",
"mode": "manual_or_sequential",
"manual_active_take": _safe_int(index.get("active_take", 1), 1),
"queue_policy": "manual_take_picker_branches_or_external_sequential_queue",
"contract": contract,
"steps": [{
"step_index": idx + 1,
"take_index": item["take_index"],
"timeline_id": item["timeline_id"],
"audio_lane": item["audio_lane"],
"bridge_action": f"prepare_{item['timeline_id']}_{item['audio_lane']}",
"expected_video_slot": f"video_take_{item['take_index']:02d}",
} for idx, item in enumerate(contract)],
"truth": "T1 uses A1, T2 uses A2, T3 uses A3. Manual mode prepares one take; sequential mode queues each step in order and sends generated clips to the Shotboard Video Editor.",
}
def _make_concat_plan(index: Dict[str, Any], source_bus: str) -> Dict[str, Any]:
takes = index.get("takes") if isinstance(index.get("takes"), list) else []
contract = _make_take_audio_contract(takes)
return {
"schema": "iamccs.multigeneration.concat_plan",
"schema_version": 1,
@@ -441,9 +493,11 @@ def _make_concat_plan(index: Dict[str, Any], source_bus: str) -> Dict[str, Any]:
"source_bus": str(source_bus),
"final_audio_policy": "restore_original_master_or_selected_bus_after_video_concat",
"video_concat_policy": "hard_cut_in_take_order",
"take_audio_contract": contract,
"takes": [{
"take_index": _safe_int(take.get("take_index", idx + 1), idx + 1),
"timeline_id": str(take.get("timeline_id", f"T{idx + 1:02d}")),
"audio_lane": contract[idx]["audio_lane"] if idx < len(contract) else _take_audio_lane_name(idx + 1),
"global_start_frames": _safe_int(take.get("global_start_frames", 0), 0),
"duration_frames": _safe_int(take.get("duration_frames", 0), 0),
"expected_video_slot": f"video_take_{idx + 1:02d}",
@@ -584,9 +638,18 @@ class IAMCCS_MultiTimelineBridge:
takes.append(take)
active_index = max(1, min(len(takes), _safe_int(active_take, 1))) - 1
for idx, take in enumerate(takes):
take_index = max(1, _safe_int(take.get("take_index", idx + 1), idx + 1))
take["take_index"] = take_index
take["timeline_id"] = str(take.get("timeline_id") or _take_timeline_id(take_index))
take["audio_lane"] = _take_audio_lane_name(take_index)
take["audio_track_index"] = take_index - 1
take["timeline_audio_contract"] = f"{take['timeline_id']}->{take['audio_lane']}"
take_audio_contract = _make_take_audio_contract(takes)
generation_index = {
"schema": "iamccs.multigeneration.index",
"schema_version": 1,
"schema_version": 2,
"source": "IAMCCS_MultiTimelineBridge",
"frame_rate": float(fps),
"chunk_template": str(chunk_template),
@@ -598,11 +661,15 @@ class IAMCCS_MultiTimelineBridge:
"source_duration_seconds": source_duration / fps,
"take_count": int(len(takes)),
"active_take": int(active_index + 1),
"active_timeline_id": _take_timeline_id(active_index + 1),
"active_audio_lane": _take_audio_lane_name(active_index + 1),
"take_track_layout": str(take_track_layout),
"takes": takes,
"take_audio_contract": take_audio_contract,
"bus_generation_index": manifest.get("generation_index") if isinstance(manifest.get("generation_index"), dict) else {},
"truth": "Audio remains BusOut/AudioBoard custom-audio metadata. Each take receives a local audio window for sequential video-driven generation, then video takes are hard-concatenated.",
"truth": "T1=A1, T2=A2, T3=A3. Audio remains BusOut/AudioBoard custom-audio metadata. Each take receives a local audio window for sequential video-driven generation, then video takes are hard-concatenated.",
}
sequence_plan = _make_sequence_plan(generation_index)
concat_plan = _make_concat_plan(generation_index, str(source_bus))
out_linx = _clone_linx(cine_linx)
@@ -610,6 +677,9 @@ class IAMCCS_MultiTimelineBridge:
outputs = _outputs(out_linx)
resources["cine_multigeneration_concat_plan"] = concat_plan
resources["cine_multigeneration_concat_plan_json"] = _json_dump(concat_plan)
resources["cine_multigeneration_sequence_plan"] = sequence_plan
resources["cine_multigeneration_sequence_plan_json"] = _json_dump(sequence_plan)
resources["cine_multigeneration_take_audio_contract"] = take_audio_contract
resources["cine_multigeneration_bus_manifest"] = manifest
_apply_active_take(out_linx, generation_index, takes[active_index], str(take_track_layout))
outputs["concat_plan_json"] = _json_dump(concat_plan)
@@ -632,12 +702,145 @@ class IAMCCS_MultiTimelineBridge:
"source_segments": len(source_segments),
"active_segments": len(takes[active_index].get("audioSegments", [])),
"prechunked": bool(takes[active_index].get("prechunked", False)),
"active_timeline_id": _take_timeline_id(active_index + 1),
"active_audio_lane": _take_audio_lane_name(active_index + 1),
"contract": [item.get("mapping") for item in take_audio_contract],
"concat_policy": concat_plan["video_concat_policy"],
"sequence_steps": len(sequence_plan.get("steps", [])),
})
outputs["report"] = report
return out_linx, _json_dump(generation_index), _json_dump(takes[active_index]), _json_dump(concat_plan), report
class IAMCCS_MultiTimelineSequentialPicker:
"""Expose T1-A1..T5-A5 as parallel cine_linx outputs for one-queue staged generation."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"generation_index_json": ("STRING", {"default": "", "multiline": True}),
"take_track_layout": (["collapse_to_lane_1", "preserve_bus_tracks"], {"default": "collapse_to_lane_1"}),
"enabled_takes": ("INT", {"default": 3, "min": 1, "max": 5, "step": 1}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
},
}
RETURN_TYPES = (
SUPERNODE_LINX_TYPE,
SUPERNODE_LINX_TYPE,
SUPERNODE_LINX_TYPE,
SUPERNODE_LINX_TYPE,
SUPERNODE_LINX_TYPE,
"STRING",
"STRING",
)
RETURN_NAMES = (
"cine_linx_T1_A1",
"cine_linx_T2_A2",
"cine_linx_T3_A3",
"cine_linx_T4_A4",
"cine_linx_T5_A5",
"sequence_plan_json",
"report",
)
FUNCTION = "pick_sequence"
CATEGORY = "IAMCCS/Cine/Multigeneration"
def pick_sequence(self, generation_index_json, take_track_layout, enabled_takes, cine_linx=None):
generation_index = _safe_json_loads(generation_index_json, {})
if not isinstance(generation_index, dict):
generation_index = {}
takes = generation_index.get("takes") if isinstance(generation_index.get("takes"), list) else []
if not takes:
generation_index.setdefault("frame_rate", 24.0)
takes = []
for index in range(max(1, min(5, _safe_int(enabled_takes, 3)))):
take = {
"schema": "iamccs.multigeneration.take",
"schema_version": 1,
"take_index": index + 1,
"timeline_id": _take_timeline_id(index + 1),
"audio_lane": _take_audio_lane_name(index + 1),
"duration_frames": 1,
"audioSegments": [],
}
takes.append(take)
generation_index["takes"] = takes
max_enabled = max(1, min(5, _safe_int(enabled_takes, 3)))
outputs: List[Dict[str, Any]] = []
steps: List[Dict[str, Any]] = []
for index in range(5):
source_take = copy.deepcopy(takes[index]) if index < len(takes) else {
"schema": "iamccs.multigeneration.take",
"schema_version": 1,
"take_index": index + 1,
"timeline_id": _take_timeline_id(index + 1),
"audio_lane": _take_audio_lane_name(index + 1),
"duration_frames": 1,
"audioSegments": [],
"disabled": True,
}
take_index = index + 1
source_take["take_index"] = take_index
source_take["timeline_id"] = str(source_take.get("timeline_id") or _take_timeline_id(take_index))
source_take["audio_lane"] = _take_audio_lane_name(take_index)
source_take["audio_track_index"] = take_index - 1
source_take["sequence_enabled"] = take_index <= max_enabled and index < len(takes)
out_linx = _clone_linx(cine_linx)
local_index = copy.deepcopy(generation_index)
local_index["active_take"] = take_index
local_index["active_timeline_id"] = source_take["timeline_id"]
local_index["active_audio_lane"] = source_take["audio_lane"]
_apply_active_take(out_linx, local_index, source_take, str(take_track_layout))
out_linx["type"] = SUPERNODE_LINX_TYPE
out_linx["mode"] = "iamccs_multigeneration_sequence_take"
out_linx.setdefault("chain", []).append({
"role": "multigeneration_sequence_picker",
"name": "IAMCCS_MultiTimelineSequentialPicker",
"take_index": take_index,
"timeline_id": source_take["timeline_id"],
"audio_lane": source_take["audio_lane"],
"enabled": bool(source_take["sequence_enabled"]),
})
_outputs(out_linx)["report"] = _json_dump({
"node": "IAMCCS_MultiTimelineSequentialPicker",
"take_index": take_index,
"timeline_id": source_take["timeline_id"],
"audio_lane": source_take["audio_lane"],
"enabled": bool(source_take["sequence_enabled"]),
})
_refresh_linx_index(out_linx)
outputs.append(out_linx)
steps.append({
"step_index": take_index,
"take_index": take_index,
"timeline_id": source_take["timeline_id"],
"audio_lane": source_take["audio_lane"],
"enabled": bool(source_take["sequence_enabled"]),
"output": f"cine_linx_T{take_index}_A{take_index}",
"expected_video_slot": f"video_take_{take_index:02d}",
})
sequence_plan = {
"schema": "iamccs.multigeneration.sequence_plan",
"schema_version": 2,
"source": "IAMCCS_MultiTimelineSequentialPicker",
"enabled_takes": max_enabled,
"steps": steps,
"truth": "Connect each enabled cine_linx_Tn_An output to its own generation branch, then connect generated videos to IAMCCS Shotboard Video Editor in the same order.",
}
report = _json_dump({
"node": "IAMCCS_MultiTimelineSequentialPicker",
"enabled_takes": max_enabled,
"outputs": [f"T{idx + 1}/A{idx + 1}" for idx in range(5)],
"queue_mode": "parallel_branches_in_one_comfy_queue",
})
return (*outputs, _json_dump(sequence_plan), report)
class IAMCCS_MultiTimelineTakePicker:
"""Pick one take from a MultiTimelineBridge index and expose it as active cine_linx audio."""
@@ -760,6 +963,409 @@ def _concat_audio(audio_items: List[Tuple[Any, int, float]]) -> Dict[str, Any] |
return {"waveform": torch.cat(pieces, dim=2), "sample_rate": target_rate}
def _parse_take_order(value: Any, max_count: int) -> List[int]:
text = str(value or "").strip()
if not text:
return list(range(1, max_count + 1))
out: List[int] = []
for part in text.replace(";", ",").split(","):
number = _safe_int(part.strip(), 0)
if 1 <= number <= max_count and number not in out:
out.append(number)
return out or list(range(1, max_count + 1))
def _clip_edit_for_take(edits: Any, take_index: int) -> Dict[str, Any]:
if not isinstance(edits, dict):
return {}
clips = edits.get("clips") if isinstance(edits.get("clips"), dict) else edits
for key in (str(take_index), f"T{take_index:02d}", f"video_take_{take_index:02d}"):
item = clips.get(key) if isinstance(clips, dict) else None
if isinstance(item, dict):
return item
return {}
def _trim_component(comp: Any, fps: float, trim_in_seconds: float, trim_out_seconds: float):
frame_count = int(comp.images.shape[0])
start = max(0, min(frame_count - 1, int(round(max(0.0, trim_in_seconds) * max(1.0, fps)))))
if trim_out_seconds > 0:
end = max(start + 1, min(frame_count, int(round(trim_out_seconds * max(1.0, fps)))))
else:
end = frame_count
images = comp.images[start:end]
audio = comp.audio
if isinstance(audio, dict) and audio.get("waveform") is not None:
sample_rate = int(audio.get("sample_rate") or 44100)
sample_start = max(0, int(round((start / max(1.0, fps)) * sample_rate)))
sample_end = max(sample_start + 1, int(round((end / max(1.0, fps)) * sample_rate)))
waveform = audio["waveform"][..., sample_start:sample_end]
audio = {"waveform": waveform, "sample_rate": sample_rate}
return images, audio, start, end
def _audio_waveform(audio: Any):
if not isinstance(audio, dict) or audio.get("waveform") is None:
return None, 44100
waveform = audio.get("waveform")
sample_rate = int(audio.get("sample_rate") or 44100)
if waveform is None:
return None, sample_rate
if waveform.dim() == 1:
waveform = waveform.reshape(1, 1, -1)
elif waveform.dim() == 2:
waveform = waveform.unsqueeze(0)
return waveform, sample_rate
def _audio_duration_seconds(audio: Any) -> float:
waveform, sample_rate = _audio_waveform(audio)
if waveform is None:
return 0.0
return float(waveform.shape[-1]) / max(1.0, float(sample_rate))
def _mix_editor_audio_tracks(audio_tracks: List[Any]) -> Any:
prepared = []
target_rate = 0
target_channels = 1
target_device = None
target_dtype = None
for audio in audio_tracks:
waveform, sample_rate = _audio_waveform(audio)
if waveform is None:
continue
if not target_rate:
target_rate = sample_rate
target_device = waveform.device
target_dtype = waveform.dtype
if sample_rate != target_rate:
waveform = torchaudio.functional.resample(waveform, sample_rate, target_rate)
if target_device is not None and waveform.device != target_device:
waveform = waveform.to(target_device)
if target_dtype is not None and waveform.dtype != target_dtype:
waveform = waveform.to(target_dtype)
target_channels = max(target_channels, int(waveform.shape[-2]))
prepared.append(waveform)
if not prepared:
return None
max_samples = max(int(w.shape[-1]) for w in prepared)
padded = []
for waveform in prepared:
if int(waveform.shape[-2]) < target_channels:
waveform = waveform.repeat_interleave(target_channels, dim=-2)[..., :target_channels, :]
if int(waveform.shape[-1]) < max_samples:
waveform = torch.nn.functional.pad(waveform, (0, max_samples - int(waveform.shape[-1])))
padded.append(waveform)
mixed = torch.stack(padded, dim=0).sum(dim=0).clamp(-1.0, 1.0)
return {"waveform": mixed, "sample_rate": int(target_rate or 44100)}
class IAMCCS_ShotboardVideoEditor:
"""Editorial hard-cut assembler. VIDEO/AUDIO inputs are gathered by CineInfo3 through cine_linx."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
"editor_mode": (["assemble_sequence", "preview_selected_take"], {"default": "assemble_sequence"}),
"selected_take": ("INT", {"default": 1, "min": 1, "max": 8, "step": 1}),
"take_order": ("STRING", {"default": "1,2,3", "multiline": False}),
"audio_policy": ([
"concat_clip_audio",
"use_master_audio",
"first_selected_audio",
"mix_editor_audio_tracks",
"concat_editor_audio_tracks",
"silent",
], {"default": "concat_clip_audio"}),
"fps_mode": (["from_first_video", "override_fps"], {"default": "from_first_video"}),
"override_fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}),
"global_trim_in_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.01}),
"global_trim_out_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.01}),
},
"optional": {
"master_audio": ("AUDIO",),
"concat_plan_json": ("STRING", {"default": "", "multiline": True}),
"clip_edits_json": ("STRING", {"default": "", "multiline": True}),
"editor_manifest_json": ("STRING", {"default": "", "multiline": True}),
},
}
RETURN_TYPES = ("VIDEO", "IMAGE", "STRING", "STRING", SUPERNODE_LINX_TYPE)
RETURN_NAMES = ("video", "frames", "editor_plan_json", "report", "cine_linx")
FUNCTION = "edit"
CATEGORY = "IAMCCS/Cine/Multigeneration"
def edit(
self,
cine_linx,
editor_mode,
selected_take,
take_order,
audio_policy,
fps_mode,
override_fps,
global_trim_in_seconds,
global_trim_out_seconds,
master_audio=None,
concat_plan_json="",
clip_edits_json="",
editor_manifest_json="",
):
resources = _resources(cine_linx if isinstance(cine_linx, dict) else {})
video_inputs = resources.get("cine_info3_video_inputs")
audio_inputs = resources.get("cine_info3_audio_inputs")
if not isinstance(video_inputs, list):
video_inputs = []
if not isinstance(audio_inputs, list):
audio_inputs = []
videos = [item.get("video") for item in video_inputs if isinstance(item, dict) and item.get("video") is not None]
if not videos:
raise ValueError("IAMCCS Shotboard Video Editor: connect rendered take videos to IAMCCS_CineInfo3, then connect CineInfo3 cine_linx here.")
components = [_video_components(video) for video in videos]
if str(editor_mode) == "preview_selected_take":
order = [max(1, min(len(components), _safe_int(selected_take, 1)))]
else:
order = _parse_take_order(take_order, len(components))
first = components[order[0] - 1]
first_shape = tuple(first.images.shape[1:3])
first_device = first.images.device
fps = float(override_fps) if str(fps_mode) == "override_fps" else float(first.frame_rate)
edits = _safe_json_loads(clip_edits_json, {})
frame_batches = []
audio_items = []
clip_reports = []
timeline_cursor_frames = 0
for take_index in order:
comp = components[take_index - 1]
if tuple(comp.images.shape[1:3]) != first_shape:
raise ValueError(
"IAMCCS Shotboard Video Editor: all clips must share height and width. "
f"video_1={first_shape}, video_{take_index}={tuple(comp.images.shape[1:3])}"
)
clip_edit = _clip_edit_for_take(edits, take_index)
trim_in = _safe_float(clip_edit.get("trim_in_seconds", global_trim_in_seconds), float(global_trim_in_seconds))
trim_out = _safe_float(clip_edit.get("trim_out_seconds", global_trim_out_seconds), float(global_trim_out_seconds))
images, audio, start, end = _trim_component(comp, fps, trim_in, trim_out)
if images.device != first_device:
images = images.to(first_device)
frame_batches.append(images)
audio_items.append((audio, int(images.shape[0]), fps))
used_frames = int(images.shape[0])
clip_reports.append({
"take_index": take_index,
"timeline_id": _take_timeline_id(take_index),
"audio_lane": _take_audio_lane_name(take_index),
"timeline_start_frame": int(timeline_cursor_frames),
"timeline_end_frame": int(timeline_cursor_frames + used_frames),
"source_frames": int(comp.images.shape[0]),
"used_start_frame": int(start),
"used_end_frame": int(end),
"used_frames": used_frames,
"duration_seconds": used_frames / max(1.0, fps),
"has_clip_audio": audio is not None,
})
timeline_cursor_frames += used_frames
frames = torch.cat(frame_batches, dim=0)
frame_rate = Fraction(round(max(1.0, fps) * 1000), 1000)
editor_audio_tracks = [item.get("audio") for item in audio_inputs if isinstance(item, dict) and item.get("audio") is not None]
audio = None
if str(audio_policy) == "use_master_audio":
audio = master_audio
elif str(audio_policy) == "first_selected_audio":
audio = audio_items[0][0]
elif str(audio_policy) == "concat_clip_audio":
audio = _concat_audio(audio_items)
elif str(audio_policy) == "mix_editor_audio_tracks":
audio = _mix_editor_audio_tracks(editor_audio_tracks)
elif str(audio_policy) == "concat_editor_audio_tracks":
audio = _concat_audio([(track, int(round(_audio_duration_seconds(track) * fps)), fps) for track in editor_audio_tracks])
video = InputImpl.VideoFromComponents(Types.VideoComponents(images=frames, audio=audio, frame_rate=frame_rate))
concat_plan = _safe_json_loads(concat_plan_json, {})
if not concat_plan and isinstance(resources.get("cine_info3_concat_plan"), dict):
concat_plan = resources.get("cine_info3_concat_plan")
editor_manifest = _safe_json_loads(editor_manifest_json, {})
if not editor_manifest and isinstance(resources.get("cine_info3_video_manifest"), list):
editor_manifest = {
"video_manifest": resources.get("cine_info3_video_manifest"),
"audio_manifest": resources.get("cine_info3_audio_manifest") if isinstance(resources.get("cine_info3_audio_manifest"), list) else [],
}
out_linx = _clone_linx(cine_linx, "iamccs_video_editor")
resources = _resources(out_linx)
editor_plan = {
"schema": "iamccs.shotboard.video_editor_plan",
"schema_version": 3,
"source": "IAMCCS_ShotboardVideoEditor",
"editor_mode": str(editor_mode),
"manual_selected_take": _safe_int(selected_take, 1),
"take_order": order,
"clip_reports": clip_reports,
"concat_plan_takes": len(concat_plan.get("takes", [])) if isinstance(concat_plan, dict) else 0,
"external_editor_manifest": editor_manifest if isinstance(editor_manifest, dict) else {},
"audio_policy": str(audio_policy),
"editor_audio_tracks": len(editor_audio_tracks),
"total_frames": int(frames.shape[0]),
"fps": float(frame_rate),
"duration_seconds": int(frames.shape[0]) / max(1.0, float(frame_rate)),
"truth": "CineInfo3 owns all VIDEO/AUDIO inputs; VideoEditor consumes cine_linx only and assembles selected rendered videos.",
}
resources["cine_video_editor_plan"] = editor_plan
resources["cine_video_editor_plan_json"] = _json_dump(editor_plan)
resources["cine_video_editor_output"] = {
"frames": int(frames.shape[0]),
"fps": float(frame_rate),
"duration_seconds": int(frames.shape[0]) / max(1.0, float(frame_rate)),
"has_audio": audio is not None,
"audio_policy": str(audio_policy),
}
out_linx.setdefault("chain", []).append({
"role": "shotboard_video_editor",
"name": "IAMCCS_ShotboardVideoEditor",
"mode": str(editor_mode),
})
_refresh_linx_index(out_linx)
report = _json_dump({
"node": "IAMCCS_ShotboardVideoEditor",
"editor_mode": str(editor_mode),
"manual_selected_take": _safe_int(selected_take, 1),
"take_order": order,
"clip_count": len(order),
"total_frames": int(frames.shape[0]),
"duration_seconds": int(frames.shape[0]) / max(1.0, float(frame_rate)),
"fps": float(frame_rate),
"audio_policy": str(audio_policy),
"editor_audio_tracks": len(editor_audio_tracks),
"has_audio": audio is not None,
"video_source": "cine_info3_cine_linx",
})
return video, frames, _json_dump(editor_plan), report, out_linx
class IAMCCS_CineInfo3:
"""Collect video/audio edit inputs into cine_linx metadata and transport objects for the Shotboard Video Editor."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mode": (["collect_video_editor_inputs", "inspect", "publish_editor_manifest"], {"default": "collect_video_editor_inputs"}),
"frame_rate": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}),
},
"optional": {
"cine_linx": (SUPERNODE_LINX_TYPE,),
"video_1": ("VIDEO",),
"video_2": ("VIDEO",),
"video_3": ("VIDEO",),
"video_4": ("VIDEO",),
"video_5": ("VIDEO",),
"video_6": ("VIDEO",),
"video_7": ("VIDEO",),
"video_8": ("VIDEO",),
"audio_1": ("AUDIO",),
"audio_2": ("AUDIO",),
"audio_3": ("AUDIO",),
"audio_4": ("AUDIO",),
"audio_5": ("AUDIO",),
"audio_6": ("AUDIO",),
"audio_7": ("AUDIO",),
"audio_8": ("AUDIO",),
"concat_plan_json": ("STRING", {"default": "", "multiline": True}),
"editor_notes": ("STRING", {"default": "", "multiline": True}),
},
}
RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "STRING", "STRING")
RETURN_NAMES = ("cine_linx", "video_manifest_json", "audio_manifest_json", "report")
FUNCTION = "collect"
CATEGORY = "IAMCCS/Cine/Multigeneration"
def collect(
self,
mode,
frame_rate,
cine_linx=None,
video_1=None,
video_2=None,
video_3=None,
video_4=None,
video_5=None,
video_6=None,
video_7=None,
video_8=None,
audio_1=None,
audio_2=None,
audio_3=None,
audio_4=None,
audio_5=None,
audio_6=None,
audio_7=None,
audio_8=None,
concat_plan_json="",
editor_notes="",
):
out_linx = _clone_linx(cine_linx, "iamccs_cine_info3")
video_manifest = []
video_inputs = []
for index, video in enumerate((video_1, video_2, video_3, video_4, video_5, video_6, video_7, video_8), start=1):
if video is None:
continue
comp = _video_components(video)
fps = float(comp.frame_rate or frame_rate)
frames = int(comp.images.shape[0])
video_inputs.append({"slot": index, "timeline_id": _take_timeline_id(index), "audio_lane": _take_audio_lane_name(index), "video": video})
video_manifest.append({
"slot": index,
"timeline_id": _take_timeline_id(index),
"audio_lane": _take_audio_lane_name(index),
"frames": frames,
"fps": fps,
"duration_seconds": frames / max(1.0, fps),
"height": int(comp.images.shape[1]),
"width": int(comp.images.shape[2]),
"has_embedded_audio": comp.audio is not None,
})
audio_manifest = []
audio_inputs = []
for index, audio in enumerate((audio_1, audio_2, audio_3, audio_4, audio_5, audio_6, audio_7, audio_8), start=1):
waveform, sample_rate = _audio_waveform(audio)
if waveform is None:
continue
audio_inputs.append({"slot": index, "audio_lane": _take_audio_lane_name(index), "audio": audio})
audio_manifest.append({
"slot": index,
"audio_lane": _take_audio_lane_name(index),
"sample_rate": int(sample_rate),
"samples": int(waveform.shape[-1]),
"channels": int(waveform.shape[-2]),
"duration_seconds": int(waveform.shape[-1]) / max(1.0, float(sample_rate)),
})
concat_plan = _safe_json_loads(concat_plan_json, {})
resources = _resources(out_linx)
resources["cine_info3_video_manifest"] = video_manifest
resources["cine_info3_audio_manifest"] = audio_manifest
resources["cine_info3_video_inputs"] = video_inputs
resources["cine_info3_audio_inputs"] = audio_inputs
resources["cine_info3_concat_plan"] = concat_plan if isinstance(concat_plan, dict) else {}
resources["cine_info3_editor_notes"] = str(editor_notes or "")
resources["cine_info3_mode"] = str(mode)
out_linx.setdefault("chain", []).append({"role": "cine_info3", "name": "IAMCCS_CineInfo3", "mode": str(mode)})
_refresh_linx_index(out_linx)
report = {
"node": "IAMCCS_CineInfo3",
"mode": str(mode),
"videos": len(video_manifest),
"audios": len(audio_manifest),
"truth": "CineInfo3 owns rendered take VIDEO/AUDIO inputs and transports them through cine_linx for the Video Editor.",
}
return out_linx, _json_dump(video_manifest), _json_dump(audio_manifest), _json_dump(report)
class IAMCCS_VideoHardConcat:
"""Hard-concatenate generated take videos into a final VIDEO object."""
@@ -860,6 +1466,8 @@ class IAMCCS_VideoHardConcat:
NODE_CLASS_MAPPINGS = {
"IAMCCS_MultiTimelineBridge": IAMCCS_MultiTimelineBridge,
"IAMCCS_MultiTimelineTakePicker": IAMCCS_MultiTimelineTakePicker,
"IAMCCS_ShotboardVideoEditor": IAMCCS_ShotboardVideoEditor,
"IAMCCS_CineInfo3": IAMCCS_CineInfo3,
"IAMCCS_VideoHardConcat": IAMCCS_VideoHardConcat,
}
@@ -867,5 +1475,7 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_MultiTimelineBridge": "IAMCCS MultiTimeline Bridge",
"IAMCCS_MultiTimelineTakePicker": "IAMCCS MultiTimeline Take Picker",
"IAMCCS_ShotboardVideoEditor": "IAMCCS Shotboard Video Editor",
"IAMCCS_CineInfo3": "IAMCCS CineInfo3",
"IAMCCS_VideoHardConcat": "IAMCCS Video Hard Concat",
}
File diff suppressed because it is too large Load Diff
+21 -7
View File
@@ -4190,6 +4190,11 @@ class IAMCCS_CineFilmmakerBackend:
peak = float(torch.max(torch.abs(out_waveform)).detach().cpu().item())
if peak > 0.0001:
out_waveform = out_waveform * min(4.0, 0.92 / peak)
if out_waveform.numel():
peak = float(torch.max(torch.abs(out_waveform)).detach().cpu().item())
if peak > 0.985:
out_waveform = torch.tanh(out_waveform) / max(0.0001, math.tanh(peak)) * 0.985
print(f"[IAMCCS FilmmakerBackend] Audio soft peak guard applied peak_in={peak:.6f} peak_out=0.985000")
if any(dsp_stats.values()):
print(f"[IAMCCS FilmmakerBackend] Audio DSP applied muted={dsp_stats['muted']} eq={dsp_stats['eq']} pan={dsp_stats['pan']}")
@@ -4220,17 +4225,16 @@ class IAMCCS_CineFilmmakerBackend:
if strict:
raise RuntimeError("Encoded custom audio latent is empty.")
return {}
mask = torch.full(
mask = torch.zeros(
(1, latent_samples.shape[-2], latent_samples.shape[-1]),
0.0,
dtype=torch.float32,
device=comfy.model_management.intermediate_device(),
device=latent_samples.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])),
"noise_mask": mask,
}
except Exception as exc:
if strict:
@@ -4353,9 +4357,11 @@ class IAMCCS_CineFilmmakerBackend:
max_frames = _safe_int(resources.get("cine_max_frames", outputs.get("max_frames", payload.get("max_frames", 0))), 0)
if max_frames <= 0:
max_frames = _round_ltx_frames(int(round(duration_seconds * max(1, frame_rate))), str(payload.get("ltx_round_mode", "up_8n_plus_1")))
audio_end_frames = self._timeline_end_frames(audio_timeline_json, "audioSegments")
visual_end_frames = self._timeline_end_frames(visual_segments_json, "segments")
timeline_end_frames = max(
self._timeline_end_frames(audio_timeline_json, "audioSegments"),
self._timeline_end_frames(visual_segments_json, "segments"),
audio_end_frames,
visual_end_frames,
)
duration_target_frames = int(round(float(duration_seconds) * max(1, int(frame_rate))))
duration_clamp_applied = False
@@ -4376,8 +4382,16 @@ class IAMCCS_CineFilmmakerBackend:
"[IAMCCS FilmmakerBackend] "
f"DURATION_EFFECTIVE source=cine_linx duration={float(duration_seconds):.3f}s fps={int(frame_rate)} "
f"target_frames={int(duration_target_frames)} max_frames={int(max_frames)} "
f"timeline_end_frames={int(timeline_end_frames)} clamp_applied={bool(duration_clamp_applied)}"
f"timeline_end_frames={int(timeline_end_frames)} audio_end_frames={int(audio_end_frames)} visual_end_frames={int(visual_end_frames)} "
f"clamp_applied={bool(duration_clamp_applied)}"
)
if audio_end_frames > 0:
print(
"[IAMCCS FilmmakerBackend] "
f"AUDIO_DURATION_TRUTH audio_end_frames={int(audio_end_frames)} "
f"audio_duration={float(audio_end_frames) / max(1.0, float(frame_rate)):.3f}s "
f"duration_used={float(duration_seconds):.3f}s max_frames={int(max_frames)}"
)
epsilon = _safe_float(resources.get("cine_promptrelay_epsilon", outputs.get("promptrelay_epsilon", payload.get("promptrelay_epsilon", 0.001))), 0.001)
latent = optional_latent if isinstance(optional_latent, dict) else self._empty_latent(width, height, max_frames)
+544 -10
View File
@@ -1,3 +1,4 @@
import asyncio
import copy
import hashlib
import json
@@ -11,6 +12,11 @@ from PIL import Image, ImageDraw, ImageFilter, ImageOps
import folder_paths
import nodes as comfy_nodes
_GEMMA_ASSIST_DEFAULT_MODEL = "text_encoders\\gemma4_e4b_it_fp8_scaled.safetensors"
_GEMMA_ASSIST_CLIP_CACHE: Dict[Tuple[str, str], Any] = {}
_GEMMA_ASSIST_RUNNING = False
_GEMMA_ASSIST_ABORT_REQUESTED = False
# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
@@ -110,6 +116,18 @@ def _default_design() -> Dict[str, Any]:
"brief": "",
"instruction": "Enhance the current Ideogram JSON without changing layout, bbox coordinates, visible text, or panel count."
},
"gemma_assistant": {
"enabled": false,
"provider": "local_gemma",
"mode": "full_json_enhance",
"speed": "fast",
"model": "text_encoders\\gemma4_e4b_it_fp8_scaled.safetensors",
"selected_id": "",
"target_field": "",
"current_text": "",
"brief": "",
"request_ready": false
},
"mask_paint": {
"brush_size": 48,
"strokes": []
@@ -831,7 +849,7 @@ def _design_with_json_override(design: Dict[str, Any]) -> Tuple[Dict[str, Any],
if payload is None:
return design, None
converted = _from_ideogram_prompt(payload)
for key in ("canvas", "i2i", "reference_mode", "workflow_mode", "preset_key", "json_export_mode", "brief_to_json", "json_override", "mask_paint"):
for key in ("canvas", "i2i", "reference_mode", "workflow_mode", "preset_key", "json_export_mode", "brief_to_json", "gemma_assistant", "json_override", "mask_paint"):
if key in design:
converted[key] = copy.deepcopy(design[key])
return converted, payload
@@ -842,7 +860,7 @@ def _design_from_runtime_source(source_data: Any, fallback_data: Any) -> Dict[st
design = _normalize_design(source_data)
if _is_ideogram_prompt_json(parsed_source):
fallback = _normalize_design(fallback_data)
for key in ("canvas", "i2i", "reference_mode", "workflow_mode", "preset_key", "json_export_mode", "brief_to_json", "mask_paint"):
for key in ("canvas", "i2i", "reference_mode", "workflow_mode", "preset_key", "json_export_mode", "brief_to_json", "gemma_assistant", "mask_paint"):
if key not in parsed_source and key in fallback:
design[key] = copy.deepcopy(fallback[key])
return design
@@ -883,6 +901,7 @@ def _normalize_design(raw: Any) -> Dict[str, Any]:
if merged["json_export_mode"] not in {"json_perfect", "standard"}:
merged["json_export_mode"] = "json_perfect"
merged["brief_to_json"] = _normalize_brief_to_json(data.get("brief_to_json") if isinstance(data.get("brief_to_json"), dict) else base.get("brief_to_json"))
merged["gemma_assistant"] = _normalize_gemma_assistant(data.get("gemma_assistant") if isinstance(data.get("gemma_assistant"), dict) else base.get("gemma_assistant"))
merged["mask_paint"] = _normalize_mask_paint(
data.get("mask_paint") if isinstance(data.get("mask_paint"), dict) else base.get("mask_paint"),
base.get("mask_paint"),
@@ -1116,27 +1135,539 @@ def _normalize_brief_to_json(raw: Any) -> Dict[str, str]:
}
def _normalize_gemma_assistant(raw: Any) -> Dict[str, Any]:
data = raw if isinstance(raw, dict) else {}
mode = _clean_text(data.get("mode") or "full_json_enhance")
if mode not in {"full_json_enhance", "selected_box_enhance", "brief_to_ideoboard", "prompt_critic", "field_enhance"}:
mode = "full_json_enhance"
speed = _clean_text(data.get("speed") or data.get("detail_mode") or "fast").lower()
if speed not in {"fast", "detailed"}:
speed = "fast"
return {
"enabled": bool(data.get("enabled", False)),
"provider": "local_gemma",
"mode": mode,
"speed": speed,
"model": _clean_text(data.get("model")) or _GEMMA_ASSIST_DEFAULT_MODEL,
"selected_id": _clean_text(data.get("selected_id")),
"target_field": _clean_text(data.get("target_field")),
"current_text": _clean_text(data.get("current_text")),
"brief": _clean_text(data.get("brief")),
"request_ready": bool(data.get("request_ready", False)),
}
def _gemma_json_request_for_design(design: Dict[str, Any], prompt_json: str) -> str:
brief = _normalize_brief_to_json(design.get("brief_to_json"))
assistant = _normalize_gemma_assistant(design.get("gemma_assistant"))
mode = assistant["mode"] if assistant.get("request_ready") else "full_json_enhance"
selected_id = assistant.get("selected_id") or ""
direction = assistant.get("brief") or brief["brief"] or brief["instruction"]
selected_item = {}
for item in design.get("items", []) if isinstance(design.get("items"), list) else []:
if _clean_text(item.get("id")) == selected_id:
selected_item = item
break
contract = (
"OUTPUT CONTRACT\n"
"Return one valid JSON object only, no markdown.\n"
)
if mode == "prompt_critic":
contract += (
"Return {\"mode\":\"prompt_critic\",\"notes\":\"concise critique\",\"suggestions\":[\"specific improvement\", \"specific improvement\"]}.\n"
"Do not rewrite the full prompt in critic mode.\n"
)
else:
contract += (
"Return {\"mode\":\"" + mode + "\",\"ideogram_prompt\":{...},\"notes\":\"short reason\"}.\n"
"The ideogram_prompt must preserve the exact schema: high_level_description, style_description, compositional_deconstruction.\n"
"Preserve every element count, type, visible text, and bbox coordinate exactly unless mode is brief_to_ideoboard and the user asks for a new board.\n"
"For selected_box_enhance, improve only the selected element description inside the full returned ideogram_prompt.\n"
)
return (
"You are an Ideogram 4 JSON prompt enhancer. Output JSON only, no markdown, no commentary.\n\n"
"You are an Ideogram 4 JSON prompt enhancer for IAMCCS FrameDesigner. Output JSON only.\n\n"
"TASK\n"
"Improve the supplied Ideogram 4 structured JSON for stronger cinematic visual quality, clearer subject descriptions, "
"better material detail, lighting, camera language, and storyboard continuity.\n\n"
"STRICT RULES\n"
"- Preserve the exact JSON schema: high_level_description, style_description, compositional_deconstruction.\n"
"- Preserve every element count, type, visible text, and bbox coordinate exactly.\n"
"- Use positive concrete visual language.\n"
"- Do not add negative prompts, banned lists, or lists of things to avoid.\n"
"- Bbox order is [ymin, xmin, ymax, xmax] on a 0-1000 grid. Do not convert to x/y order.\n"
"- Do not add negative prompts or lists of things to avoid. Use positive concrete visual language.\n"
"- Do not add new characters, new panels, or extra objects unless the user brief explicitly asks for a replacement inside an existing bbox.\n"
"- Keep image/reference panels described as references if present.\n"
"- Keep color_palette arrays as hex colors when present.\n\n"
f"USER BRIEF OR ENHANCEMENT DIRECTION\n{brief['brief'] or brief['instruction']}\n\n"
"- Keep color_palette arrays as hex colors when present.\n"
"- Keep the result compatible with IAMCCS_IdeoTranslate.\n\n"
f"MODE\n{mode}\n\n"
f"SELECTED ELEMENT ID\n{selected_id or 'none'}\n\n"
"SELECTED ELEMENT JSON\n"
f"{json.dumps(selected_item, ensure_ascii=False, indent=2)}\n\n"
f"USER BRIEF OR ENHANCEMENT DIRECTION\n{direction}\n\n"
f"{contract}\n"
"CURRENT IDEOGRAM JSON\n"
f"{prompt_json}\n"
)
def _gemma_assistant_system_prompt() -> str:
return (
"You are Gemma running as the IAMCCS FrameDesigner assistant for Ideogram 4 structured prompting. "
"Return compact JSON only. No markdown. No commentary outside JSON. "
"Use positive, specific visual language. Do not write negative prompts, banned lists, or 'do not' instructions. "
"Preserve the Ideogram JSON structure when enhancing. Preserve bbox coordinates unless the user explicitly asks to build a new board. "
"Bbox order is [ymin, xmin, ymax, xmax] on a 0-1000 layout grid. "
"Prioritize cinematic clarity, subject identity, physical action, material detail, lighting, lens language, and readable composition. "
"When the user gives a general natural-language brief, infer whether they want a single image, image refinement, or storyboard grid, then produce a complete FrameDesigner ideoboard JSON. "
"When enhancing one field or one selected box, return only the improved text for that target plus brief notes. "
"After the closing JSON brace, immediately end the answer with <end_of_turn>."
)
def _gemma_assistant_user_prompt(
design: Dict[str, Any],
mode: str,
brief: str,
selected_id: str,
target_field: str = "",
current_text: str = "",
) -> str:
normalized = _normalize_design(design)
prompt_json = json.dumps(_to_ideogram_prompt(normalized), ensure_ascii=False, indent=2)
selected_item = None
for item in normalized.get("items", []):
if _clean_text(item.get("id")) == selected_id:
selected_item = item
break
selected_block = json.dumps(selected_item or {}, ensure_ascii=False, indent=2)
field = _clean_text(target_field)
text = _clean_text(current_text)
if mode == "field_enhance":
return (
"ASSISTANT MODE\n"
"field_enhance\n\n"
"TARGET FIELD KEY\n"
f"{field}\n\n"
"CURRENT FIELD TEXT\n"
f"{text}\n\n"
"USER DIRECTION\n"
f"{_clean_text(brief) or text or 'Improve this field for Ideogram 4.'}\n\n"
"CURRENT SELECTED BOX\n"
f"{selected_block}\n\n"
"CURRENT IDEOGRAM PROMPT JSON FOR CONTEXT\n"
f"{prompt_json}\n\n"
"OUTPUT CONTRACT\n"
"{"
"\"mode\":\"field_enhance\","
"\"field_key\":\"same target field key\","
"\"selected_id\":\"selected box id if any\","
"\"text\":\"improved replacement text only\","
"\"notes\":\"short reason\""
"}\n"
"Improve only the target field. Do not rewrite unrelated fields. Use positive concrete visual language and preserve user intent. "
"Keep the replacement text concise. End immediately after the JSON object with <end_of_turn>.\n"
)
return (
"ASSISTANT MODE\n"
f"{mode}\n\n"
"USER DIRECTION\n"
f"{_clean_text(brief) or 'Improve the current board for stronger Ideogram 4 results while preserving user intent.'}\n\n"
"CURRENT SELECTED BOX\n"
f"{selected_block}\n\n"
"TARGET FIELD KEY, IF A FIELD BUTTON WAS USED\n"
f"{field or 'none'}\n\n"
"CURRENT FIELD TEXT, IF A FIELD BUTTON WAS USED\n"
f"{text or 'none'}\n\n"
"CURRENT FRAMEDESIGNER IDEOBOARD JSON\n"
f"{json.dumps(normalized, ensure_ascii=False, indent=2)}\n\n"
"CURRENT IDEOGRAM PROMPT JSON\n"
f"{prompt_json}\n\n"
"OUTPUT CONTRACT\n"
"- For mode selected_box_enhance, output: {\"mode\":\"selected_box_enhance\",\"selected_id\":\"...\",\"desc\":\"improved positive visual description\",\"notes\":\"short reason\"}.\n"
"- For mode prompt_critic, output: {\"mode\":\"prompt_critic\",\"notes\":\"concise critique\",\"suggestions\":[\"specific improvement\", \"specific improvement\"]}.\n"
"- For mode full_json_enhance, output: {\"mode\":\"full_json_enhance\",\"ideogram_prompt\":{...},\"notes\":\"short reason\"}. Preserve all element bbox coordinates and count.\n"
"- For mode brief_to_ideoboard, output: {\"mode\":\"brief_to_ideoboard\",\"ideoboard\":{...},\"notes\":\"short reason\"}. The ideoboard must use schema iamccs.ideogram_storyboard_frame_designer, canvas, scene, items, workflow_mode, grid_key, target_resolution_key.\n"
"Use compact JSON. Avoid whitespace-heavy formatting. End immediately after the JSON object with <end_of_turn>.\n"
)
def _strip_gemma_response_noise(raw: Any) -> str:
text = _clean_text(raw)
if not text:
return ""
for marker in ("<end_of_turn>", "<eos>", "</s>"):
text = text.replace(marker, "")
stripped = text.strip()
if stripped.startswith("```"):
lines = stripped.splitlines()
if lines and lines[0].strip().startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip().startswith("```"):
lines = lines[:-1]
stripped = "\n".join(lines).strip()
return stripped
def _extract_balanced_json_object(raw: str) -> Dict[str, Any]:
text = _strip_gemma_response_noise(raw)
if not text:
return {}
for start in [idx for idx, char in enumerate(text) if char == "{"]:
depth = 0
in_string = False
escape = False
for pos in range(start, len(text)):
char = text[pos]
if in_string:
if escape:
escape = False
elif char == "\\":
escape = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
elif char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
candidate = text[start:pos + 1]
parsed = _safe_json(candidate, {})
if isinstance(parsed, dict):
return parsed
break
return {}
def _extract_json_object(raw: Any) -> Dict[str, Any]:
if isinstance(raw, dict):
return raw
cleaned = _strip_gemma_response_noise(raw)
parsed = _safe_json(cleaned, {})
if isinstance(parsed, dict):
return parsed
parsed = _extract_balanced_json_object(cleaned)
return parsed if isinstance(parsed, dict) else {}
def _apply_gemma_assistant_response(design: Dict[str, Any], response_payload: Dict[str, Any], selected_id: str, mode: str) -> Tuple[Dict[str, Any], str]:
current = _normalize_design(design)
payload = response_payload if isinstance(response_payload, dict) else {}
notes = _clean_text(payload.get("notes") or payload.get("report"))
response_mode = _clean_text(payload.get("mode") or mode)
if response_mode == "prompt_critic":
suggestions = payload.get("suggestions")
if isinstance(suggestions, list) and suggestions:
notes = (notes + " | " if notes else "") + " | ".join(_clean_text(x) for x in suggestions if _clean_text(x))
return current, notes or "Gemma critic returned notes only."
if response_mode == "field_enhance":
return current, notes or "Gemma returned a field patch."
if response_mode == "selected_box_enhance" or payload.get("desc"):
target_id = _clean_text(payload.get("selected_id") or selected_id)
desc = _clean_text(payload.get("desc") or payload.get("description"))
if desc:
for item in current.get("items", []):
if _clean_text(item.get("id")) == target_id:
item["desc"] = desc
if item.get("kind") == "text" and payload.get("text") is not None:
item["text"] = _clean_text(payload.get("text"))
return current, notes or f"Enhanced selected box {target_id}."
return current, notes or "Gemma returned no selected-box description to apply."
ideoboard = payload.get("ideoboard") or payload.get("design_data") or payload.get("board")
if isinstance(ideoboard, dict):
next_design = _normalize_design(ideoboard)
return next_design, notes or "Gemma returned a complete ideoboard."
ideogram_prompt = payload.get("ideogram_prompt") or payload.get("prompt_json") or payload.get("prompt")
if isinstance(ideogram_prompt, dict) and _is_ideogram_prompt_json(ideogram_prompt):
converted = _from_ideogram_prompt(ideogram_prompt)
for key in ("canvas", "i2i", "reference_mode", "workflow_mode", "preset_key", "json_export_mode", "gemma_assistant", "mask_paint"):
if key in current:
converted[key] = copy.deepcopy(current[key])
return _normalize_design(converted), notes or "Gemma returned enhanced Ideogram prompt JSON."
if _is_ideogram_prompt_json(payload):
converted = _from_ideogram_prompt(payload)
for key in ("canvas", "i2i", "reference_mode", "workflow_mode", "preset_key", "json_export_mode", "gemma_assistant", "mask_paint"):
if key in current:
converted[key] = copy.deepcopy(current[key])
return _normalize_design(converted), notes or "Gemma returned raw Ideogram prompt JSON."
return current, notes or "Gemma response was valid JSON but did not contain an applicable ideoboard, prompt, or box patch."
def _normalize_gemma_model_name(value: Any) -> str:
model = _clean_text(value) or _GEMMA_ASSIST_DEFAULT_MODEL
return model.replace("/", "\\")
def _gemma_model_candidates(value: Any) -> List[str]:
model = _normalize_gemma_model_name(value)
candidates = [model]
lower = model.lower()
if lower.startswith("text_encoders\\"):
candidates.append(model.split("\\", 1)[1])
else:
candidates.append(f"text_encoders\\{model}")
out: List[str] = []
for candidate in candidates:
if candidate and candidate not in out:
out.append(candidate)
return out
def _list_gemma_assist_models() -> List[str]:
try:
names = folder_paths.get_filename_list("text_encoders")
except Exception:
names = []
cleaned: List[str] = []
for name in names:
text = _clean_text(name).replace("/", "\\")
if text and text not in cleaned:
cleaned.append(text)
preferred = [name for name in cleaned if "gemma" in name.lower()]
others = [name for name in cleaned if name not in preferred]
return preferred + others
def _load_gemma_assist_clip(model: str, device: str = "default") -> Tuple[Any, str]:
last_error: Exception | None = None
for candidate in _gemma_model_candidates(model):
key = (candidate, device)
if key in _GEMMA_ASSIST_CLIP_CACHE:
return _GEMMA_ASSIST_CLIP_CACHE[key], candidate
try:
clip = comfy_nodes.CLIPLoader().load_clip(candidate, "ideogram4", device)[0]
_GEMMA_ASSIST_CLIP_CACHE[key] = clip
return clip, candidate
except Exception as exc:
last_error = exc
raise RuntimeError(f"Could not load Gemma text encoder '{model}': {last_error}")
def _extract_node_output_text(output: Any) -> str:
try:
result = getattr(output, "result", None)
if isinstance(result, (list, tuple)) and result:
return _clean_text(result[0])
except Exception:
pass
try:
return _clean_text(output[0])
except Exception:
pass
if isinstance(output, (list, tuple)) and output:
return _clean_text(output[0])
return _clean_text(output)
def _set_gemma_interrupt(value: bool) -> None:
try:
import comfy.model_management as model_management
model_management.interrupt_current_processing(bool(value))
except Exception:
pass
def _gemma_token_budget(mode: str, speed: str, requested: Any = None) -> int:
try:
explicit = int(requested)
except Exception:
explicit = 0
if explicit > 0:
return max(64, min(1400, explicit))
speed_key = _clean_text(speed).lower()
if speed_key not in {"fast", "detailed"}:
speed_key = "fast"
budgets = {
"field_enhance": {"fast": 128, "detailed": 220},
"selected_box_enhance": {"fast": 160, "detailed": 280},
"prompt_critic": {"fast": 180, "detailed": 300},
"full_json_enhance": {"fast": 420, "detailed": 720},
"brief_to_ideoboard": {"fast": 560, "detailed": 900},
}
return budgets.get(mode, budgets["full_json_enhance"])[speed_key]
def _call_local_gemma_generate(model: str, system: str, prompt: str, max_length: int = 1400) -> Tuple[str, str]:
from comfy_extras.nodes_textgen import TextGenerate
from server import PromptServer
if _GEMMA_ASSIST_ABORT_REQUESTED:
raise RuntimeError("Gemma assistant was stopped before generation started.")
clip, loaded_model = _load_gemma_assist_clip(model)
formatted_prompt = (
f"<start_of_turn>system\n{system.strip()}<end_of_turn>\n"
f"<start_of_turn>user\n{prompt.strip()}<end_of_turn>\n"
"<start_of_turn>model\n"
)
server_instance = getattr(PromptServer, "instance", None)
old_prompt_id = getattr(server_instance, "last_prompt_id", None) if server_instance is not None else None
old_node_id = getattr(server_instance, "last_node_id", None) if server_instance is not None else None
had_prompt_id = hasattr(server_instance, "last_prompt_id") if server_instance is not None else False
had_node_id = hasattr(server_instance, "last_node_id") if server_instance is not None else False
if server_instance is not None:
server_instance.last_prompt_id = "iamccs_gemma_assistant"
server_instance.last_node_id = "iamccs_framedesigner_gemma"
try:
_set_gemma_interrupt(False)
output = TextGenerate.execute(
clip=clip,
prompt=formatted_prompt,
max_length=max(64, min(1400, int(max_length or 1400))),
sampling_mode={"sampling_mode": "off"},
thinking=False,
use_default_template=False,
)
finally:
if server_instance is not None:
if had_prompt_id:
server_instance.last_prompt_id = old_prompt_id
else:
try:
delattr(server_instance, "last_prompt_id")
except Exception:
pass
if had_node_id:
server_instance.last_node_id = old_node_id
else:
try:
delattr(server_instance, "last_node_id")
except Exception:
pass
return _extract_node_output_text(output), loaded_model
def _field_patch_from_gemma_response(response_json: Dict[str, Any], raw_text: str, target_field: str, selected_id: str) -> Dict[str, str]:
payload = response_json if isinstance(response_json, dict) else {}
text = _clean_text(payload.get("text") or payload.get("replacement") or payload.get("desc") or payload.get("description"))
if not text:
text = _strip_gemma_response_noise(raw_text)
if text.startswith("{") and text.endswith("}"):
parsed = _extract_json_object(text)
text = _clean_text(parsed.get("text") or parsed.get("replacement") or parsed.get("desc") or parsed.get("description"))
return {
"field_key": _clean_text(payload.get("field_key") or target_field),
"selected_id": _clean_text(payload.get("selected_id") or selected_id),
"text": text,
}
def _register_framedesigner_gemma_route() -> None:
try:
from aiohttp import web
from server import PromptServer
except Exception:
return
instance = getattr(PromptServer, "instance", None)
routes = getattr(instance, "routes", None)
if routes is None or getattr(instance, "_iamccs_framedesigner_gemma_route", False):
return
@routes.get("/iamccs/framedesigner/gemma_models")
async def iamccs_framedesigner_gemma_models(request):
models = _list_gemma_assist_models()
default_model = _GEMMA_ASSIST_DEFAULT_MODEL
if default_model not in models:
for candidate in _gemma_model_candidates(default_model):
if candidate in models:
default_model = candidate
break
return web.json_response({
"ok": True,
"models": models,
"default": default_model if default_model in models else (models[0] if models else _GEMMA_ASSIST_DEFAULT_MODEL),
})
@routes.post("/iamccs/framedesigner/gemma_abort")
async def iamccs_framedesigner_gemma_abort(request):
global _GEMMA_ASSIST_ABORT_REQUESTED
_GEMMA_ASSIST_ABORT_REQUESTED = True
_set_gemma_interrupt(True)
return web.json_response({
"ok": True,
"running": bool(_GEMMA_ASSIST_RUNNING),
"message": "Gemma assistant stop requested.",
})
@routes.post("/iamccs/framedesigner/gemma_assist")
async def iamccs_framedesigner_gemma_assist(request):
global _GEMMA_ASSIST_ABORT_REQUESTED, _GEMMA_ASSIST_RUNNING
try:
payload = await request.json()
design = payload.get("design_data") if isinstance(payload, dict) else {}
mode = _clean_text(payload.get("mode") if isinstance(payload, dict) else "") or "full_json_enhance"
if mode not in {"full_json_enhance", "selected_box_enhance", "brief_to_ideoboard", "prompt_critic", "field_enhance"}:
mode = "full_json_enhance"
brief = _clean_text(payload.get("brief") if isinstance(payload, dict) else "")
selected_id = _clean_text(payload.get("selected_id") if isinstance(payload, dict) else "")
target_field = _clean_text(payload.get("target_field") if isinstance(payload, dict) else "")
current_text = _clean_text(payload.get("current_text") if isinstance(payload, dict) else "")
model = _normalize_gemma_model_name(payload.get("model") if isinstance(payload, dict) else "")
speed = _clean_text(payload.get("speed") if isinstance(payload, dict) else "") or "fast"
max_tokens = _gemma_token_budget(mode, speed, payload.get("max_tokens") if isinstance(payload, dict) else None)
normalized = _normalize_design(design)
system_prompt = _gemma_assistant_system_prompt()
user_prompt = _gemma_assistant_user_prompt(normalized, mode, brief, selected_id, target_field, current_text)
_GEMMA_ASSIST_ABORT_REQUESTED = False
_GEMMA_ASSIST_RUNNING = True
response_text, loaded_model = await asyncio.to_thread(
_call_local_gemma_generate,
model,
system_prompt,
user_prompt,
max_tokens,
)
if _GEMMA_ASSIST_ABORT_REQUESTED:
return web.json_response({"ok": False, "error": "Gemma assistant was stopped."}, status=409)
response_json = _extract_json_object(response_text)
if mode == "field_enhance":
field_patch = _field_patch_from_gemma_response(response_json, response_text, target_field, selected_id)
return web.json_response({
"ok": True,
"mode": mode,
"selected_id": selected_id,
"target_field": target_field,
"field_patch": field_patch,
"notes": _clean_text(response_json.get("notes")) or "Gemma enhanced the selected field.",
"raw_response": response_json or _strip_gemma_response_noise(response_text),
"raw_text": _strip_gemma_response_noise(response_text),
"model": loaded_model,
"speed": speed,
"max_tokens": max_tokens,
})
next_design, notes = _apply_gemma_assistant_response(normalized, response_json, selected_id, mode)
prompt_json = json.dumps(_to_ideogram_prompt(next_design), ensure_ascii=False, indent=2)
return web.json_response({
"ok": True,
"mode": mode,
"selected_id": selected_id,
"target_field": target_field,
"design_data": next_design,
"prompt_json": prompt_json,
"notes": notes,
"raw_response": response_json or _strip_gemma_response_noise(response_text),
"raw_text": _strip_gemma_response_noise(response_text),
"model": loaded_model,
"speed": speed,
"max_tokens": max_tokens,
})
except Exception as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=500)
finally:
_GEMMA_ASSIST_RUNNING = False
_GEMMA_ASSIST_ABORT_REQUESTED = False
_set_gemma_interrupt(False)
instance._iamccs_framedesigner_gemma_route = True
def _resolve_image_path(path: str) -> str:
clean = _clean_text(path).replace("\\", "/")
if not clean:
@@ -1854,3 +2385,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_IdeoMaskedPixels": "IAMCCS Ideo Masked Pixels",
"IAMCCS_IdeogramJSONPreviewPass": "IAMCCS Ideogram JSON Preview / Pass",
}
_register_framedesigner_gemma_route()
+2 -2
View File
@@ -78,7 +78,7 @@ class IAMCCS_ScailExtends:
"continuity_profile": (
["external_1to1", "iamccs_boundary_ramp", "off"],
{
"default": "iamccs_boundary_ramp",
"default": "off",
"tooltip": "external_1to1 matches the source plugin behavior. boundary_ramp only grades the first seam frames.",
},
),
@@ -99,7 +99,7 @@ class IAMCCS_ScailExtends:
"boundary_strength": (
"FLOAT",
{
"default": 0.75,
"default": 0.0,
"min": 0.0,
"max": 2.0,
"step": 0.05,
+413
View File
@@ -0,0 +1,413 @@
import math
import torch
from PIL import Image
import numpy as np
import comfy.model_management
import comfy.utils
def _to_4n1(value):
return ((max(1, int(value)) - 1) // 4) * 4 + 1
def _image_frames(x):
if x is None:
return 0
try:
return int(x.shape[0])
except Exception:
return 0
def _preview_image_tuple(images, mode):
if images is None or int(images.shape[0]) <= 0:
return None
mode = str(mode or "middle_frame")
if mode == "off":
return None
if mode == "first_frame":
idx = 0
elif mode == "last_frame":
idx = int(images.shape[0]) - 1
else:
idx = int(images.shape[0]) // 2
frame = images[idx].detach().clamp(0.0, 1.0).cpu().numpy()
img = Image.fromarray(np.clip(frame * 255.0, 0, 255).astype(np.uint8))
return ("PNG", img, None)
def _estimate_wananimate_plan(source_frames, first_chunk_length, next_chunk_length, continue_motion_max_frames):
plan = []
total = 0
idx = 0
while total < int(source_frames):
length = first_chunk_length if idx == 0 else next_chunk_length
trim_hint = 0 if idx == 0 else continue_motion_max_frames
add_hint = length if idx == 0 else max(1, length - continue_motion_max_frames)
add_hint = min(add_hint, int(source_frames) - total)
plan.append(f"#{idx + 1}:len{length}/trim~{trim_hint}/add~{add_hint}")
total += add_hint
idx += 1
if idx > 10000:
break
return plan
def _clone_trim_latent(latent, trim_amount):
out = latent.copy()
samples = latent["samples"]
trim_amount = max(0, int(trim_amount))
if trim_amount > 0:
out["samples"] = samples[:, :, trim_amount:]
else:
out["samples"] = samples
return out
class IAMCCS_WanAnimateExtends:
DESCRIPTION = (
"IAMCCS WanAnimate long-video wrapper. It runs WanAnimateToVideo + KSampler + VAE decode "
"in chunks, uses the previous visible tail as continue_motion, trims native latent/image "
"anchors, and can grade only the first boundary frames of each joined chunk."
)
CATEGORY = "IAMCCS/video/WanAnimate"
RETURN_TYPES = ("IMAGE", "INT", "INT", "INT", "STRING")
RETURN_NAMES = (
"images",
"frame_count",
"source_frames",
"trimmed_frames",
"chunk_plan",
)
FUNCTION = "generate"
@classmethod
def INPUT_TYPES(cls):
import comfy.samplers
return {
"required": {
"model": ("MODEL",),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"vae": ("VAE",),
"width": ("INT", {"default": 832, "min": 16, "max": 8192, "step": 16}),
"height": ("INT", {"default": 480, "min": 16, "max": 8192, "step": 16}),
"target_frames": (
"INT",
{
"default": 0,
"min": 0,
"max": 100000,
"step": 1,
"tooltip": "0 = infer from pose/background/face/mask frame count.",
},
),
"first_chunk_length": ("INT", {"default": 81, "min": 5, "max": 1024, "step": 4}),
"next_chunk_length": (
"INT",
{
"default": 77,
"min": 5,
"max": 1024,
"step": 4,
"tooltip": "Matches the common WanAnimate loop math: 77 length with 5 continue frames contributes 72 visible frames.",
},
),
"continue_motion_max_frames": ("INT", {"default": 5, "min": 1, "max": 129, "step": 4}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 64, "step": 1}),
"seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 0xffffffffffffffff,
"control_after_generate": True,
},
),
"seed_mode": (["fixed", "increment"], {"default": "fixed"}),
"steps": ("INT", {"default": 6, "min": 1, "max": 10000, "step": 1}),
"cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 100.0, "step": 0.1}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "euler"}),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "simple"}),
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"continuity_profile": (
["off", "iamccs_boundary_ramp", "external_1to1"],
{
"default": "iamccs_boundary_ramp",
"tooltip": "boundary_ramp only color matches the first frames after a chunk seam.",
},
),
"color_method": (["reinhard_lab", "mkl_lab", "histogram"], {"default": "reinhard_lab"}),
"boundary_frames": ("INT", {"default": 8, "min": 1, "max": 96, "step": 1}),
"boundary_strength": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.05}),
"live_chunk_preview": (
"STRING",
{
"default": "middle_frame",
"tooltip": "off, first_frame, middle_frame, last_frame. Stale numeric values are treated as middle_frame.",
},
),
"empty_cache_each_chunk": ("BOOLEAN", {"default": False}),
},
"optional": {
"clip_vision_output": ("CLIP_VISION_OUTPUT",),
"reference_image": ("IMAGE",),
"face_video": ("IMAGE",),
"pose_video": ("IMAGE",),
"background_video": ("IMAGE",),
"character_mask": ("MASK",),
},
}
def _match_external(self, color_node, contrib, ref_frame, method):
return color_node.execute(
image_target=contrib,
image_ref=ref_frame,
method=method,
source_stats={"source_stats": "per_frame"},
strength=1.0,
).args[0]
def _match_boundary(self, color_node, contrib, ref_frame, method, boundary_frames, strength):
if strength <= 0.0 or contrib.shape[0] == 0:
return contrib
n = min(int(boundary_frames), int(contrib.shape[0]))
head = contrib[:n]
matched = color_node.execute(
image_target=head,
image_ref=ref_frame,
method=method,
source_stats={"source_stats": "per_frame"},
strength=1.0,
).args[0]
if n == 1:
weights = torch.ones((1, 1, 1, 1), device=head.device, dtype=head.dtype) * strength
else:
weights = torch.linspace(strength, 0.0, n, device=head.device, dtype=head.dtype).view(n, 1, 1, 1)
corrected = torch.lerp(head, matched.to(device=head.device, dtype=head.dtype), weights.clamp(0.0, 1.0))
if n == contrib.shape[0]:
return corrected
return torch.cat([corrected, contrib[n:]], dim=0)
def generate(
self,
model,
positive,
negative,
vae,
width,
height,
target_frames,
first_chunk_length,
next_chunk_length,
continue_motion_max_frames,
batch_size,
seed,
seed_mode,
steps,
cfg,
sampler_name,
scheduler,
denoise,
continuity_profile,
color_method,
boundary_frames,
boundary_strength,
live_chunk_preview,
empty_cache_each_chunk,
clip_vision_output=None,
reference_image=None,
face_video=None,
pose_video=None,
background_video=None,
character_mask=None,
):
import nodes
from comfy_extras.nodes_post_processing import ColorTransfer
from comfy_extras.nodes_wan import WanAnimateToVideo
first_chunk_length = _to_4n1(first_chunk_length)
next_chunk_length = _to_4n1(next_chunk_length)
continue_motion_max_frames = _to_4n1(continue_motion_max_frames)
inferred = max(
_image_frames(pose_video),
_image_frames(background_video),
_image_frames(face_video),
_image_frames(character_mask),
)
source_frames = int(target_frames) if int(target_frames) > 0 else inferred
if source_frames <= 0:
source_frames = first_chunk_length
if width % 16 != 0 or height % 16 != 0:
raise ValueError(f"IAMCCS_WanAnimateExtends: width/height must be divisible by 16, got {width}x{height}.")
sampler = nodes.KSampler()
decoder = nodes.VAEDecode()
chunks = []
prev_motion = None
prev_color_ref = None
offset = 0
chunk_index = 0
plan = []
estimated_plan = _estimate_wananimate_plan(
source_frames,
first_chunk_length,
next_chunk_length,
continue_motion_max_frames,
)
estimated_chunks = len(estimated_plan)
print(
f"[IAMCCS_WanAnimateExtends] PLAN source_frames={source_frames} "
f"first_chunk_length={first_chunk_length} next_chunk_length={next_chunk_length} "
f"continue_motion_max_frames={continue_motion_max_frames} estimated_chunks={estimated_chunks} "
f"plan={' | '.join(estimated_plan)}",
flush=True,
)
max_chunks = max(estimated_chunks + 4, math.ceil(source_frames / max(1, next_chunk_length - continue_motion_max_frames)) + 4)
pbar = comfy.utils.ProgressBar(max_chunks)
while sum(int(c.shape[0]) for c in chunks) < source_frames:
comfy.model_management.throw_exception_if_processing_interrupted()
length = first_chunk_length if chunk_index == 0 else next_chunk_length
this_seed = int(seed) + chunk_index if seed_mode == "increment" else int(seed)
cond = WanAnimateToVideo.execute(
positive=positive,
negative=negative,
vae=vae,
width=int(width),
height=int(height),
length=int(length),
batch_size=int(batch_size),
continue_motion_max_frames=int(continue_motion_max_frames),
video_frame_offset=int(offset),
reference_image=reference_image,
clip_vision_output=clip_vision_output,
face_video=face_video,
pose_video=pose_video,
continue_motion=prev_motion,
background_video=background_video,
character_mask=character_mask,
)
pos_c, neg_c, latent, trim_latent, trim_image, offset = cond.args
sampled = sampler.sample(
model=model,
seed=this_seed,
steps=int(steps),
cfg=float(cfg),
sampler_name=sampler_name,
scheduler=scheduler,
positive=pos_c,
negative=neg_c,
latent_image=latent,
denoise=float(denoise),
)[0]
sampled = _clone_trim_latent(sampled, int(trim_latent))
images = decoder.decode(vae=vae, samples=sampled)[0]
if images.ndim == 5:
images = images.reshape(-1, *images.shape[-3:])
trim_image = max(0, int(trim_image))
contrib = images[trim_image:] if trim_image > 0 else images
if contrib.shape[0] == 0:
raise RuntimeError(
f"IAMCCS_WanAnimateExtends: chunk {chunk_index + 1} produced no visible frames "
f"(length={length}, trim_image={trim_image})."
)
if chunk_index > 0 and continuity_profile != "off" and prev_color_ref is not None:
if continuity_profile == "external_1to1":
contrib = self._match_external(ColorTransfer, contrib, prev_color_ref, color_method)
else:
contrib = self._match_boundary(
ColorTransfer,
contrib,
prev_color_ref,
color_method,
boundary_frames,
boundary_strength,
)
chunks.append(contrib)
visible_so_far = sum(int(c.shape[0]) for c in chunks)
prev_motion = torch.cat(chunks, dim=0)[-continue_motion_max_frames:].detach()
prev_color_ref = contrib[-1:].detach()
plan.append(f"{length}/trimL{int(trim_latent)}/trimI{trim_image}/add{int(contrib.shape[0])}/off{int(offset)}")
print(
f"[IAMCCS_WanAnimateExtends] chunk {chunk_index + 1}: "
f"length={length} seed={this_seed} trim_latent={int(trim_latent)} "
f"trim_image={trim_image} contributed={int(contrib.shape[0])} "
f"visible={visible_so_far}/{source_frames} offset={int(offset)}"
)
pbar.update_absolute(chunk_index + 1, max_chunks, _preview_image_tuple(contrib, live_chunk_preview))
chunk_index += 1
if empty_cache_each_chunk:
comfy.model_management.soft_empty_cache()
if chunk_index > max_chunks:
raise RuntimeError("IAMCCS_WanAnimateExtends: safety stop hit while chunking.")
images = torch.cat([c.to(device=chunks[0].device, dtype=chunks[0].dtype) for c in chunks], dim=0)
if images.shape[0] > source_frames:
images = images[:source_frames]
trimmed_frames = max(0, sum(int(c.shape[0]) for c in chunks) - int(images.shape[0]))
return (
images,
int(images.shape[0]),
int(source_frames),
int(trimmed_frames),
" | ".join(plan),
)
class IAMCCS_WanAnimateExtendPlan:
DESCRIPTION = "Plan IAMCCS WanAnimate chunk contribution without sampling."
CATEGORY = "IAMCCS/video/WanAnimate"
RETURN_TYPES = ("INT", "INT", "STRING")
RETURN_NAMES = ("source_frames", "estimated_chunks", "chunk_plan_hint")
FUNCTION = "plan"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"source_frames": ("INT", {"default": 161, "min": 1, "max": 100000, "step": 1}),
"first_chunk_length": ("INT", {"default": 81, "min": 5, "max": 1024, "step": 4}),
"next_chunk_length": ("INT", {"default": 77, "min": 5, "max": 1024, "step": 4}),
"continue_motion_max_frames": ("INT", {"default": 5, "min": 1, "max": 129, "step": 4}),
}
}
def plan(self, source_frames, first_chunk_length, next_chunk_length, continue_motion_max_frames):
first_chunk_length = _to_4n1(first_chunk_length)
next_chunk_length = _to_4n1(next_chunk_length)
continue_motion_max_frames = _to_4n1(continue_motion_max_frames)
contributed = min(source_frames, first_chunk_length)
chunks = [f"{first_chunk_length}->+{contributed}"]
while contributed < source_frames:
add = max(1, next_chunk_length - continue_motion_max_frames)
contributed += add
chunks.append(f"{next_chunk_length}-trim{continue_motion_max_frames}->+{add}")
return (int(source_frames), len(chunks), " | ".join(chunks))
NODE_CLASS_MAPPINGS = {
"IAMCCS_WanAnimateExtends": IAMCCS_WanAnimateExtends,
"IAMCCS_WanAnimateExtendPlan": IAMCCS_WanAnimateExtendPlan,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_WanAnimateExtends": "IAMCCS WanAnimate Extends",
"IAMCCS_WanAnimateExtendPlan": "IAMCCS WanAnimate Extend Plan",
}
+358 -31
View File
@@ -996,6 +996,14 @@ function ensureAudioBoardArrangerStyles() {
white-space: nowrap;
}
.iamccs-audio-board button:hover { border-color: rgba(244, 212, 158, .65); }
.iamccs-audio-board button:active,
.iamccs-audio-board button[data-iamccs-audio-pressed="true"] {
color: #120D08;
background: linear-gradient(180deg,#FFE08A 0%,#E08B3E 100%);
border-color: #FFE08A;
box-shadow: inset 0 3px 8px rgba(7,12,17,.75),0 0 0 2px rgba(255,224,138,.42),0 0 16px rgba(255,162,84,.28);
filter: brightness(1.18) saturate(1.16);
}
.iamccs-audio-board button.is-active {
color: #101315;
background: linear-gradient(180deg, #f2d79a, #c79e59);
@@ -1193,7 +1201,9 @@ function ensureAudioBoardArrangerStyles() {
background: #fafff3;
box-shadow: 0 0 0 1px rgba(255,255,255,.18), inset 0 0 0 1px rgba(15,102,24,.18);
font: 950 9px/1 "Courier New", ui-monospace, Consolas, monospace;
pointer-events: none;
pointer-events: auto;
cursor: ew-resize;
user-select: none;
}
.iamccs-loop-marker.out {
color: #9b5b00;
@@ -2147,8 +2157,73 @@ function iamccsAudioBoardFixedSizeFromDom(root, rawValue) {
];
}
function installAudioLowZoomOverlay(node, key, buildLines) {
if (node && key) node[key] = true;
return;
if (!node || node[key]) return;
const previous = node.onDrawForeground;
node.onDrawForeground = function(ctx) {
if (typeof previous === "function") previous.apply(this, arguments);
const scale = Math.max(0.12, Number(app?.canvas?.ds?.scale || 1));
if (!ctx || scale >= 0.62) return;
let lines = [];
try { lines = buildLines?.(this) || []; } catch { lines = []; }
lines = lines.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4);
if (!lines.length) return;
const nodeW = Math.max(340, Number(this.size?.[0] || 420));
const nodeH = Math.max(180, Number(this.size?.[1] || 240));
const boost = Math.max(1.2, Math.min(3.4, 0.72 / scale));
const pad = 12 * boost;
const lineH = 18 * boost;
const titleFont = Math.round(13 * boost);
const bodyFont = Math.round(11 * boost);
const w = Math.max(240, Math.min(nodeW - pad * 2, 700 * boost));
const h = 34 * boost + lines.length * lineH;
const x = pad;
const y = Math.min(Math.max(56, 46 * boost), Math.max(40, nodeH - h - pad));
ctx.save();
ctx.globalAlpha = 0.96;
ctx.fillStyle = "rgba(7,17,18,.93)";
ctx.strokeStyle = "rgba(143,208,204,.72)";
ctx.lineWidth = Math.max(1.5, 1.2 * boost);
if (typeof ctx.roundRect === "function") {
ctx.beginPath();
ctx.roundRect(x, y, w, h, 8 * boost);
ctx.fill();
ctx.stroke();
} else {
ctx.fillRect(x, y, w, h);
ctx.strokeRect(x, y, w, h);
}
ctx.fillStyle = "rgba(239,204,139,.95)";
ctx.fillRect(x, y, Math.max(4, 3 * boost), h);
ctx.fillStyle = "#F4D49E";
ctx.font = `900 ${titleFont}px sans-serif`;
ctx.fillText(lines[0], x + 12 * boost, y + 21 * boost);
ctx.fillStyle = "#BFD7D5";
ctx.font = `800 ${bodyFont}px sans-serif`;
for (let i = 1; i < lines.length; i += 1) ctx.fillText(lines[i], x + 12 * boost, y + 21 * boost + i * lineH);
ctx.restore();
};
node[key] = true;
}
function renderAudioBoardArranger(node) {
ensureAudioBoardArrangerStyles();
installAudioLowZoomOverlay(node, "_iamccsAudioBoardLowZoomOverlay", () => {
let data = {};
try { data = JSON.parse(String(findWidget(node, "arranger_data")?.value || "{}")); } catch {}
const clips = Array.isArray(data.audioSegments) ? data.audioSegments.length : 0;
const tracks = Math.max(1, Number(data.audioTrackCount || 1));
const duration = Math.max(0, Number(data.duration_seconds || 0));
const published = Array.isArray(data.shotboardAudioSegments) ? data.shotboardAudioSegments.length : 0;
return [
"AudioBoard mini view",
`${clips} clips / ${tracks} tracks / ${duration.toFixed(2)}s`,
`published to Shotboard: ${published} clips`,
"Zoom in or open editor for DAW controls",
];
});
if (node._iamccsAudioBoardReady) {
const runtimeWidget = findWidget(node, "arranger_data");
let runtimeValue = String(runtimeWidget?.value || "").trim();
@@ -2416,6 +2491,7 @@ function renderAudioBoardArranger(node) {
const hundredths = Math.floor((total - Math.floor(total)) * 100);
return `${minutes}:${String(seconds).padStart(2, "0")}.${String(hundredths).padStart(2, "0")}`;
};
const secondsClock = (seconds) => fmtTime(secondsToFrames(seconds));
const closeContextMenu = () => {
contextMenuEl.style.display = "none";
contextMenuEl.innerHTML = "";
@@ -2451,6 +2527,7 @@ function renderAudioBoardArranger(node) {
audioBusMode: "all_tracks",
onlyFirstTrack: false,
loopEnabled: false,
loopPublishEnabled: false,
loopInFrame: 0,
loopOutFrame: 0,
selectedMixer: { type: "track", track: 0 },
@@ -2516,6 +2593,7 @@ function renderAudioBoardArranger(node) {
out.loopEnabled = Boolean(out.loopEnabled);
out.loopInFrame = Math.max(0, Math.round(Number(out.loopInFrame || 0)));
out.loopOutFrame = Math.max(0, Math.round(Number(out.loopOutFrame || 0)));
out.loopPublishEnabled = Boolean(out.loopPublishEnabled);
out.selectedMixer = out.selectedMixer && typeof out.selectedMixer === "object" ? out.selectedMixer : fallback.selectedMixer;
out.selectedMixer.track = Math.max(0, Math.min(out.audioTrackCount - 1, Number(out.selectedMixer.track || 0)));
out.showEventMonitor = Boolean(out.showEventMonitor);
@@ -3088,6 +3166,7 @@ function renderAudioBoardArranger(node) {
const setLoopIn = () => {
state.loopInFrame = Math.max(0, Math.min(totalFrames() - 1, Math.round(Number(transport.playhead || 0))));
if (!state.loopOutFrame || state.loopOutFrame <= state.loopInFrame) state.loopOutFrame = Math.min(totalFrames(), state.loopInFrame + secondsToFrames(2));
state.loopPublishEnabled = true;
addEdit(`Loop IN set at ${fmtTime(state.loopInFrame)}.`);
writeState("loop_in", false);
draw();
@@ -3095,6 +3174,7 @@ function renderAudioBoardArranger(node) {
const setLoopOut = () => {
state.loopOutFrame = Math.max(Math.round(Number(state.loopInFrame || 0)) + 1, Math.min(totalFrames(), Math.round(Number(transport.playhead || 0))));
if (state.loopOutFrame <= state.loopInFrame) state.loopInFrame = Math.max(0, state.loopOutFrame - secondsToFrames(2));
state.loopPublishEnabled = true;
addEdit(`Loop OUT set at ${fmtTime(state.loopOutFrame)}.`);
writeState("loop_out", false);
draw();
@@ -3106,6 +3186,7 @@ function renderAudioBoardArranger(node) {
state.loopOutFrame = Math.min(totalFrames(), state.loopInFrame + secondsToFrames(4));
}
state.loopEnabled = !state.loopEnabled;
if (loopRange()) state.loopPublishEnabled = true;
addEdit(`Loop ${state.loopEnabled ? "enabled" : "disabled"} ${loopRange() ? `${fmtTime(state.loopInFrame)}-${fmtTime(state.loopOutFrame)}` : ""}.`);
writeState("loop_toggle", false);
draw();
@@ -3197,12 +3278,48 @@ function renderAudioBoardArranger(node) {
return next;
});
};
const activePublishRange = () => {
const total = Math.max(1, totalFrames());
const inFrame = Math.max(0, Math.min(total, Math.round(Number(state.loopInFrame || 0))));
const outFrame = Math.max(0, Math.min(total, Math.round(Number(state.loopOutFrame || 0))));
const explicitlyEnabled = Boolean(state.loopPublishEnabled || state.loopEnabled);
return explicitlyEnabled && outFrame > inFrame
? { enabled: true, inFrame, outFrame, durationFrames: outFrame - inFrame }
: { enabled: false, inFrame: 0, outFrame: 0, durationFrames: 0 };
};
const cropAudioSegmentsToPublishRange = (items, range) => {
const source = Array.isArray(items) ? items : [];
if (!range?.enabled) return JSON.parse(JSON.stringify(source));
const out = [];
for (const raw of source) {
const seg = JSON.parse(JSON.stringify(raw || {}));
const start = Math.max(0, Math.round(Number(seg.start || 0)));
const length = Math.max(1, Math.round(Number(seg.length || seg.audioDurationFrames || 1)));
const end = start + length;
const cutStart = Math.max(start, range.inFrame);
const cutEnd = Math.min(end, range.outFrame);
if (cutEnd <= cutStart) continue;
const trimDelta = cutStart - start;
seg.start = cutStart - range.inFrame;
seg.length = cutEnd - cutStart;
seg.trimStart = Math.max(0, Math.round(Number(seg.trimStart || 0) + trimDelta));
seg.publishRangeSourceStart = start;
seg.publishRangeSourceEnd = end;
seg.publishRangeInFrame = range.inFrame;
seg.publishRangeOutFrame = range.outFrame;
seg.publishRangeApplied = true;
out.push(seg);
}
return out;
};
const isManualPublishReason = (reason) => reason === "manual_publish" || reason === "manual_sync" || String(reason || "").includes("publish");
const syncToShotboard = (reason = "sync", options = {}) => {
const liveSync = isLiveSyncReason(reason);
const renderTarget = options.render !== false && !liveSync;
const callbackTarget = options.callback !== false && !liveSync;
const boards = linkedShotboardNodes();
const promptSource = dialoguePromptSyncSource();
const publishRange = isManualPublishReason(reason) ? activePublishRange() : { enabled: false };
for (const board of boards) {
const widget = findWidget(board, "timeline_data");
if (!widget) continue;
@@ -3244,10 +3361,20 @@ function renderAudioBoardArranger(node) {
}
}
const allAudioSegments = JSON.parse(JSON.stringify(segments()));
const shotboardOnlyFirst = state.audioBusMode === "only_first" || state.onlyFirstTrack;
let effectivePublishRange = publishRange;
let publishSourceAudioSegments = effectivePublishRange.enabled ? cropAudioSegmentsToPublishRange(allAudioSegments, effectivePublishRange) : allAudioSegments;
if (effectivePublishRange.enabled && !publishSourceAudioSegments.length && allAudioSegments.length) {
addEdit("Publish IN/OUT skipped: selected range contains no audio clips. Publishing full AudioBoard instead.");
effectivePublishRange = { enabled: false };
publishSourceAudioSegments = allAudioSegments;
}
const pluriPublishEnabled = Boolean(state.multiGeneration?.pluriPublishEnabled);
const shotboardOnlyFirst = pluriPublishEnabled || state.audioBusMode === "only_first" || state.onlyFirstTrack;
const firstAvailableTrack = Math.min(...publishSourceAudioSegments.map((seg) => Math.max(0, Number(seg.track || 0))).filter((value) => Number.isFinite(value)));
const onlyFirstTrackIndex = Number.isFinite(firstAvailableTrack) ? firstAvailableTrack : 0;
let nextShotboardAudioSegments = shotboardOnlyFirst
? allAudioSegments.filter((seg) => Number(seg.track || 0) === 0)
: allAudioSegments;
? publishSourceAudioSegments.filter((seg) => Number(seg.track || 0) === onlyFirstTrackIndex)
: publishSourceAudioSegments;
const publishCleanup = stripDialogueInjectPlaceholders(nextShotboardAudioSegments, { force: reason === "manual_publish" });
if (publishCleanup.removed > 0) nextShotboardAudioSegments = publishCleanup.segments;
data.audioTrackCount = shotboardOnlyFirst ? 1 : Math.max(1, Number(state.audioTrackCount || 4));
@@ -3262,7 +3389,44 @@ function renderAudioBoardArranger(node) {
data.audioSyncMode = String(state.audioSyncMode || "timeline_audio");
const arrangerMulti = state.multiGeneration && typeof state.multiGeneration === "object" ? state.multiGeneration : {};
const boardMulti = data.multiGeneration && typeof data.multiGeneration === "object" ? data.multiGeneration : {};
if (arrangerMulti.enabled) {
if (pluriPublishEnabled && publishSourceAudioSegments.length) {
const pluriSegments = publishSourceAudioSegments.map((seg) => {
const sourceTrack = Math.max(0, Math.round(Number(seg.track || 0)));
const take = sourceTrack + 1;
return {
...seg,
id: `${String(seg.id || "aud")}_pluri_t${take}`,
timelineId: `T${String(take).padStart(2, "0")}`,
multiTakeIndex: take,
multiGenerationClip: true,
shotboardPluriPublish: true,
sourceTrackOriginal: sourceTrack,
sourceGlobalStart: Math.max(0, Number(seg.start || 0)),
localStart: 0,
start: 0,
track: 0,
};
});
const timelineIds = Array.from(new Set(pluriSegments.map((seg) => seg.timelineId))).sort();
const activeTake = Math.max(1, Number(boardMulti.activeTake || arrangerMulti.activeTake || 1));
const activeTimelineId = `T${String(activeTake).padStart(2, "0")}`;
data.multiGeneration = {
...boardMulti,
...arrangerMulti,
enabled: true,
pluriPublishEnabled: true,
audioSegmentsAll: pluriSegments,
audioSegmentsAllSource: allAudioSegments,
timelineIds,
activeTake,
activeTimelineId,
publishRange: effectivePublishRange.enabled ? JSON.parse(JSON.stringify(effectivePublishRange)) : { enabled: false },
shotboardDurationPolicy: "pluri_publish_audio_lanes_to_timelines",
truth: "AudioBoard pluri-publish maps A1->T1, A2->T2, A3->T3... and publishes only the active timeline lane to Shotboard audio.",
};
nextShotboardAudioSegments = shotboardAudioForActiveTake(pluriSegments, activeTake, activeTimelineId);
data.trackSettings = [allTrackSettings[Math.max(0, Number(nextShotboardAudioSegments[0]?.sourceTrackOriginal || 0))] || allTrackSettings[0] || {}];
} else if (arrangerMulti.enabled) {
const clonedMulti = JSON.parse(JSON.stringify(arrangerMulti));
if (clonedMulti.sourceSegment && typeof clonedMulti.sourceSegment === "object") {
const source = clonedMulti.sourceSegment;
@@ -3283,7 +3447,9 @@ function renderAudioBoardArranger(node) {
data.multiGeneration = {
...boardMulti,
...clonedMulti,
audioSegmentsAll: allAudioSegments,
audioSegmentsAll: publishSourceAudioSegments,
audioSegmentsAllSource: allAudioSegments,
publishRange: effectivePublishRange.enabled ? JSON.parse(JSON.stringify(effectivePublishRange)) : { enabled: false },
visualTimelines,
activeTake: Math.max(1, Number(boardMulti.activeTake || clonedMulti.activeTake || 1)),
activeTimelineId: String(boardMulti.activeTimelineId || clonedMulti.activeTimelineId || firstTimeline),
@@ -3291,7 +3457,7 @@ function renderAudioBoardArranger(node) {
};
if (shotboardOnlyFirst) {
nextShotboardAudioSegments = shotboardAudioForActiveTake(
allAudioSegments,
publishSourceAudioSegments,
data.multiGeneration.activeTake,
data.multiGeneration.activeTimelineId
);
@@ -3322,19 +3488,6 @@ function renderAudioBoardArranger(node) {
}
data.use_custom_audio = data.audioSegments.some((seg) => hasMedia(seg) && !seg.mute);
data.audio_data = JSON.stringify({
audioSegments: data.audioSegments,
audioTrackCount: data.audioTrackCount,
use_custom_audio: data.use_custom_audio,
masterAudioGain: data.masterAudioGain,
masterAudioNormalize: data.masterAudioNormalize,
masterBus: data.masterBus,
trackSettings: data.trackSettings,
audioBusMode: data.audioBusMode,
onlyFirstTrack: data.onlyFirstTrack,
audioSyncMode: data.audioSyncMode,
duration_seconds: data.duration_seconds,
});
const syncSignature = JSON.stringify({
audioSegments: data.audioSegments,
audioTrackCount: data.audioTrackCount,
@@ -3367,9 +3520,57 @@ function renderAudioBoardArranger(node) {
: 0;
const visualOverflow = multiDurationSeconds > 0
&& Math.max(visualEndSeconds, visualTimelineDuration) > multiDurationSeconds + 0.05;
const audioEndFrames = Math.max(0, ...data.audioSegments.map((seg) => {
return Math.max(0, Number(seg.start || 0)) + Math.max(1, Number(seg.length || seg.audioDurationFrames || 1));
}));
const publishDurationFrames = effectivePublishRange.enabled ? Math.max(1, Number(effectivePublishRange.durationFrames || 1)) : audioEndFrames;
const publishDurationSeconds = effectivePublishRange.enabled ? publishDurationFrames / fps() : audioEndSeconds;
data.audioPublishRange = effectivePublishRange.enabled ? {
enabled: true,
mode: "in_out",
inFrame: effectivePublishRange.inFrame,
outFrame: effectivePublishRange.outFrame,
durationFrames: publishDurationFrames,
durationSeconds: publishDurationSeconds,
} : {
enabled: false,
mode: "full_audio_from_zero",
inFrame: 0,
outFrame: audioEndFrames,
durationFrames: audioEndFrames,
durationSeconds: audioEndSeconds,
};
const audioDrivenDuration = data.use_custom_audio
&& String(data.audioSyncMode || "timeline_audio") === "timeline_audio"
&& audioEndFrames > 0
&& (reason === "manual_publish" || reason === "manual_sync" || String(reason || "").includes("publish"));
const mediaDuration = Math.max(VIEWPORT_SECONDS, audioEndSeconds, visualEndSeconds, multiDurationSeconds);
const storedBoardDuration = Math.max(0, Number(data.duration_seconds || 0));
if (multiDurationSeconds > 0 && !visualOverflow) {
if (audioDrivenDuration && !arrangerMulti.enabled) {
const visualPromptSegments = Array.isArray(data.segments)
? data.segments.filter((seg) => String(seg?.type || "image").toLowerCase() !== "audio" && !seg?.placeholder)
: [];
if (visualPromptSegments.length === 1) {
const only = visualPromptSegments[0];
const onlyStart = Math.max(0, Number(only.start || only.frame || 0));
if (onlyStart === 0 && Number(only.length || only.len || 1) !== publishDurationFrames) {
only.length = publishDurationFrames;
only.len = publishDurationFrames;
}
}
if (visualPromptSegments.length) {
const lengths = visualPromptSegments.map((seg) => Math.max(1, Math.round(Number(seg.length || seg.len || 1))));
data.director_segment_lengths = lengths.join(",");
data.segment_lengths = data.director_segment_lengths;
}
data.duration_seconds = publishDurationSeconds;
if (boardDurationWidget) {
boardDurationWidget.value = Number(data.duration_seconds.toFixed(3));
if (callbackTarget) {
try { boardDurationWidget.callback?.(boardDurationWidget.value); } catch {}
}
}
} else if (multiDurationSeconds > 0 && !visualOverflow) {
data.duration_seconds = multiDurationSeconds;
if (boardDurationWidget) {
boardDurationWidget.value = Number(multiDurationSeconds.toFixed(3));
@@ -3392,6 +3593,25 @@ function renderAudioBoardArranger(node) {
}
}
}
data.audio_data = JSON.stringify({
audioSegments: data.audioSegments,
audioTrackCount: data.audioTrackCount,
use_custom_audio: data.use_custom_audio,
masterAudioGain: data.masterAudioGain,
masterAudioNormalize: data.masterAudioNormalize,
masterBus: data.masterBus,
trackSettings: data.trackSettings,
audioBusMode: data.audioBusMode,
onlyFirstTrack: data.onlyFirstTrack,
audioSyncMode: data.audioSyncMode,
audioPublishRange: data.audioPublishRange,
duration_seconds: data.duration_seconds,
});
if (audioDrivenDuration) {
if (effectivePublishRange.enabled) addEdit(`Audio IN/OUT published: ${secondsClock(effectivePublishRange.inFrame / fps())} -> ${secondsClock(effectivePublishRange.outFrame / fps())}, Shotboard duration ${data.duration_seconds.toFixed(3)}s.`);
else addEdit(`Audio full fallback published: 0 -> ${secondsClock(audioEndFrames / fps())}, Shotboard duration ${data.duration_seconds.toFixed(3)}s.`);
addEdit(`Audio duration truth published: ${data.duration_seconds.toFixed(3)}s (${publishDurationFrames} frames).`);
}
data.frame_rate = fps();
data.truth_revision = Math.max(Number(data.truth_revision || 0), Number(board.properties?.iamccs_v3_timeline_revision || 0), 0) + 1;
data.truth_updated_at = new Date().toISOString();
@@ -3434,6 +3654,7 @@ function renderAudioBoardArranger(node) {
state.audioBusMode = (state.audioBusMode === "only_first" || state.onlyFirstTrack) ? "only_first" : "all_tracks";
state.onlyFirstTrack = state.audioBusMode === "only_first";
state.loopEnabled = Boolean(state.loopEnabled);
state.loopPublishEnabled = Boolean(state.loopPublishEnabled);
state.loopInFrame = Math.max(0, Math.min(totalFrames(), Math.round(Number(state.loopInFrame || 0))));
state.loopOutFrame = Math.max(0, Math.min(totalFrames(), Math.round(Number(state.loopOutFrame || 0))));
if (state.loopOutFrame && state.loopOutFrame <= state.loopInFrame) state.loopOutFrame = Math.min(totalFrames(), state.loopInFrame + Math.max(1, secondsToFrames(1)));
@@ -3598,9 +3819,20 @@ function renderAudioBoardArranger(node) {
const subfolder = parts.join("/");
return `/view?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(seg.audioUploadType || "input")}&subfolder=${encodeURIComponent(subfolder)}`;
};
const audioSourceKey = (seg) => {
if (!seg) return "";
const file = String(seg.audioFile || "").trim();
if (file) return `file:${seg.audioUploadType || "input"}:${file}`;
const blobUrl = audioUrls.get(seg.id);
if (blobUrl) return `blob:${blobUrl}`;
const b64 = String(seg.audioB64 || "").trim();
if (b64) return `b64:${seg.id || "audio"}:${b64.length}:${b64.slice(0, 96)}`;
return `id:${seg.id || ""}`;
};
const getBuffer = async (seg, resume = true) => {
if (!seg) return null;
if (audioBuffers.has(seg.id)) return audioBuffers.get(seg.id);
const key = audioSourceKey(seg);
if (audioBuffers.has(key)) return audioBuffers.get(key);
const ctx = await ensureAudioContext(resume);
const url = audioUrls.get(seg.id) || audioViewUrl(seg);
if (!url) return null;
@@ -3608,7 +3840,8 @@ function renderAudioBoardArranger(node) {
if (!resp.ok) throw new Error(`audio fetch failed ${resp.status}`);
const arrayBuffer = await resp.arrayBuffer();
const decoded = await ctx.decodeAudioData(arrayBuffer.slice(0));
audioBuffers.set(seg.id, decoded);
audioBuffers.set(key, decoded);
seg._iamccsWaveformDecodedKey = key;
return decoded;
};
const peaksFromBuffer = (decoded, count = 1200) => {
@@ -3642,11 +3875,14 @@ function renderAudioBoardArranger(node) {
return peaks;
};
const ensureSegmentWaveform = (seg) => {
const sourceKey = audioSourceKey(seg);
const hasDecodedPeaks = Array.isArray(seg?.waveformPeaks) && seg.waveformPeaks.length > 8;
if (!seg || !hasMedia(seg) || (seg.waveformReal === true && hasDecodedPeaks) || waveformLoading.has(seg.id)) return;
if (!seg || !hasMedia(seg) || waveformLoading.has(seg.id)) return;
if (seg._iamccsWaveformDecodedKey === sourceKey && seg.waveformReal === true && hasDecodedPeaks) return;
// Regenerable waveform caches may be compacted from workflow drafts. A stale
// waveformReal flag must never prevent decoding the still-valid audio file.
seg.waveformReal = false;
delete seg.waveformPeaks;
waveformLoading.add(seg.id);
getBuffer(seg, false)
.then((buffer) => {
@@ -3654,6 +3890,7 @@ function renderAudioBoardArranger(node) {
seg.audioDurationFrames = Math.max(1, Math.round(buffer.duration * fps()));
seg.waveformPeaks = peaksFromBuffer(buffer, Math.max(900, Math.min(2200, Math.round(buffer.duration * 70))));
seg.waveformReal = true;
seg._iamccsWaveformDecodedKey = sourceKey;
waveformLoading.delete(seg.id);
writeState("waveform_decode", false, { quiet: true });
draw();
@@ -3722,6 +3959,7 @@ function renderAudioBoardArranger(node) {
size: file.size,
waveformPeaks: info.peaks,
waveformReal: true,
_iamccsWaveformDecodedKey: `file:${uploaded.type || "input"}:${uploaded.path}`,
channelCount: Math.max(1, Number(info.channelCount || 1)),
channelMode: Number(info.channelCount || 1) > 1 ? "stereo" : "mono",
purpose: "dialogue_or_music",
@@ -3751,8 +3989,8 @@ function renderAudioBoardArranger(node) {
solo: false,
linkedVisualId: firstVisual ? firstVisual.id : "",
};
audioBuffers.set(seg.id, info.buffer);
audioUrls.set(seg.id, URL.createObjectURL(file));
audioBuffers.set(audioSourceKey(seg), info.buffer);
segments().push(seg);
selectedId = seg.id;
if (!firstVisual) cursor += info.durationFrames;
@@ -4744,7 +4982,7 @@ function renderAudioBoardArranger(node) {
if (trim) trim.textContent = `src +${fmtTime(Number(seg.trimStart || 0))} -> +${fmtTime(srcEnd)}`;
const marker = clip.querySelector(".iamccs-clip-source-marker");
if (marker) marker.textContent = `SRC ${fmtTime(srcEnd)}`;
const waveKey = `${Math.round(Number(seg.trimStart || 0))}:${Math.round(Number(seg.length || 1))}:${Array.isArray(seg.waveformPeaks) ? seg.waveformPeaks.length : 0}:${seg.waveformReal ? 1 : 0}`;
const waveKey = `${audioSourceKey(seg)}:${Math.round(Number(seg.trimStart || 0))}:${Math.round(Number(seg.length || 1))}:${Array.isArray(seg.waveformPeaks) ? seg.waveformPeaks.length : 0}:${seg.waveformReal ? 1 : 0}`;
if (clip.dataset.waveKey !== waveKey) {
clip.dataset.waveKey = waveKey;
clip.querySelectorAll(".iamccs-clip-wave-svg").forEach((el) => el.remove());
@@ -5010,12 +5248,46 @@ function renderAudioBoardArranger(node) {
draw();
event.preventDefault();
});
const addButtonPressPreview = (button, options = {}) => {
if (!button || button._iamccsAudioPressPreview) return button;
button._iamccsAudioPressPreview = true;
const press = () => {
button.dataset.iamccsAudioPressed = "true";
button._iamccsAudioPressBg = button._iamccsAudioPressBg || button.style.background;
button._iamccsAudioPressColor = button._iamccsAudioPressColor || button.style.color;
button._iamccsAudioPressBorder = button._iamccsAudioPressBorder || button.style.borderColor;
button._iamccsAudioPressShadow = button._iamccsAudioPressShadow || button.style.boxShadow;
button._iamccsAudioPressFilter = button._iamccsAudioPressFilter || button.style.filter;
button.style.background = options.pressedBg || "linear-gradient(180deg,#FFE08A 0%,#E08B3E 100%)";
button.style.color = options.pressedColor || "#120D08";
button.style.borderColor = options.pressedBorder || "#FFE08A";
button.style.boxShadow = options.pressedShadow || "inset 0 3px 8px rgba(7,12,17,.75),0 0 0 2px rgba(255,224,138,.42),0 0 16px rgba(255,162,84,.28)";
button.style.filter = "brightness(1.18) saturate(1.16)";
window.setTimeout(() => release(), Number(options.duration || 260));
};
const release = () => {
if (button.dataset.iamccsAudioPressed !== "true") return;
button.dataset.iamccsAudioPressed = "false";
button.style.background = button._iamccsAudioPressBg || button.style.background;
button.style.color = button._iamccsAudioPressColor || button.style.color;
button.style.borderColor = button._iamccsAudioPressBorder || button.style.borderColor;
button.style.boxShadow = button._iamccsAudioPressShadow || button.style.boxShadow || "";
button.style.filter = button._iamccsAudioPressFilter || "";
};
button.addEventListener("pointerdown", press, { capture: true });
button.addEventListener("click", press, { capture: true });
button.addEventListener("pointerup", release, { capture: true });
button.addEventListener("pointercancel", release, { capture: true });
button.addEventListener("mouseleave", release);
return button;
};
const addButton = (parent, label, handler, klass = "") => {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = label;
btn.className = klass;
btn.onclick = handler;
addButtonPressPreview(btn);
parent.appendChild(btn);
return btn;
};
@@ -5264,6 +5536,7 @@ function renderAudioBoardArranger(node) {
let sourceTrack = Number(multi.sourceTrack || 0);
let destinationStartTrack = Number(multi.destinationStartTrack || 0);
let splitStartMode = String(multi.splitStartMode || "all_zero") === "global_source" ? "global_source" : "all_zero";
let pluriPublishEnabled = Boolean(multi.pluriPublishEnabled);
const chunk = makeMultiSelect("Template", [["10", "10 sec"], ["15", "15 sec"], ["20", "20 sec"], ["25", "25 sec"], ["custom", "custom"]], [10, 15, 20, 25].includes(chunkSeconds) ? String(chunkSeconds) : "custom", (value) => {
if (value !== "custom") customInput.value = value;
chunkSeconds = Math.max(1, Number(customInput.value || value || 20));
@@ -5281,7 +5554,22 @@ function renderAudioBoardArranger(node) {
const takes = makeMultiSelect("Takes", [["auto", "auto"], ["2", "2 takes"], ["3", "3 takes"], ["4", "4 takes"], ["5", "5 takes"]], takeCount, (value) => { takeCount = value; });
const source = makeMultiSelect("Source", Array.from({ length: Math.max(1, Number(state.audioTrackCount || 1)) }, (_, i) => [String(i), `A${i + 1}`]), sourceTrack, (value) => { sourceTrack = Number(value || 0); });
const dest = makeMultiSelect("T1 lane", Array.from({ length: Math.max(1, Number(state.audioTrackCount || 1)) }, (_, i) => [String(i), `A${i + 1}`]), destinationStartTrack, (value) => { destinationStartTrack = Number(value || 0); });
const startMode = makeMultiSelect("Start", [["all_zero", "all T @ 0"], ["global_source", "keep source time"]], splitStartMode, (value) => { splitStartMode = String(value || "all_zero"); });
const startMode = makeMultiSelect("T lanes start", [["all_zero", "all lanes from 0"], ["global_source", "sequential / source time"]], splitStartMode, (value) => { splitStartMode = String(value || "all_zero"); });
startMode.wrap.title = "Choose whether each generated T lane starts at frame 0, or keeps the source/sequential timing.";
const pluri = document.createElement("button");
pluri.type = "button";
pluri.className = pluriPublishEnabled ? "is-primary" : "";
pluri.textContent = "PLURI-PUBLISH";
pluri.title = "When active, Publish maps A1 to Shotboard T1, A2 to T2, A3 to T3...";
pluri.onclick = () => {
state.multiGeneration = state.multiGeneration && typeof state.multiGeneration === "object" ? state.multiGeneration : {};
state.multiGeneration.pluriPublishEnabled = !Boolean(state.multiGeneration.pluriPublishEnabled);
state.multiGeneration.enabled = true;
pluriPublishEnabled = Boolean(state.multiGeneration.pluriPublishEnabled);
addEdit(`MULTI pluri-publish ${pluriPublishEnabled ? "enabled" : "disabled"}.`);
writeState("toggle_multi_pluri_publish", false);
draw();
};
const split = document.createElement("button");
split.type = "button";
split.className = "is-primary";
@@ -5295,7 +5583,7 @@ function renderAudioBoardArranger(node) {
const map = document.createElement("div");
map.className = "iamccs-audio-board-multi-map";
map.textContent = multiGenerationMapText();
strip.append(title, chunk.wrap, customLabel, takes.wrap, source.wrap, dest.wrap, startMode.wrap, split, clear, map);
strip.append(title, chunk.wrap, customLabel, takes.wrap, source.wrap, dest.wrap, startMode.wrap, pluri, split, clear, map);
parent.appendChild(strip);
};
const syncInlineFxControls = (fx) => {
@@ -6372,11 +6660,43 @@ function renderAudioBoardArranger(node) {
rulerTickLabel.style.cssText = `position:absolute;left:${x + 4}px;top:5px;color:#9fb1b8;font-size:9px;font-weight:850;`;
ruler.append(tick, rulerTickLabel);
}
const markerFrameFromEvent = (event) => {
const rect = ruler.getBoundingClientRect();
const scaleX = Math.max(.001, ruler.offsetWidth / Math.max(1, rect.width));
const contentX = Math.max(0, (event.clientX - rect.left) * scaleX);
return xToFrame(contentX);
};
const addLoopMarker = (frame, label, out = false) => {
const marker = document.createElement("div");
marker.className = `iamccs-loop-marker${out ? " out" : ""}`;
marker.style.left = `${frameToX(frame)}px`;
marker.textContent = label;
marker.title = out ? "Drag OUT marker" : "Drag IN marker";
marker.onpointerdown = (event) => {
event.preventDefault();
event.stopPropagation();
state.loopPublishEnabled = true;
marker.setPointerCapture?.(event.pointerId);
const move = (moveEvent) => {
const next = markerFrameFromEvent(moveEvent);
if (out) {
state.loopOutFrame = Math.max(Math.round(Number(state.loopInFrame || 0)) + 1, Math.min(totalFrames(), next));
} else {
state.loopInFrame = Math.max(0, Math.min(Math.max(0, Math.round(Number(state.loopOutFrame || totalFrames())) - 1), next));
}
marker.style.left = `${frameToX(out ? state.loopOutFrame : state.loopInFrame)}px`;
transport.helper = `${out ? "OUT" : "IN"} ${fmtTime(out ? state.loopOutFrame : state.loopInFrame)}`;
moveEvent.preventDefault();
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
writeState(out ? "loop_out_drag" : "loop_in_drag", false);
draw();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up, { once: true });
};
ruler.appendChild(marker);
};
addLoopMarker(Math.max(0, Number(state.loopInFrame || 0)), "IN");
@@ -7793,13 +8113,19 @@ function renderAudioBoardMixer(node) {
}
root.querySelectorAll(".iamccs-mixer-meter i").forEach((fill) => {
const key = fill.dataset.mixerMeter || "";
let peak = Number(fill.dataset.staticPeak || 0);
let peak = 0;
if (playing && key === "master") {
peak = masterState.meterMode === "rms" ? Number(meters.master?.rms || 0) : Number(meters.master?.peak || 0);
if (masterState.postFaderMeter !== false) peak *= Number(liveData.masterAudioGain ?? 1);
}
else if (playing && key !== "") peak = Number(meters.tracks?.[key]?.peak || 0);
fill.style.height = `${Math.max(0, Math.min(100, Math.round(peak * 100)))}%`;
const pct = Math.max(0, Math.min(100, Math.round(peak * 100)));
fill.style.height = `${pct}%`;
fill.style.background = pct >= 86
? "linear-gradient(180deg,#ff4b3f,#f2bd4b 36%,#46e17b)"
: (pct >= 64
? "linear-gradient(180deg,#f2bd4b,#f2bd4b 18%,#46e17b)"
: "linear-gradient(180deg,#46e17b,#1faa58)");
});
mixerMeterRaf = requestAnimationFrame(updateMixerLiveMeters);
}
@@ -8003,7 +8329,8 @@ function renderAudioBoardMixer(node) {
meterFill.dataset.mixerMeter = isMaster ? "master" : String(index);
const displayPeak = isMaster && st.postFaderMeter ? Math.min(1, staticPeak * Number(data.masterAudioGain ?? 1)) : staticPeak;
meterFill.dataset.staticPeak = String(displayPeak);
meterFill.style.height = `${Math.round(displayPeak * 100)}%`;
meterFill.style.height = "0%";
meterFill.style.background = "linear-gradient(180deg,#46e17b,#1faa58)";
meter.appendChild(meterFill);
body.append(scale, fader, meter);
const route = document.createElement("div");
+692
View File
@@ -0,0 +1,692 @@
import { app } from "../../scripts/app.js";
const TARGET_CLASS = "IAMCCS_CineMotionSketch";
const STATE_KEY = "__iamccs_motion_sketch_ui";
const UI_W = 1160;
const UI_H = 760;
function widget(node, name) {
return (node?.widgets || []).find((w) => w?.name === name || w?.label === name) || null;
}
function safeParse(value, fallback = {}) {
if (value && typeof value === "object") return value;
try {
const text = String(value || "").trim();
return text ? JSON.parse(text) : fallback;
} catch {
return fallback;
}
}
function writeWidget(node, name, value) {
const w = widget(node, name);
if (!w) return;
w.value = typeof value === "string" ? value : JSON.stringify(value, null, 2);
try { w.callback?.(w.value); } catch {}
try { app.graph?.setDirtyCanvas?.(true, true); } catch {}
}
function graphNodeById(id) {
try {
return app.graph?._nodes_by_id?.[id] || app.graph?.getNodeById?.(id) || null;
} catch {
return null;
}
}
function linkedSourceNode(node, inputName) {
const input = (node.inputs || []).find((inp) => inp?.name === inputName);
if (!input || input.link == null) return null;
const link = app.graph?.links?.[input.link];
if (!link) return null;
return graphNodeById(link.origin_id);
}
function normalizeSegment(raw, index) {
const start = Math.max(0, Math.round(Number(raw?.start ?? raw?.frame ?? 0) || 0));
const length = Math.max(1, Math.round(Number(raw?.length ?? raw?.frames ?? raw?.len ?? 24) || 24));
const id = String(raw?.id || raw?.segment_id || raw?.slot_id || `shot_${index + 1}`);
const imageFile = String(raw?.imageFile || raw?.image_file || raw?.file || raw?.filename || raw?.path || "");
return {
id,
label: String(raw?.label || raw?.name || raw?.refLabel || raw?.image_label || imageFile || id || `Shot ${index + 1}`),
start,
length,
imageFile,
prompt: String(raw?.prompt || raw?.local_prompt || raw?.localPrompt || ""),
raw: raw && typeof raw === "object" ? raw : {},
};
}
function extractSegmentsFromData(data) {
if (!data || typeof data !== "object") return [];
const candidates = [
data.visual_segments,
data.segments,
data.timeline_segments,
data.rows,
data.timeline?.segments,
data.payload?.visual_segments,
data.resources?.cine_payload?.visual_segments,
data.resources?.cine_payload?.segments,
];
for (const candidate of candidates) {
if (Array.isArray(candidate) && candidate.length) {
return candidate.map(normalizeSegment).sort((a, b) => a.start - b.start);
}
}
return [];
}
function collectShotboardCandidates(source) {
const out = [];
const push = (value) => {
if (value == null) return;
out.push(value);
};
for (const name of ["timeline_data", "timeline_json", "shotboard_data", "board_data"]) {
push(widget(source, name)?.value);
}
for (const w of source?.widgets || []) {
if (typeof w?.value === "string" && /segments|visual_segments|timeline/i.test(w.value)) push(w.value);
}
const props = source?.properties || {};
for (const key of Object.keys(props)) {
if (/timeline|board|segment|payload/i.test(key)) push(props[key]);
}
push(source?.timeline_data);
push(source?.timelineData);
push(source?.shotboard_data);
push(source?.iamccs_timeline_data);
return out;
}
function readLinkedShotboardSegments(node) {
const source = linkedSourceNode(node, "cine_linx");
if (!source) return { segments: [], source: "not connected" };
for (const candidate of collectShotboardCandidates(source)) {
const segments = extractSegmentsFromData(safeParse(candidate, null));
if (segments.length) return { segments, source: `${source.type || source.comfyClass || "node"}:${source.id}` };
}
return { segments: [], source: `${source.type || source.comfyClass || "node"}:${source.id} no timeline found` };
}
function imageUrl(file) {
if (!file) return "";
const raw = String(file).replace(/\\/g, "/").trim();
if (/^(https?:|blob:|data:)/i.test(raw)) return raw;
if (/^[A-Z]:\//i.test(raw)) {
return `/view?filename=${encodeURIComponent(raw.split("/").pop())}&type=input`;
}
return `/view?filename=${encodeURIComponent(raw)}&type=input`;
}
function ensureStyle() {
if (document.getElementById("iamccs-motion-sketch-style")) return;
const style = document.createElement("style");
style.id = "iamccs-motion-sketch-style";
style.textContent = `
.iamccs-motion-sketch{font-family:Inter,Arial,sans-serif;color:#eaf7f5;background:#071011;border:1px solid #2e4948;border-radius:8px;padding:12px;box-sizing:border-box;width:${UI_W}px;min-width:${UI_W}px;height:${UI_H}px;min-height:${UI_H}px;overflow:hidden}
.iamccs-ms-top{display:grid;grid-template-columns:1fr auto;gap:12px;align-items:center;height:42px;margin-bottom:10px}
.iamccs-ms-status{border:1px solid #284241;border-radius:6px;background:#081516;color:#9fe3e8;font:700 11px/1.2 monospace;padding:7px 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.iamccs-ms-actions{display:flex;gap:8px;align-items:center}
.iamccs-ms-btn,.iamccs-ms-select{height:30px;border-radius:6px;border:1px solid #3f6c6c;background:#102b2e;color:#e9ffff;font-weight:800;font-size:11px;padding:0 10px}
.iamccs-ms-btn.on{background:#dcb66e;color:#111;border-color:#ffe3a0;box-shadow:0 0 0 1px rgba(255,232,170,.35) inset}
.iamccs-ms-grid{display:grid;grid-template-columns:1fr 292px;grid-template-rows:174px 1fr;gap:10px;height:682px}
.iamccs-ms-timeline{grid-column:1/3;border:1px solid #263d3c;border-radius:7px;background:#071112;overflow:hidden;display:grid;grid-template-columns:128px 1fr}
.iamccs-ms-legend{border-right:1px solid #284241;background:#0c191a;color:#ffe2a6;font-size:11px;font-weight:900;padding:10px}
.iamccs-ms-lane-label{height:28px;display:flex;align-items:center;border-top:1px solid rgba(255,255,255,.05);color:#a9dadd;cursor:pointer;padding-left:2px;border-left:3px solid transparent;box-sizing:border-box}
.iamccs-ms-lane-label.on{color:#fff0bc;border-left-color:#ffd579;background:rgba(255,213,121,.08)}
.iamccs-ms-time{position:relative;overflow-x:auto;overflow-y:hidden;background:#050b0c}
.iamccs-ms-ruler{position:relative;height:28px;border-bottom:1px solid #263d3c;background:linear-gradient(90deg,rgba(255,255,255,.12) 1px,transparent 1px);background-size:48px 100%}
.iamccs-ms-shotrow{position:relative;height:42px;border-bottom:1px solid #1d3030;background:#081112}
.iamccs-ms-trackrow{position:relative;height:28px;border-bottom:1px solid #132323;background:#061011;cursor:pointer}
.iamccs-ms-trackrow.on{background:linear-gradient(90deg,rgba(255,213,121,.08),rgba(6,16,17,.98))}
.iamccs-ms-shotblock{position:absolute;top:4px;height:32px;border:1px solid #486d6a;border-radius:5px;background:#122b2d;color:#f5e6bd;font-size:10px;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:8px 6px;box-sizing:border-box;cursor:pointer}
.iamccs-ms-shotblock.on{background:#4a3617;border-color:#ffd579;color:#fff5ce}
.iamccs-ms-part{position:absolute;top:5px;height:18px;border-radius:999px;border:1px solid rgba(255,255,255,.35);box-shadow:0 0 10px rgba(0,0,0,.35);cursor:pointer}
.iamccs-ms-part.on{outline:2px solid #ffe7a2}
.iamccs-ms-playhead{position:absolute;top:0;width:2px;height:154px;background:#ffe89d;box-shadow:0 0 0 1px rgba(0,0,0,.4),0 0 10px rgba(255,232,157,.75);pointer-events:none;z-index:8}
.iamccs-ms-playhead::before{content:"";position:absolute;left:-5px;top:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:8px solid #ffe89d}
.iamccs-ms-stage{position:relative;background:#020607;border:1px solid #315452;border-radius:7px;overflow:hidden;min-height:498px}
.iamccs-ms-canvas,.iamccs-ms-img{position:absolute;inset:0;width:100%;height:100%}
.iamccs-ms-img{object-fit:contain;background:radial-gradient(circle at 50% 40%,#152323,#030707 68%)}
.iamccs-ms-canvas{touch-action:none;cursor:crosshair}
.iamccs-ms-noimg{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#5f8b8d;font-weight:900;font-size:13px;pointer-events:none}
.iamccs-ms-side{border:1px solid #263d3c;border-radius:7px;background:#0b1718;padding:10px;overflow:auto;min-height:498px}
.iamccs-ms-side h4{font-size:11px;color:#f4deb0;margin:8px 0 6px}
.iamccs-ms-field{display:grid;grid-template-columns:92px 1fr;gap:6px;align-items:center;margin:7px 0;font-size:10px;color:#a8d8da}
.iamccs-ms-field input,.iamccs-ms-field select{width:100%;height:26px;border-radius:5px;border:1px solid #315452;background:#071011;color:#eff;font-weight:700}
.iamccs-ms-list{display:flex;flex-direction:column;gap:5px;margin-top:8px}
.iamccs-ms-stroke{border:1px solid #315452;background:#102124;border-radius:5px;padding:7px;font-size:10px;color:#d7ffff;text-align:left}
.iamccs-ms-stroke.on{border-color:#ffd579;background:#332914}
.iamccs-ms-hint{font-size:10px;color:#8fc9cf;line-height:1.45;margin-top:8px}
`;
document.head.appendChild(style);
}
function newSketchData(node) {
const data = safeParse(widget(node, "motion_sketch_data")?.value, {});
if (!Array.isArray(data.strokes)) data.strokes = [];
data.schema = data.schema || "iamccs.shotboard_v4.motion_sketch";
data.schema_version = data.schema_version || 1;
data.view = data.view && typeof data.view === "object" ? data.view : {};
return data;
}
function durationFrames(state) {
return Math.max(24, ...state.segments.map((s) => s.start + s.length), ...state.strokes.map((s) => (s.start_frame || 0) + (s.length_frames || 1)));
}
function segmentsSignature(segments) {
return (segments || []).map((seg) => [
seg.id,
seg.start,
seg.length,
seg.imageFile,
seg.label,
seg.prompt,
].join(":")).join("|");
}
function alignStrokesToSegments(state) {
const byId = new Map((state.segments || []).map((seg) => [String(seg.id), seg]));
for (const stroke of state.strokes || []) {
const seg = byId.get(String(stroke.segment_id || ""));
if (!seg) continue;
stroke.start_frame = seg.start;
stroke.length_frames = seg.length;
}
}
function selectedSegment(state) {
return state.segments.find((s) => s.id === state.selectedSegmentId) || state.segments[0] || null;
}
function persist(node, state) {
const data = newSketchData(node);
data.frame_rate = Number(widget(node, "frame_rate")?.value || data.frame_rate || 24);
data.control_family = String(widget(node, "control_family")?.value || data.control_family || "auto");
data.shotboard_segments = state.segments;
data.strokes = state.strokes;
data.motionParts = buildMotionParts(state);
data.view = { ...(data.view || {}), selected_segment_id: state.selectedSegmentId, selected_stroke_id: state.selectedStrokeId };
data.duration_frames = durationFrames(state);
writeWidget(node, "motion_sketch_data", data);
}
function strokeColor(track) {
const map = {
camera_path: "#4ed6ff",
subject_path: "#78f287",
object_path: "#ffd267",
background_lock: "#ff6c83",
attention_mask: "#b389ff",
};
return map[track] || "#4ed6ff";
}
function buildMotionParts(state) {
return state.strokes.map((s, i) => ({
id: s.id || `motion_part_${i + 1}`,
type: "motion_control",
segment_id: s.segment_id || "",
track: s.track || "camera_path",
mode: s.mode || "motion_track",
scope: s.scope || "slot_only",
start: Math.max(0, Math.round(Number(s.start_frame || 0))),
length: Math.max(1, Math.round(Number(s.length_frames || 24))),
trimStart: Math.max(0, Math.round(Number(s.start_frame || 0))),
videoStrength: Number(s.strength ?? 0.75),
videoAttentionStrength: Number(s.attention_strength ?? 0.65),
resampleMode: "nearest",
}));
}
function repaintCanvas(state) {
const canvas = state.canvas;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.round(rect.width * dpr));
const h = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const stroke of state.strokes) {
if (stroke.segment_id && stroke.segment_id !== state.selectedSegmentId) continue;
const points = Array.isArray(stroke.points) ? stroke.points : [];
if (!points.length) continue;
ctx.globalAlpha = stroke.id === state.selectedStrokeId ? 1 : 0.82;
ctx.strokeStyle = strokeColor(stroke.track);
ctx.lineWidth = Math.max(3, Number(stroke.radius || 20) / 6);
ctx.shadowColor = strokeColor(stroke.track);
ctx.shadowBlur = 10;
ctx.beginPath();
points.forEach((p, i) => {
const x = Number(p[0] || 0) * rect.width;
const y = Number(p[1] || 0) * rect.height;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.shadowBlur = 0;
const last = points[points.length - 1];
if (last) {
ctx.fillStyle = strokeColor(stroke.track);
ctx.beginPath();
ctx.arc(Number(last[0]) * rect.width, Number(last[1]) * rect.height, 5, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
function renderStrokeList(state) {
state.list.innerHTML = "";
const visible = state.strokes.filter((s) => !s.segment_id || s.segment_id === state.selectedSegmentId);
for (const stroke of visible) {
const item = document.createElement("button");
item.className = `iamccs-ms-stroke${stroke.id === state.selectedStrokeId ? " on" : ""}`;
item.type = "button";
item.textContent = `${stroke.track || "path"} / ${stroke.mode || "motion"} / ${stroke.scope || "slot_only"} / ${stroke.start_frame || 0}f + ${stroke.length_frames || 0}f / ${stroke.points?.length || 0} pts`;
item.onclick = () => {
state.selectedStrokeId = stroke.id;
renderStrokeList(state);
renderTimeline(state);
repaintCanvas(state);
};
state.list.appendChild(item);
}
}
function renderLaneHighlights(state) {
const active = state.trackSelect?.value || "camera_path";
state.root?.querySelectorAll?.(".iamccs-ms-lane-label[data-track]")?.forEach((label) => {
label.classList.toggle("on", label.dataset.track === active);
});
}
function setActiveTrack(state, track) {
if (!track || !state.trackSelect) return;
state.trackSelect.value = track;
renderLaneHighlights(state);
renderTimeline(state);
state.status.textContent = `TRACK ${track.replace(/_/g, " ")} / draw on the selected shot`;
persist(state.node, state);
}
function frameFromPointer(state, event) {
const innerRect = state.timeInner.getBoundingClientRect();
const x = Math.max(0, event.clientX - innerRect.left);
return Math.max(0, Math.round(x / Math.max(0.001, state.pxPerFrame || 1)));
}
function setPlayheadFrame(state, frame, selectShot = true) {
const total = durationFrames(state);
const next = Math.max(0, Math.min(total, Math.round(Number(frame) || 0)));
state.playheadFrame = next;
if (selectShot) {
const seg = state.segments.find((item) => next >= item.start && next < item.start + item.length) || state.segments[state.segments.length - 1];
if (seg && seg.id !== state.selectedSegmentId) {
state.selectedSegmentId = seg.id;
state.noImg.style.display = seg?.imageFile ? "none" : "flex";
state.currentImage.src = seg?.imageFile ? imageUrl(seg.imageFile) : "";
renderStrokeList(state);
repaintCanvas(state);
}
}
state.status.textContent = `SCRUB ${next}f / ${total}f / shot ${selectedSegment(state)?.label || "none"}`;
renderTimeline(state);
persist(state.node, state);
}
function setPlayheadFromEvent(state, event) {
setPlayheadFrame(state, frameFromPointer(state, event), true);
}
function setupScrubbing(state) {
let scrubbing = false;
state.timeScroll.onpointerdown = (event) => {
if (event.target?.closest?.(".iamccs-ms-shotblock,.iamccs-ms-part")) return;
event.preventDefault();
scrubbing = true;
state.timeScroll.setPointerCapture?.(event.pointerId);
setPlayheadFromEvent(state, event);
};
state.timeScroll.onpointermove = (event) => {
if (!scrubbing) return;
event.preventDefault();
setPlayheadFromEvent(state, event);
};
const stop = () => {
scrubbing = false;
};
state.timeScroll.onpointerup = stop;
state.timeScroll.onpointercancel = stop;
}
function renderTimeline(state) {
const total = durationFrames(state);
const pxPerFrame = Math.max(3, Math.min(10, 920 / total));
const width = Math.max(960, Math.ceil(total * pxPerFrame) + 20);
state.pxPerFrame = pxPerFrame;
state.timelineWidth = width;
state.timeInner.style.width = `${width}px`;
state.timeInner.innerHTML = "";
const ruler = document.createElement("div");
ruler.className = "iamccs-ms-ruler";
ruler.style.width = `${width}px`;
const fps = Number(widget(state.node, "frame_rate")?.value || 24);
for (let f = 0; f <= total; f += Math.max(1, Math.round(fps))) {
const mark = document.createElement("div");
mark.style.position = "absolute";
mark.style.left = `${f * pxPerFrame}px`;
mark.style.top = "7px";
mark.style.font = "800 10px monospace";
mark.style.color = "#d2f1f3";
mark.textContent = `${Math.round(f / fps)}s`;
ruler.appendChild(mark);
}
state.timeInner.appendChild(ruler);
const shotRow = document.createElement("div");
shotRow.className = "iamccs-ms-shotrow";
shotRow.style.width = `${width}px`;
for (const seg of state.segments) {
const block = document.createElement("button");
block.type = "button";
block.className = `iamccs-ms-shotblock${seg.id === state.selectedSegmentId ? " on" : ""}`;
block.style.left = `${seg.start * pxPerFrame}px`;
block.style.width = `${Math.max(24, seg.length * pxPerFrame)}px`;
block.textContent = seg.label;
block.title = `${seg.label} ${seg.start}f + ${seg.length}f`;
block.onclick = () => selectSegment(state, seg.id);
shotRow.appendChild(block);
}
state.timeInner.appendChild(shotRow);
for (const track of state.trackOrder) {
const row = document.createElement("div");
row.className = `iamccs-ms-trackrow${state.trackSelect?.value === track ? " on" : ""}`;
row.dataset.track = track;
row.style.width = `${width}px`;
row.onclick = (event) => {
if (event.target?.closest?.(".iamccs-ms-part")) return;
setActiveTrack(state, track);
setPlayheadFromEvent(state, event);
};
for (const stroke of state.strokes.filter((s) => (s.track || "camera_path") === track)) {
const part = document.createElement("button");
part.type = "button";
part.className = `iamccs-ms-part${stroke.id === state.selectedStrokeId ? " on" : ""}`;
part.style.left = `${Math.max(0, Number(stroke.start_frame || 0)) * pxPerFrame}px`;
part.style.width = `${Math.max(18, Number(stroke.length_frames || 1) * pxPerFrame)}px`;
part.style.background = strokeColor(track);
part.title = `${track} ${stroke.start_frame || 0}f + ${stroke.length_frames || 0}f`;
part.onclick = (event) => {
event?.stopPropagation?.();
state.selectedStrokeId = stroke.id;
if (stroke.segment_id) selectSegment(state, stroke.segment_id, false);
renderTimeline(state);
renderStrokeList(state);
repaintCanvas(state);
};
row.appendChild(part);
}
state.timeInner.appendChild(row);
}
const playhead = document.createElement("div");
playhead.className = "iamccs-ms-playhead";
playhead.style.left = `${Math.max(0, Math.min(total, state.playheadFrame || 0)) * pxPerFrame}px`;
state.timeInner.appendChild(playhead);
renderLaneHighlights(state);
}
function selectSegment(state, id, persistNow = true) {
state.selectedSegmentId = id;
const seg = selectedSegment(state);
if (seg) state.playheadFrame = Math.max(0, Math.round(Number(seg.start || 0)));
state.noImg.style.display = seg?.imageFile ? "none" : "flex";
state.currentImage.src = seg?.imageFile ? imageUrl(seg.imageFile) : "";
state.status.textContent = `SYNC ${state.syncSource} / shot ${seg?.label || "none"} / ${seg?.start || 0}f + ${seg?.length || 0}f`;
if (persistNow) persist(state.node, state);
renderTimeline(state);
renderStrokeList(state);
repaintCanvas(state);
}
function refreshFromShotboard(state, options = {}) {
const linked = readLinkedShotboardSegments(state.node);
const data = newSketchData(state.node);
const fallback = extractSegmentsFromData(data);
state.syncSource = linked.source;
const nextSegments = linked.segments.length ? linked.segments : (fallback.length ? fallback : state.segments);
const nextSignature = segmentsSignature(nextSegments);
if (options.onlyIfChanged && nextSignature === state.lastSegmentsSignature) return false;
state.segments = nextSegments;
state.lastSegmentsSignature = nextSignature;
state.strokes = Array.isArray(data.strokes) ? data.strokes : [];
alignStrokesToSegments(state);
const saved = data.view?.selected_segment_id;
if (saved && state.segments.some((s) => s.id === saved)) state.selectedSegmentId = saved;
if (!state.selectedSegmentId || !state.segments.some((s) => s.id === state.selectedSegmentId)) {
state.selectedSegmentId = state.segments[0]?.id || "";
}
selectSegment(state, state.selectedSegmentId || "", false);
persist(state.node, state);
return true;
}
function setupDrawing(state) {
let active = null;
state.canvas.onpointerdown = (event) => {
event.preventDefault();
state.canvas.setPointerCapture?.(event.pointerId);
const seg = selectedSegment(state);
if (!seg) return;
state.isDrawing = true;
const rect = state.canvas.getBoundingClientRect();
const stroke = {
id: `motion_part_${Date.now().toString(36)}`,
segment_id: seg.id,
track: state.trackSelect.value,
mode: state.modeSelect.value,
scope: state.scopeSelect.value,
start_frame: seg.start,
length_frames: seg.length,
strength: Number(state.strength.value || 0.75),
attention_strength: Number(state.attention.value || 0.65),
radius: Number(state.radius.value || 28),
falloff: Number(state.falloff.value || 0.35),
easing: "ease_in_out",
points: [[(event.clientX - rect.left) / rect.width, (event.clientY - rect.top) / rect.height]],
};
state.strokes.push(stroke);
state.selectedStrokeId = stroke.id;
active = stroke;
persist(state.node, state);
renderTimeline(state);
renderStrokeList(state);
repaintCanvas(state);
};
state.canvas.onpointermove = (event) => {
if (!active) return;
const rect = state.canvas.getBoundingClientRect();
active.points.push([
Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)),
]);
persist(state.node, state);
repaintCanvas(state);
};
const finish = () => {
if (!active) return;
active = null;
state.isDrawing = false;
persist(state.node, state);
renderTimeline(state);
renderStrokeList(state);
repaintCanvas(state);
};
state.canvas.onpointerup = finish;
state.canvas.onpointercancel = finish;
}
function enforceNodeSize(node) {
node.size = [Math.max(node.size?.[0] || 0, UI_W + 30), Math.max(node.size?.[1] || 0, UI_H + 92)];
node.min_size = [UI_W + 30, UI_H + 92];
}
function createUi(node) {
ensureStyle();
enforceNodeSize(node);
const root = document.createElement("div");
root.className = "iamccs-motion-sketch";
root.innerHTML = `
<div class="iamccs-ms-top">
<div class="iamccs-ms-status">Connect Shotboard V3 cine_linx -> Motion Sketch cine_linx</div>
<div class="iamccs-ms-actions">
<button class="iamccs-ms-btn" data-act="refresh">Sync Shotboard</button>
<button class="iamccs-ms-btn" data-act="clear">Clear Shot</button>
<button class="iamccs-ms-btn on" data-act="publish">Publish</button>
</div>
</div>
<div class="iamccs-ms-grid">
<div class="iamccs-ms-timeline">
<div class="iamccs-ms-legend">
<div style="height:28px">TIME</div>
<div class="iamccs-ms-lane-label">SHOT</div>
<div class="iamccs-ms-lane-label" data-track="camera_path">CAMERA</div>
<div class="iamccs-ms-lane-label" data-track="subject_path">SUBJECT</div>
<div class="iamccs-ms-lane-label" data-track="object_path">OBJECT</div>
<div class="iamccs-ms-lane-label" data-track="background_lock">LOCK</div>
</div>
<div class="iamccs-ms-time"><div class="iamccs-ms-time-inner"></div></div>
</div>
<div class="iamccs-ms-stage">
<img class="iamccs-ms-img" alt="">
<canvas class="iamccs-ms-canvas"></canvas>
<div class="iamccs-ms-noimg">NO SHOT IMAGE</div>
</div>
<div class="iamccs-ms-side">
<h4>MOTION TRACK</h4>
<label class="iamccs-ms-field"><span>track</span><select data-field="track"><option value="camera_path">camera path</option><option value="subject_path">subject path</option><option value="object_path">object path</option><option value="background_lock">background lock</option><option value="attention_mask">attention mask</option></select></label>
<label class="iamccs-ms-field"><span>mode</span><select data-field="mode"><option value="motion_track">motion track</option><option value="orbit">orbit</option><option value="dolly">dolly</option><option value="pan">pan</option><option value="lock">lock</option></select></label>
<label class="iamccs-ms-field"><span>scope</span><select data-field="scope"><option value="slot_only">slot only</option><option value="continue_to_next">continue to next</option><option value="hold_last">hold last</option><option value="cut_reset">cut reset</option></select></label>
<label class="iamccs-ms-field"><span>strength</span><input data-field="strength" type="number" min="0" max="1" step="0.01" value="0.75"></label>
<label class="iamccs-ms-field"><span>attention</span><input data-field="attention" type="number" min="0" max="1" step="0.01" value="0.65"></label>
<label class="iamccs-ms-field"><span>brush</span><input data-field="radius" type="number" min="1" step="1" value="28"></label>
<label class="iamccs-ms-field"><span>falloff</span><input data-field="falloff" type="number" min="0" max="1" step="0.01" value="0.35"></label>
<h4>MOTION PARTS</h4>
<div class="iamccs-ms-list"></div>
<div class="iamccs-ms-hint">Disegna sull'immagine selezionata. Ogni tratto diventa una motionPart sincronizzata allo start/length dello shot nella timeline.</div>
</div>
</div>`;
const state = {
node,
root,
status: root.querySelector(".iamccs-ms-status"),
timeScroll: root.querySelector(".iamccs-ms-time"),
timeInner: root.querySelector(".iamccs-ms-time-inner"),
canvas: root.querySelector(".iamccs-ms-canvas"),
currentImage: root.querySelector(".iamccs-ms-img"),
noImg: root.querySelector(".iamccs-ms-noimg"),
list: root.querySelector(".iamccs-ms-list"),
trackSelect: root.querySelector("[data-field='track']"),
modeSelect: root.querySelector("[data-field='mode']"),
scopeSelect: root.querySelector("[data-field='scope']"),
strength: root.querySelector("[data-field='strength']"),
attention: root.querySelector("[data-field='attention']"),
radius: root.querySelector("[data-field='radius']"),
falloff: root.querySelector("[data-field='falloff']"),
trackOrder: ["camera_path", "subject_path", "object_path", "background_lock"],
segments: [],
strokes: [],
selectedSegmentId: "",
selectedStrokeId: "",
playheadFrame: 0,
lastSegmentsSignature: "",
autoSyncTimer: null,
syncSource: "not connected",
};
node[STATE_KEY] = state;
root.querySelector("[data-act='refresh']").onclick = () => refreshFromShotboard(state);
root.querySelector("[data-act='clear']").onclick = () => {
state.strokes = state.strokes.filter((s) => s.segment_id !== state.selectedSegmentId);
state.selectedStrokeId = "";
persist(node, state);
renderTimeline(state);
renderStrokeList(state);
repaintCanvas(state);
};
root.querySelector("[data-act='publish']").onclick = (event) => {
event.currentTarget.classList.toggle("on");
persist(node, state);
};
root.querySelectorAll(".iamccs-ms-lane-label[data-track]").forEach((label) => {
label.onclick = () => setActiveTrack(state, label.dataset.track);
});
state.trackSelect.onchange = () => setActiveTrack(state, state.trackSelect.value);
for (const input of [state.strength, state.attention, state.radius, state.falloff, state.modeSelect, state.scopeSelect]) {
input.onchange = () => persist(node, state);
}
setupDrawing(state);
setupScrubbing(state);
state.currentImage.onload = () => repaintCanvas(state);
new ResizeObserver(() => {
enforceNodeSize(node);
repaintCanvas(state);
}).observe(root);
const domWidget = node.addDOMWidget("CineMotionSketch", "iamccs_cine_motion_sketch", root, { serialize: false });
domWidget.computeSize = () => [UI_W, UI_H];
const originalOnResize = node.onResize;
node.onResize = function (...args) {
const out = originalOnResize?.apply(this, args);
enforceNodeSize(node);
setTimeout(() => repaintCanvas(state), 0);
return out;
};
setTimeout(() => refreshFromShotboard(state), 80);
state.autoSyncTimer = setInterval(() => {
if (!document.body.contains(root) || node[STATE_KEY] !== state) {
clearInterval(state.autoSyncTimer);
return;
}
if (state.isDrawing) return;
refreshFromShotboard(state, { onlyIfChanged: true });
}, 700);
}
function setupNode(node) {
if (!node || (node.comfyClass || node.type) !== TARGET_CLASS || node[STATE_KEY]) return;
if (typeof node.addDOMWidget !== "function") {
setTimeout(() => setupNode(node), 150);
return;
}
const jsonWidget = widget(node, "motion_sketch_data");
if (jsonWidget) {
jsonWidget.type = "hidden";
jsonWidget.computeSize = () => [0, -4];
}
createUi(node);
}
app.registerExtension({
name: "iamccs.cine.motion.sketch.v4",
nodeCreated(node) {
setupNode(node);
},
loadedGraphNode(node) {
setupNode(node);
},
});
+374 -146
View File
@@ -1,8 +1,8 @@
import { app } from "../../scripts/app.js";
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
console.info("[IAMCCS V3] Stable node UI mode active. AudioBoard UI is loaded directly by Comfy extension registry.");
const CINE_VERSION = "2026-05-26-v3-perf-idle-sync";
console.info("[IAMCCS V3] Stable node UI mode active. Shotboard audio duration floor build loaded.");
const CINE_VERSION = "2026-06-23-v3-audio-duration-floor";
const SHOTBOARD_V3_RIGID_WIDTH = 1920;
const SHOTBOARD_V3_OPEN_HEIGHT = 900;
const SHOTBOARD_V3_COLLAPSED_HEIGHT = 660; // increased to accommodate global prompt always visible in collapsed mode
@@ -2646,7 +2646,7 @@ function openReferenceFrameEditor(node, index, path, onApply) {
"overflow:hidden",
`background:${CINE_FILM_LAB.field}`,
`border:1px solid ${CINE_FILM_LAB.border}`,
"border-radius:7px",
"border-radius:3px",
"display:flex",
"align-items:center",
"justify-content:center",
@@ -6823,6 +6823,13 @@ function renderShotboardV3(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 segmentHasAudioMedia = (seg) => Boolean(seg && (String(seg.audioFile || "").trim() || String(seg.audioB64 || "").trim()));
const endOfAudioSegments = () => endOfSegments((timeline.audioSegments || []).filter((seg) => segmentHasAudioMedia(seg) && !seg.placeholder));
const durationFloorFrames = () => Math.max(endOfVisualSegments(), endOfAudioSegments());
const durationFloorSeconds = () => {
const frames = durationFloorFrames();
return frames > 0 ? Number((frames / getFps()).toFixed(3)) : 0;
};
const showTimelineNotice = (message, tone = "warn") => {
if (!timelineNotice) return;
if (!message && Date.now() < timelineNoticeUntil) return;
@@ -6833,13 +6840,18 @@ function renderShotboardV3(node) {
timelineNotice.style.color = tone === "error" ? "#FFE3DD" : "#FFF1BE";
};
const setDurationSeconds = (seconds, reason = "manual") => {
const next = Math.max(0.1, Number(seconds) || 0.1);
const requested = Math.max(0.1, Number(seconds) || 0.1);
const floor = durationFloorSeconds();
const next = floor > 0 ? Math.max(requested, floor) : requested;
if (durationWidget) durationWidget.value = next;
setWidgetValue(node, "duration_seconds", next);
timeline.duration_seconds = next;
timelineMeterSeconds = clampTimelineMeterSeconds(timelineMeterSeconds);
durationValueControl?._iamccsSetValue?.(next);
console.log("[IAMCCS V3 DURATION TRUTH]", { nodeId: node?.id, reason, duration_seconds: next, fps: getFps() });
if (next > requested + 0.0005) {
showTimelineNotice(`Duration locked to ${next.toFixed(3)}s because timeline audio/slots reach that point. Shorten or remove content before reducing duration.`, "warn");
}
console.log("[IAMCCS V3 DURATION TRUTH]", { nodeId: node?.id, reason, requested_duration_seconds: requested, duration_floor_seconds: floor, duration_seconds: next, fps: getFps() });
};
const setFrameRateValue = (fps, reason = "manual") => {
const next = Math.max(1, Math.round(Number(fps) || 24));
@@ -6858,7 +6870,7 @@ function renderShotboardV3(node) {
syncTimingWidgetsFromTimelineTruth("initial_timeline_load");
const enforceDurationMinimum = () => {
const fps = getFps();
const minFrames = endOfVisualSegments();
const minFrames = durationFloorFrames();
if (!minFrames) {
showTimelineNotice("");
return false;
@@ -6869,8 +6881,9 @@ function renderShotboardV3(node) {
return false;
}
const minSeconds = Number((minFrames / fps).toFixed(3));
showTimelineNotice(`Timeline content reaches ${minSeconds}s, but board duration remains ${getDuration().toFixed(3)}s. Change Duration explicitly or shorten/ripple-delete the extra space.`);
return false;
setDurationSeconds(minSeconds, "enforce_duration_floor");
showTimelineNotice(`Timeline duration restored to ${minSeconds}s because audio/slots reach that point.`, "warn");
return true;
};
const ensureDurationForFrames = (requiredFrames) => {
const fps = getFps();
@@ -7608,7 +7621,13 @@ function renderShotboardV3(node) {
try { widget.callback?.(take); } catch {}
try {
window.dispatchEvent(new CustomEvent("iamccs:multigeneration-active-take", {
detail: { nodeId: bridge.id, activeTake: take },
detail: {
nodeId: bridge.id,
activeTake: take,
timelineId: multiTimelineId(take),
audioLane: `A${take}`,
source: "shotboard",
},
}));
} catch {}
};
@@ -7720,6 +7739,18 @@ function renderShotboardV3(node) {
showTimelineNotice(`Loaded ${nextId}. Visual boxes are independent for this generation.`, "info");
draw();
};
if (!root._iamccsV3BridgeTimelineListener) {
root._iamccsV3BridgeTimelineListener = true;
window.addEventListener("iamccs:multigeneration-active-take", (event) => {
const detail = event?.detail || {};
if (detail.source === "shotboard") return;
const take = Math.max(1, Math.round(Number(detail.activeTake || String(detail.timelineId || "").replace(/\D/g, "") || 1)));
const multi = timeline.multiGeneration && typeof timeline.multiGeneration === "object" ? timeline.multiGeneration : {};
const currentTake = Math.max(1, Math.round(Number(multi.activeTake || multiTimelineTakeFromId(multi.activeTimelineId) || 1)));
if (take === currentTake) return;
switchMultiTimeline(take);
});
}
const makeMultiTimelineControl = () => {
const multi = timeline.multiGeneration && typeof timeline.multiGeneration === "object" ? timeline.multiGeneration : {};
const bridge = findMultiTimelineBridge();
@@ -7821,11 +7852,20 @@ function renderShotboardV3(node) {
btn.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
timelineMeterSeconds = clampTimelineMeterSeconds(timelineMeterSeconds + delta);
const current = Number.isFinite(Number(timelineMeterSeconds)) ? Number(timelineMeterSeconds) : Math.max(0.5, getDuration());
timelineMeterSeconds = clampTimelineMeterSeconds(current + delta);
node.properties = node.properties || {};
node.properties.iamccs_v3_timeline_meter_seconds = timelineMeterSeconds;
node.properties.iamccs_v3_timeline_meter_user_set = true;
timelineNotice = label === "-" ? "Timeline meter compressed" : "Timeline meter expanded";
timelineNoticeUntil = Date.now() + 900;
draw();
requestAnimationFrame(() => {
timelineMeterSeconds = clampTimelineMeterSeconds(timelineMeterSeconds);
draw();
try { node.setDirtyCanvas?.(true, true); } catch {}
try { app.graph?.setDirtyCanvas?.(true, true); } catch {}
});
};
return protectControlDrag(btn);
};
@@ -7984,8 +8024,10 @@ function renderShotboardV3(node) {
"min-width:0",
"box-sizing:border-box",
].join(";");
const frameRuler = document.createElement("div");
frameRuler.style.cssText = `height:28px;position:relative;border:1px solid ${purple.border};border-bottom:0;background:linear-gradient(180deg,#18323A 0%,#13272F 60%,#101D23 100%);border-radius:6px 6px 0 0;overflow:hidden;box-shadow:inset 0 1px 0 rgba(255,255,255,.10);cursor:ew-resize;user-select:none;`;
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);`;
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%);overflow:hidden;box-shadow:inset 0 1px 0 rgba(255,255,255,.10);cursor:ew-resize;user-select:none;`;
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:344px;border:1px solid ${purple.border};background:#242220;overflow:hidden;border-radius:0 0 6px 6px;margin-bottom:6px;box-shadow:inset 0 0 0 1px rgba(216,155,69,.08);`;
@@ -8095,7 +8137,7 @@ function renderShotboardV3(node) {
let drawRaf = 0;
let transitionAppliedStamp = 0;
playbar.append(scrubStyle, playBtn, loopBtn, timeReadout, audioPlaybarControls, scrub);
timelineCanvas.append(ruler, timelineBox);
timelineCanvas.append(frameRuler, ruler, timelineBox);
timelineViewport.appendChild(timelineCanvas);
// Timeline height resize handle — drag to expand/shrink timeline rows (slots + local prompts)
// By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
@@ -8163,13 +8205,74 @@ function renderShotboardV3(node) {
return `${(frame / getFps()).toFixed(2)}s`;
}
function chooseFrameRulerStep(total, viewportWidth) {
const px = Math.max(1, Number(viewportWidth || timelineViewport?.clientWidth || 0) || 1);
const targetLabels = Math.max(4, Math.min(14, Math.floor(px / 120)));
const raw = Math.max(1, total / targetLabels);
const steps = [1, 2, 4, 5, 8, 10, 12, 16, 20, 24, 30, 32, 40, 48, 60, 64, 80, 96, 120, 121, 160, 192, 240];
return steps.find((step) => step >= raw) || Math.ceil(raw / 60) * 60;
}
function drawFrameRuler() {
frameRuler.innerHTML = "";
const total = Math.max(1, getTotalFrames());
const fps = Math.max(1, getFps());
const visibleWidth = Math.max(1, Number(timelineViewport?.clientWidth || timelineCanvas?.clientWidth || 0) || 1);
const majorStep = chooseFrameRulerStep(total, visibleWidth);
const baseLine = document.createElement("div");
baseLine.style.cssText = `position:absolute;left:0;right:0;bottom:0;height:2px;background:${purple.accent};opacity:.95;pointer-events:none;box-shadow:0 0 7px rgba(141,231,255,.38);`;
frameRuler.appendChild(baseLine);
for (let frame = 0; frame <= total; frame += 1) {
const major = frame % majorStep === 0 || frame === 0 || frame === total;
const pos = (frame / total) * 100;
const tick = document.createElement("div");
tick.style.cssText = [
"position:absolute",
`left:calc(${pos}% - ${major ? 1 : 0.5}px)`,
"bottom:0",
`width:${major ? 2 : 1}px`,
`height:${major ? 23 : 9}px`,
`background:${major ? purple.play : "#6FB6D2"}`,
`opacity:${major ? 1 : 0.64}`,
major ? "box-shadow:0 0 8px rgba(255,224,138,.45)" : "box-shadow:none",
"pointer-events:none",
].join(";");
frameRuler.appendChild(tick);
if (!major) continue;
const label = document.createElement("div");
label.style.cssText = [
"position:absolute",
`left:${pos}%`,
"top:3px",
frame >= total - 1 ? "transform:translateX(calc(-100% - 6px))" : "transform:translateX(6px)",
`color:${frame % fps === 0 ? purple.play : "#EAF8FF"}`,
"font-size:10px",
"font-weight:950",
"line-height:1",
"text-shadow:0 1px 2px rgba(0,0,0,.72)",
"white-space:nowrap",
"pointer-events:none",
].join(";");
label.textContent = `F${Math.round(frame)}`;
frameRuler.appendChild(label);
}
const playPos = (playFrame / total) * 100;
const marker = document.createElement("div");
marker.style.cssText = `position:absolute;left:calc(${playPos}% - 2px);top:0;bottom:0;width:4px;background:${purple.play};box-shadow:0 0 0 1px rgba(0,0,0,.72),0 0 14px rgba(255,224,138,.75);pointer-events:none;z-index:20;`;
frameRuler.appendChild(marker);
const info = document.createElement("div");
info.style.cssText = `position:absolute;right:7px;top:5px;color:#FFFFFF;font-size:10px;font-weight:950;background:rgba(0,0,0,.78);padding:3px 6px;border:1px solid ${purple.accent};border-radius:4px;pointer-events:none;text-shadow:0 1px 2px #000;`;
info.textContent = `${total}f / ${fps}fps`;
frameRuler.appendChild(info);
}
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;`;
baseLine.style.cssText = `position:absolute;left:0;right:0;bottom:0;height:2px;background:${purple.accent};opacity:.95;pointer-events:none;box-shadow:0 0 7px rgba(141,231,255,.38);`;
ruler.appendChild(baseLine);
for (let s = 0; s <= seconds + 0.001; s += step) {
const major = Math.abs(s - Math.round(s)) < 0.001;
@@ -8181,9 +8284,10 @@ function renderShotboardV3(node) {
`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}`,
`height:${five ? 34 : major ? 27 : 12}px`,
`background:${five ? purple.play : major ? purple.accent : "#6FB6D2"}`,
`opacity:${five ? 1 : major ? 0.95 : 0.65}`,
five ? "box-shadow:0 0 8px rgba(255,224,138,.45)" : "box-shadow:none",
"pointer-events:none",
].join(";");
ruler.appendChild(tick);
@@ -8194,9 +8298,9 @@ function renderShotboardV3(node) {
`left:${pos}%`,
"top:4px",
"transform:translateX(5px)",
`color:${five ? "#F4D59D" : "#D8E2E5"}`,
`color:${five ? purple.play : "#EAF8FF"}`,
"font-size:11px",
"font-weight:800",
"font-weight:950",
"line-height:1",
"text-shadow:0 1px 2px #000",
"pointer-events:none",
@@ -8204,9 +8308,13 @@ function renderShotboardV3(node) {
label.textContent = `${s.toFixed(0)}s`;
ruler.appendChild(label);
}
const playPos = (playFrame / Math.max(1, total)) * 100;
const marker = document.createElement("div");
marker.style.cssText = `position:absolute;left:calc(${playPos}% - 2px);top:0;bottom:0;width:4px;background:${purple.play};box-shadow:0 0 0 1px rgba(0,0,0,.72),0 0 14px rgba(255,224,138,.75);pointer-events:none;z-index:20;`;
ruler.appendChild(marker);
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`;
last.style.cssText = `position:absolute;right:6px;top:5px;color:#FFFFFF;font-size:10px;font-weight:950;background:rgba(0,0,0,.78);padding:3px 6px;border:1px solid ${purple.accent};border-radius:4px;pointer-events:none;text-shadow:0 1px 2px #000;`;
last.textContent = `${seconds.toFixed(2)}s`;
ruler.appendChild(last);
}
@@ -8222,6 +8330,41 @@ function renderShotboardV3(node) {
loopBtn.style.color = isLooping ? "#FFF2B8" : purple.text;
}
function setPlayFrameFromMeterEvent(event, meterElement) {
if (!meterElement) return;
event?.preventDefault?.();
event?.stopPropagation?.();
const rect = meterElement.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (Number(event.clientX || 0) - rect.left) / Math.max(1, rect.width)));
playFrame = Math.max(0, Math.min(getTotalFrames(), Math.round(ratio * getTotalFrames())));
if (isPlaying) {
playbackStartFrame = playFrame;
playbackStartTimestamp = performance.now();
scheduleAudioFromFrame(playFrame);
}
draw();
}
function bindMeterScrub(meterElement) {
if (!meterElement || meterElement._iamccsMeterScrubBound) return;
meterElement._iamccsMeterScrubBound = true;
meterElement.addEventListener("pointerdown", (event) => {
setPlayFrameFromMeterEvent(event, meterElement);
const move = (moveEvent) => setPlayFrameFromMeterEvent(moveEvent, meterElement);
const finish = () => {
window.removeEventListener("pointermove", move, true);
window.removeEventListener("pointerup", finish, true);
window.removeEventListener("pointercancel", finish, true);
};
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, capture: true });
}
bindMeterScrub(frameRuler);
bindMeterScrub(ruler);
function audioPeakValue(raw) {
if (raw && typeof raw === "object") {
const min = Math.abs(Number(raw.min) || 0);
@@ -8619,6 +8762,30 @@ function renderShotboardV3(node) {
});
};
function normalizeTimelineDragPreviewItems(items, durationFrames) {
const total = Math.max(1, Math.round(Number(durationFrames || getTotalFrames())));
let cursor = 0;
return cloneSegments(items)
.sort((a, b) => Number(a.start || 0) - Number(b.start || 0))
.map((item) => {
const next = { ...item };
next.length = Math.max(1, Math.round(Number(next.length || 1)));
next.start = Math.max(0, Math.min(Math.round(Number(next.start || 0)), Math.max(0, total - 1)));
if (next.start < cursor) next.start = cursor;
if (next.start + next.length > total) next.length = Math.max(1, total - next.start);
cursor = next.start + next.length;
return next;
});
}
function timelineDragMetrics(isAudio = false) {
const target = isAudio ? audioTracks : imageTrack;
const rect = target?.getBoundingClientRect?.() || timelineBox.getBoundingClientRect();
const widthPx = Math.max(1, Number(rect.width || timelineBox.getBoundingClientRect().width || 1));
const leftPx = Number(rect.left || timelineBox.getBoundingClientRect().left || 0);
return { rect, widthPx, leftPx };
}
function applyCenterDragPhysics(initItems, targetId, targetStart, pointerFrame, durationFrames) {
const items = cloneSegments(initItems);
const targetIndex = items.findIndex((item) => item.id === targetId);
@@ -8673,11 +8840,11 @@ function renderShotboardV3(node) {
leftCursor = Number(test[i].start || 0) + Number(test[i].length || 1);
}
return test.map((item) => {
return normalizeTimelineDragPreviewItems(test.map((item) => {
const clean = { ...item, start: Math.round(Number(item.start || 0)) };
delete clean.original_start;
return clean;
});
}), durationFrames);
}
function edgeDragPreview(initItems, targetId, dragDelta, edge, durationFrames) {
@@ -8717,7 +8884,7 @@ function renderShotboardV3(node) {
target.length = Math.max(minLength, oldLength - (nextStart - oldStart));
}
}
return items;
return normalizeTimelineDragPreviewItems(items, durationFrames);
}
function audioDragPreview(initItems, targetId, dragDelta, edge, durationFrames) {
@@ -8778,14 +8945,13 @@ function renderShotboardV3(node) {
finishDrag();
return;
}
const rect = timelineBox.getBoundingClientRect();
const widthPx = rect.width || 1;
const { widthPx, leftPx } = timelineDragMetrics(isAudio);
const deltaFrames = Math.round(((move.clientX - startX) / widthPx) * getTotalFrames());
let next;
if (isAudio) {
next = audioDragPreview(dragState.initial, dragState.targetId, deltaFrames, edge, getTotalFrames());
} else if (edge === "center") {
const pointerFrame = Math.round(((move.clientX - rect.left) / Math.max(1, widthPx)) * getTotalFrames());
const pointerFrame = Math.round(((move.clientX - leftPx) / 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());
@@ -8809,7 +8975,7 @@ function renderShotboardV3(node) {
} catch (_) {}
if (dragState) {
if (isAudio && previewAudioSegments) timeline.audioSegments = previewAudioSegments;
if (!isAudio && previewSegments) timeline.segments = previewSegments;
if (!isAudio && previewSegments) timeline.segments = normalizeTimelineDragPreviewItems(previewSegments, getTotalFrames());
}
if (!isAudio) {
const moved = (timeline.segments || []).find((item) => item.id === seg.id);
@@ -8915,54 +9081,48 @@ function renderShotboardV3(node) {
const startIndex = allPeaks.length ? Math.max(0, Math.min(allPeaks.length - 1, Math.floor((trimStart / durationFrames) * allPeaks.length))) : 0;
const endIndex = allPeaks.length ? Math.max(startIndex + 1, Math.min(allPeaks.length, Math.ceil((trimEnd / durationFrames) * allPeaks.length))) : 0;
const peaks = allPeaks.slice(startIndex, endIndex);
const shell = document.createElement("div");
shell.style.cssText = [
"position:absolute",
"left:14px",
"right:14px",
"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 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 canvas = document.createElement("canvas");
canvas.style.cssText = "position:absolute;left:0;right:0;bottom:0;width:100%;height:100%;z-index:1;";
const rect = block.getBoundingClientRect?.() || { width: block.offsetWidth || 120, height: block.offsetHeight || 58 };
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
const cssW = Math.max(64, Math.round(Number(block.offsetWidth || 120)));
const cssH = Math.max(46, Math.round(Number(block.offsetHeight || 54)));
const w = Math.max(96, Math.min(4096, Math.round(cssW * dpr)));
const h = Math.max(50, Math.min(260, Math.round(cssH * dpr)));
const timelineCssWidth = Math.max(
0,
Number(timelineCanvas?.clientWidth || 0) || Number.parseFloat(timelineCanvas?.style?.width || "0") || 0
);
const clipCssWidth = timelineCssWidth > 0
? (Math.max(1, Number(seg.length || 1)) / Math.max(1, getTotalFrames())) * timelineCssWidth
: 0;
const styledHeight = Number.parseFloat(block.style?.height || "0") || 0;
const cssW = Math.max(72, Math.round(Number(clipCssWidth || rect.width || block.offsetWidth || 120)));
const cssH = Math.max(50, Math.round(Number(styledHeight || rect.height || block.offsetHeight || 82)));
const w = Math.max(120, Math.min(4096, Math.round(cssW * dpr)));
const h = Math.max(58, Math.min(320, Math.round(cssH * dpr)));
const canvas = document.createElement("canvas");
canvas.className = "iamccs-v3-real-waveform-canvas";
canvas.dataset.waveformRenderer = "audio_board_op4";
canvas.width = w;
canvas.height = h;
canvas.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;z-index:1;image-rendering:auto;pointer-events:none;";
const ctx = canvas.getContext("2d");
if (ctx) ctx.imageSmoothingEnabled = false;
if (!ctx) {
shell.appendChild(canvas);
block.appendChild(shell);
block.appendChild(canvas);
return;
}
const bg = ctx.createLinearGradient(0, 0, 0, h);
bg.addColorStop(0, "#376a9b");
bg.addColorStop(.52, "#315f8f");
bg.addColorStop(1, "#23496f");
bg.addColorStop(.48, "#315f8f");
bg.addColorStop(1, "#22476c");
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = "rgba(255,255,255,.12)";
ctx.strokeStyle = "rgba(255,255,255,.10)";
ctx.lineWidth = 1;
for (let x = 0; x <= w; x += Math.max(36, Math.round(w / 18))) {
const gridStep = Math.max(32, Math.round(w / 18));
for (let x = 0; x <= w; x += gridStep) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.moveTo(x + .5, 0);
ctx.lineTo(x + .5, h);
ctx.stroke();
}
ctx.strokeStyle = "rgba(255,255,255,.28)";
ctx.strokeStyle = "rgba(255,255,255,.30)";
ctx.beginPath();
ctx.moveTo(0, h * .5);
ctx.lineTo(w, h * .5);
@@ -8979,89 +9139,89 @@ function renderShotboardV3(node) {
return { min: -p, max: p, rms: p * .66 };
};
if (!peaks.length) {
ctx.fillStyle = "rgba(235,248,255,.76)";
ctx.fillStyle = "rgba(235,248,255,.80)";
ctx.font = `900 ${Math.max(10, Math.round(12 * dpr))}px ui-monospace, Consolas, monospace`;
ctx.textAlign = "center";
ctx.fillText(waveformLoading.has(seg.id) ? "decoding real waveform..." : "no waveform peaks", w * .5, h * .53);
shell.appendChild(canvas);
block.appendChild(shell);
return;
}
const peakValue = (raw) => {
const p = normPeak(raw);
return Math.max(Math.abs(p.min), Math.abs(p.max), p.rms);
};
const visualMax = Math.max(.05, ...peaks.map(peakValue));
const scale = Math.min(1.65, .94 / visualMax);
const columnPeak = (x) => {
const from = Math.floor((x / Math.max(1, w)) * peaks.length);
const to = Math.max(from + 1, Math.floor(((x + 1) / Math.max(1, w)) * peaks.length));
let min = 0;
let max = 0;
let rms = 0;
let n = 0;
for (let i = from; i < Math.min(peaks.length, to); i += 1) {
const p = normPeak(peaks[i]);
min = Math.min(min, p.min);
max = Math.max(max, p.max);
rms += p.rms;
n += 1;
ctx.textBaseline = "middle";
ctx.fillText(waveformLoading.has(seg.id) ? "DECODING REAL WAVEFORM..." : "REAL WAVEFORM UNAVAILABLE", w * .5, h * .55);
block.appendChild(canvas);
} else {
const peakValue = (raw) => {
const p = normPeak(raw);
return Math.max(Math.abs(p.min), Math.abs(p.max), p.rms);
};
const visualMax = Math.max(.05, ...peaks.map(peakValue));
const scale = Math.min(1.65, .94 / visualMax);
const columnPeak = (x) => {
const from = Math.floor((x / Math.max(1, w)) * peaks.length);
const to = Math.max(from + 1, Math.floor(((x + 1) / Math.max(1, w)) * peaks.length));
let min = 0;
let max = 0;
let rms = 0;
let n = 0;
for (let i = from; i < Math.min(peaks.length, to); i += 1) {
const p = normPeak(peaks[i]);
min = Math.min(min, p.min);
max = Math.max(max, p.max);
rms += p.rms;
n += 1;
}
if (!n) return normPeak(peaks[Math.min(peaks.length - 1, Math.max(0, from))]);
return { min, max, rms: rms / n };
};
const center = h * .5;
const amp = h * .46;
const top = [];
const bottom = [];
const rmsTop = [];
const rmsBottom = [];
for (let x = 0; x < w; x += 1) {
const p = columnPeak(x);
top.push([x, center - Math.max(1, p.max * scale * amp)]);
bottom.unshift([x, center + Math.max(1, Math.abs(p.min) * scale * amp)]);
rmsTop.push([x, center - Math.max(.5, p.rms * scale * amp * .62)]);
rmsBottom.unshift([x, center + Math.max(.5, p.rms * scale * amp * .62)]);
}
if (!n) {
const p = normPeak(peaks[Math.min(peaks.length - 1, Math.max(0, from))]);
return p;
}
return { min, max, rms: rms / n };
};
const center = h * .5;
const amp = h * .46;
const top = [];
const bottom = [];
const rmsTop = [];
const rmsBottom = [];
for (let x = 0; x < w; x += 1) {
const p = columnPeak(x);
top.push([x, center - Math.max(1, p.max * scale * amp)]);
bottom.unshift([x, center + Math.max(1, Math.abs(p.min) * scale * amp)]);
rmsTop.push([x, center - Math.max(.5, p.rms * scale * amp * .62)]);
rmsBottom.unshift([x, center + Math.max(.5, p.rms * scale * amp * .62)]);
}
const body = ctx.createLinearGradient(0, 0, 0, h);
body.addColorStop(0, "rgba(236,249,255,.96)");
body.addColorStop(.48, "rgba(178,221,245,.82)");
body.addColorStop(.52, "rgba(172,215,241,.80)");
body.addColorStop(1, "rgba(236,249,255,.94)");
ctx.fillStyle = "rgba(255,255,255,.16)";
ctx.beginPath();
rmsTop.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
rmsBottom.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.closePath();
ctx.fill();
ctx.fillStyle = body;
ctx.beginPath();
top.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
bottom.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,.84)";
ctx.lineWidth = 1;
ctx.beginPath();
top.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
bottom.slice().reverse().forEach(([x, y]) => ctx.lineTo(x, y));
ctx.stroke();
ctx.strokeStyle = "rgba(255,255,255,.32)";
const detailStep = w > 2200 ? 2 : 1;
for (let x = 0; x < w; x += detailStep) {
const p = columnPeak(x);
const y1 = center - Math.max(1, p.max * scale * amp);
const y2 = center + Math.max(1, Math.abs(p.min) * scale * amp);
ctx.fillStyle = "rgba(255,255,255,.18)";
ctx.beginPath();
ctx.moveTo(x + .5, y1);
ctx.lineTo(x + .5, y2);
rmsTop.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
rmsBottom.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.closePath();
ctx.fill();
const body = ctx.createLinearGradient(0, 0, 0, h);
body.addColorStop(0, "rgba(238,250,255,.98)");
body.addColorStop(.48, "rgba(183,224,247,.84)");
body.addColorStop(.52, "rgba(174,216,241,.82)");
body.addColorStop(1, "rgba(238,250,255,.96)");
ctx.fillStyle = body;
ctx.beginPath();
top.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
bottom.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,.88)";
ctx.lineWidth = 1;
ctx.beginPath();
top.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
bottom.slice().reverse().forEach(([x, y]) => ctx.lineTo(x, y));
ctx.stroke();
ctx.strokeStyle = "rgba(255,255,255,.32)";
const detailStep = w > 2200 ? 2 : 1;
for (let x = 0; x < w; x += detailStep) {
const p = columnPeak(x);
const y1 = center - Math.max(1, p.max * scale * amp);
const y2 = center + Math.max(1, Math.abs(p.min) * scale * amp);
ctx.beginPath();
ctx.moveTo(x + .5, y1);
ctx.lineTo(x + .5, y2);
ctx.stroke();
}
block.appendChild(canvas);
}
shell.appendChild(canvas);
block.appendChild(shell);
const name = document.createElement("div");
name.textContent = `REAL ${String(seg.fileName || seg.audioFile || "Audio").split(/[\\/]/).pop()}`;
name.style.cssText = "position:absolute;left:7px;top:4px;right:7px;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,.90);z-index:2;pointer-events:none;";
block.appendChild(name);
}
function openAppendImagePicker(targetId = null) {
@@ -9436,7 +9596,7 @@ function renderShotboardV3(node) {
"cursor:grab",
(selected ? "box-shadow:0 0 0 2px rgba(249,200,89,.35),0 6px 16px rgba(0,0,0,.38),inset 0 1px 0 rgba(255,190,120,.08)" : "box-shadow:0 6px 16px rgba(0,0,0,.38),inset 0 1px 0 rgba(255,190,120,.08)"),
"user-select:none",
`z-index:${isAudio ? 14 : 4}`,
`z-index:${isAudio ? 14 : (selected || (dragState && dragState.targetId === seg.id) ? 18 : 4)}`,
].join(";");
const content = document.createElement("div");
content.style.cssText = isAudio
@@ -9528,7 +9688,7 @@ function renderShotboardV3(node) {
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.style.cssText = `position:absolute;right:6px;top:6px;width:24px;height:24px;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 9px rgba(0,0,0,.55);z-index:140;pointer-events:auto;`;
removeAudio.onpointerdown = (event) => { event.preventDefault(); event.stopPropagation(); };
removeAudio.onclick = (event) => {
event.preventDefault();
@@ -9538,8 +9698,9 @@ function renderShotboardV3(node) {
writeTimeline({ force: true });
draw();
};
block.appendChild(removeAudio);
removeAudio.dataset.iamccsAudioRemoveButton = "1";
renderAudioWaveform(block, seg);
block.appendChild(removeAudio);
}
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);
@@ -9796,8 +9957,8 @@ function renderShotboardV3(node) {
"position:absolute",
`left:calc(${pct}% - 9px)`,
"top:8px",
"bottom:8px",
"width:9px",
"height:222px",
"box-sizing:border-box",
"cursor:ew-resize",
"z-index:58",
@@ -9842,11 +10003,11 @@ function renderShotboardV3(node) {
const start = Math.round(Number(seg.start || 0));
const end = Math.round(Number(seg.start || 0) + Number(seg.length || 1));
const prev = sorted[index - 1];
const next = sorted[index + 1];
const prevEnd = prev ? Math.round(Number(prev.start || 0) + Number(prev.length || 1)) : -1;
// Skip left handle at the very start of the timeline — By IAMCCS
if (start > 0 && (!prev || Math.abs(prevEnd - start) > 1)) appendVisualEdgeHandle(seg, "left", "left");
// Skip right handle at the very end of the timeline — By IAMCCS
if (end < total) appendVisualEdgeHandle(seg, "right", "right");
const nextStart = next ? Math.round(Number(next.start || 0)) : total + 1;
if (!prev || Math.abs(prevEnd - start) > 1) appendVisualEdgeHandle(seg, "left", "left");
appendVisualEdgeHandle(seg, "right", "right");
});
}
@@ -11037,6 +11198,7 @@ function renderShotboardV3(node) {
function draw() {
if (!dragState) writeTimeline();
drawFrameRuler();
drawRuler();
updatePlayUI();
drawAudioPlaybarControls();
@@ -11058,6 +11220,7 @@ function renderShotboardV3(node) {
const canvasWidth = computeTimelineCanvasWidth(visualSegments);
const previousScrollLeft = Number(timelineViewport.scrollLeft || 0);
timelineCanvas.style.width = `${canvasWidth}px`;
frameRuler.style.width = "100%";
ruler.style.width = "100%";
timelineBox.style.width = "100%";
timelineViewport.scrollLeft = Math.min(previousScrollLeft, Math.max(0, canvasWidth - Number(timelineViewport.clientWidth || 0)));
@@ -13066,6 +13229,57 @@ function renderBoardMaker(node) {
draw();
}
function installIamccsLowZoomOverlay(node, key, buildLines) {
if (node && key) node[key] = true;
return;
if (!node || node[key]) return;
const previous = node.onDrawForeground;
node.onDrawForeground = function(ctx) {
if (typeof previous === "function") previous.apply(this, arguments);
const scale = Math.max(0.12, Number(app?.canvas?.ds?.scale || 1));
if (!ctx || scale >= 0.62) return;
let lines = [];
try { lines = buildLines?.(this) || []; } catch { lines = []; }
lines = lines.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4);
if (!lines.length) return;
const nodeW = Math.max(320, Number(this.size?.[0] || 360));
const nodeH = Math.max(180, Number(this.size?.[1] || 240));
const boost = Math.max(1.2, Math.min(3.4, 0.72 / scale));
const pad = 12 * boost;
const lineH = 18 * boost;
const titleFont = Math.round(13 * boost);
const bodyFont = Math.round(11 * boost);
const w = Math.max(220, Math.min(nodeW - pad * 2, 660 * boost));
const h = 34 * boost + lines.length * lineH;
const x = pad;
const y = Math.min(Math.max(56, 46 * boost), Math.max(40, nodeH - h - pad));
ctx.save();
ctx.globalAlpha = 0.96;
ctx.fillStyle = "rgba(8,18,20,.92)";
ctx.strokeStyle = "rgba(143,208,204,.72)";
ctx.lineWidth = Math.max(1.5, 1.2 * boost);
if (typeof ctx.roundRect === "function") {
ctx.beginPath();
ctx.roundRect(x, y, w, h, 8 * boost);
ctx.fill();
ctx.stroke();
} else {
ctx.fillRect(x, y, w, h);
ctx.strokeRect(x, y, w, h);
}
ctx.fillStyle = "rgba(239,204,139,.95)";
ctx.fillRect(x, y, Math.max(4, 3 * boost), h);
ctx.fillStyle = "#F4D49E";
ctx.font = `900 ${titleFont}px sans-serif`;
ctx.fillText(lines[0], x + 12 * boost, y + 21 * boost);
ctx.fillStyle = "#BFD7D5";
ctx.font = `800 ${bodyFont}px sans-serif`;
for (let i = 1; i < lines.length; i += 1) ctx.fillText(lines[i], x + 12 * boost, y + 21 * boost + i * lineH);
ctx.restore();
};
node[key] = true;
}
function renderForNode(node) {
const klass = nodeClassName(node);
try {
@@ -13086,6 +13300,20 @@ function renderForNode(node) {
if (klass === "IAMCCS_CinePromptRelayTimeline") renderPromptRelayEditor(node);
if (isShotboardV3Class(klass)) {
renderShotboardV3(node);
installIamccsLowZoomOverlay(node, "_iamccsShotboardLowZoomOverlay", () => {
let data = {};
try { data = JSON.parse(String(getWidget(node, "timeline_data")?.value || "{}")); } catch {}
const duration = Number(data.duration_seconds ?? getWidget(node, "duration_seconds")?.value ?? 0) || 0;
const fps = Number(data.frame_rate ?? getWidget(node, "frame_rate")?.value ?? 24) || 24;
const shots = Array.isArray(data.segments) ? data.segments.filter((seg) => String(seg?.type || "image") !== "audio").length : 0;
const audio = Array.isArray(data.audioSegments) ? data.audioSegments.length : 0;
return [
"Shotboard V3 mini view",
`${duration.toFixed(2)}s / ${Math.round(duration * fps)} frames`,
`${shots} visual slots / ${audio} audio clips`,
"Zoom in or open editor for full controls",
];
});
}
if (klass === "IAMCCS_CineShotboardLite") renderShotboardLite(node);
if (klass === "IAMCCS_CineShotboardTimelinePro" || klass === "IAMCCS_CineShotboardPlannerPro" || klass === "IAMCCS_CineShotboardPlannerProV2" || klass === "IAMCCS_CineShotboardPlannerProLegacy") renderShotboardPro(node);
+420 -47
View File
@@ -37,17 +37,17 @@ function defaultData() {
return {
schema: "iamccs.dialogue_tag_editor",
schema_version: 2,
global_prompt: "cinematic night interior, two men in field and reverse-field dialogue, natural audio-driven lip sync, controlled tension, subtle listening reactions, stable identities, coherent eyelines, no subtitles, no visible text",
settings: { engine_profile: "", output_mode: "speaker_stems_for_overlap", tts_generation_mode: "double_stem_ab", speaker_stems_zero_start: false, inline_edit_mode: "metadata_only", default_gap_seconds: 0.12, text_theme: "light_boxes", font_zoom: 1 },
global_prompt: "cinematic field and reverse-field dialogue, hard cut coverage, one dominant speaking face per shot, visible mouth movement, natural audio-driven performance, silent listener reaction, stable identities, coherent eyelines",
settings: { engine_profile: "", output_mode: "speaker_stems_for_overlap", tts_generation_mode: "double_stem_ab", speaker_stems_zero_start: false, speaker_stem_srt_local_zero: true, inline_edit_mode: "metadata_only", emotion_routing: "clean_metadata", default_gap_seconds: 0.12, text_theme: "light_boxes", font_zoom: 1 },
speakers: [
{ id: "A", name: "Man A", voice: "speaker_a_low_tense", reference_text: "Keep your voice low. We do not know who is listening.", language: "en" },
{ id: "B", name: "Man B", voice: "speaker_b_controlled_whisper", reference_text: "Good. Now we finally have something worth protecting.", language: "en" },
],
lines: [
{ id: "line_001", speaker: "A", text: "You said the signal was dead. Then why is that receiver still blinking?", emotion: "tense", style: "low", paralinguistic: "Breathing", overlap_after: 0.18, ref: 1, track: 0, local_prompt: "field shot on Man A, tense close-up, controlled suspicion, natural lip sync driven by external dialogue audio" },
{ id: "line_002", speaker: "B", text: "Because someone on the other side wants us to think we are alone.", emotion: "serious", style: "whisper", paralinguistic: "none", overlap_after: 0.12, ref: 2, track: 1, local_prompt: "reverse field shot on Man B, quiet answer, guarded fear under control, lips follow the external dialogue audio" },
{ id: "line_003", speaker: "A", text: "If we open that door, we may be giving them exactly what they came for.", emotion: "fearful", style: "dry", paralinguistic: "Sigh", overlap_after: 0.1, ref: 1, track: 0, local_prompt: "cut back to Man A, tighter frame, fear hidden behind discipline, coherent eyeline, audio-driven speaking performance" },
{ id: "line_004", speaker: "B", text: "Then we do not open it. We make them knock twice.", emotion: "coldness", style: "authority", paralinguistic: "none", overlap_after: 0, ref: 2, track: 1, local_prompt: "reverse close-up on Man B, decisive final line, controlled authority, natural lip sync driven by external audio" },
{ id: "line_001", speaker: "A", text: "You said the signal was dead. Then why is that receiver still blinking?", emotion: "tense", style: "low", paralinguistic: "Breathing", overlap_after: 0.18, ref: 1, track: 0, local_prompt: "hard cut, Man A close-up, Man A speaks clearly, visible mouth movement, tense controlled delivery, Man B listens quietly" },
{ id: "line_002", speaker: "B", text: "Because someone on the other side wants us to think we are alone.", emotion: "serious", style: "whisper", paralinguistic: "none", overlap_after: 0.12, ref: 2, track: 1, local_prompt: "hard cut, Man B close-up, Man B speaks clearly, visible mouth movement, guarded quiet answer, Man A listens quietly" },
{ id: "line_003", speaker: "A", text: "If we open that door, we may be giving them exactly what they came for.", emotion: "fearful", style: "dry", paralinguistic: "Sigh", overlap_after: 0.1, ref: 1, track: 0, local_prompt: "hard cut, Man A tighter close-up, Man A speaks clearly, visible mouth movement, fear held under discipline" },
{ id: "line_004", speaker: "B", text: "Then we do not open it. We make them knock twice.", emotion: "coldness", style: "authority", paralinguistic: "none", overlap_after: 0, ref: 2, track: 1, local_prompt: "hard cut, Man B close-up, Man B speaks clearly, visible mouth movement, decisive controlled authority" },
],
};
}
@@ -59,40 +59,40 @@ const DIALOGUE_TEMPLATES = [
];
function templateGlobalPrompt(kind) {
if (kind === "monologue_a_simple") {
return "cinematic single-speaker monologue, one person framed in an intimate close-up, natural audio-driven lip sync, quiet emotional focus, stable identity, no subtitles, no visible text";
return "cinematic single-speaker monologue, one dominant speaking face, visible mouth movement, quiet emotional focus, stable identity";
}
if (kind === "monologue_a") {
return "cinematic single-speaker monologue, Speaker A alone in frame, controlled emotional progression across hard-cut close-ups, natural audio-driven lip sync, stable identity, no subtitles, no visible text";
return "cinematic single-speaker monologue, Speaker A alone in frame, controlled emotional progression across hard-cut close-ups, visible mouth movement, stable identity";
}
if (kind === "simple_ab") {
return "cinematic simple two-person dialogue, clean field and reverse-field coverage, one short A/B exchange, natural audio-driven lip sync, coherent eyelines, no subtitles, no visible text";
return "cinematic simple two-person dialogue, clean field and reverse-field coverage, one short A/B exchange, visible mouth movement on the speaking face, coherent eyelines";
}
return "cinematic reverse-shot dialogue scene, two people facing each other in a quiet tense space, strict field and reverse-field coverage based on shotboard references, hard cuts only, natural audio-driven lip sync, coherent eyelines, no subtitles, no visible text";
return "cinematic reverse-shot dialogue scene, two people facing each other in a quiet tense space, strict field and reverse-field coverage based on shotboard references, hard cuts only, one dominant speaking face per shot, coherent eyelines";
}
function templateLines(kind) {
if (kind === "monologue_a_simple") {
return [
{ id: "line_001", speaker: "A", text: "I know exactly what I have to do now.", emotion: "resolved", style: "low, steady", paralinguistic: "none", overlap_after: 0, ref: 1, track: 0, local_prompt: "single close-up on Speaker A, calm resolved delivery, natural lip sync driven by external dialogue audio" },
{ id: "line_001", speaker: "A", text: "I know exactly what I have to do now.", emotion: "resolved", style: "low, steady", paralinguistic: "none", overlap_after: 0, ref: 1, track: 0, local_prompt: "hard cut, Speaker A close-up, Speaker A speaks clearly, visible mouth movement, calm resolved delivery" },
];
}
if (kind === "monologue_a") {
return [
{ id: "line_001", speaker: "A", text: "I have carried this silence longer than I should.", emotion: "reflective", style: "soft, intimate", paralinguistic: "none", overlap_after: 0, ref: 1, track: 0, local_prompt: "field shot on Speaker A, intimate close-up, quiet confession, natural lip sync driven by external dialogue audio" },
{ id: "line_002", speaker: "A", text: "If I say it now, everything in this room changes.", emotion: "uncertain", style: "quiet, close", paralinguistic: "Breathing", overlap_after: 0, ref: 1, track: 0, local_prompt: "hold on Speaker A, controlled hesitation, small breath before the line, stable identity and coherent eyeline" },
{ id: "line_003", speaker: "A", text: "So listen carefully, because I will only say it once.", emotion: "resolved", style: "low, steady", paralinguistic: "none", overlap_after: 0, ref: 1, track: 0, local_prompt: "tight field shot on Speaker A, resolved final phrase, subtle mouth movement, audio-driven speaking performance" },
{ id: "line_001", speaker: "A", text: "I have carried this silence longer than I should.", emotion: "reflective", style: "soft, intimate", paralinguistic: "none", overlap_after: 0, ref: 1, track: 0, local_prompt: "hard cut, Speaker A intimate close-up, Speaker A speaks clearly, visible mouth movement, quiet confession" },
{ id: "line_002", speaker: "A", text: "If I say it now, everything in this room changes.", emotion: "uncertain", style: "quiet, close", paralinguistic: "Breathing", overlap_after: 0, ref: 1, track: 0, local_prompt: "hard cut, Speaker A close-up, Speaker A speaks clearly after a small breath, controlled hesitation, stable identity and coherent eyeline" },
{ id: "line_003", speaker: "A", text: "So listen carefully, because I will only say it once.", emotion: "resolved", style: "low, steady", paralinguistic: "none", overlap_after: 0, ref: 1, track: 0, local_prompt: "hard cut, Speaker A tight close-up, Speaker A speaks clearly, visible mouth movement, resolved final phrase" },
];
}
if (kind === "simple_ab") {
return [
{ id: "line_001", speaker: "A", text: "Are you ready?", emotion: "calm", style: "natural", paralinguistic: "none", overlap_after: 0, ref: 1, track: 0, local_prompt: "field shot on Speaker A, clean question, natural lip sync driven by external dialogue audio" },
{ id: "line_002", speaker: "B", text: "Yes. Let's begin.", emotion: "calm", style: "natural", paralinguistic: "none", overlap_after: 0, ref: 2, track: 1, local_prompt: "reverse field shot on Speaker B, simple answer, coherent eyeline, lips follow the external dialogue audio" },
{ id: "line_001", speaker: "A", text: "Are you ready?", emotion: "calm", style: "natural", paralinguistic: "none", overlap_after: 0, ref: 1, track: 0, local_prompt: "hard cut, Speaker A close-up, Speaker A speaks clearly, visible mouth movement, clean question" },
{ id: "line_002", speaker: "B", text: "Yes. Let's begin.", emotion: "calm", style: "natural", paralinguistic: "none", overlap_after: 0, ref: 2, track: 1, local_prompt: "hard cut, Speaker B close-up, Speaker B speaks clearly, visible mouth movement, coherent eyeline" },
];
}
return [
{ id: "line_001", speaker: "A", text: "I have not seen you in such a long time.", emotion: "wistful", style: "soft, intimate", paralinguistic: "none", overlap_after: 0.12, ref: 1, track: 0, local_prompt: "field shot on Speaker A, intimate close-up, controlled expression, natural lip sync driven by external dialogue audio" },
{ id: "line_002", speaker: "B", text: "No, that is not true. You are mistaken.", emotion: "calm denial", style: "quiet, certain", paralinguistic: "none", overlap_after: 0.12, ref: 2, track: 1, local_prompt: "reverse field shot on Speaker B, quiet correction, steady gaze, hard cut timing, lips follow the external dialogue audio" },
{ id: "line_003", speaker: "A", text: "Maybe I saw you in my dreams.", emotion: "uncertain, haunted", style: "low, reflective", paralinguistic: "Breathing", overlap_after: 0.1, ref: 1, track: 0, local_prompt: "cut back to Speaker A, haunted pause, close controlled emotion, audio-driven speaking performance" },
{ id: "line_004", speaker: "B", text: "No. You saw me in mine.", emotion: "mysterious", style: "slow, unsettling", paralinguistic: "none", overlap_after: 0, ref: 2, track: 1, local_prompt: "reverse close-up on Speaker B, slow final line, quiet mystery, natural lip sync driven by external audio" },
{ id: "line_001", speaker: "A", text: "I have not seen you in such a long time.", emotion: "wistful", style: "soft, intimate", paralinguistic: "none", overlap_after: 0.12, ref: 1, track: 0, local_prompt: "hard cut, Speaker A intimate close-up, Speaker A speaks clearly, visible mouth movement, controlled expression" },
{ id: "line_002", speaker: "B", text: "No, that is not true. You are mistaken.", emotion: "calm denial", style: "quiet, certain", paralinguistic: "none", overlap_after: 0.12, ref: 2, track: 1, local_prompt: "hard cut, Speaker B close-up, Speaker B speaks clearly, visible mouth movement, steady gaze" },
{ id: "line_003", speaker: "A", text: "Maybe I saw you in my dreams.", emotion: "uncertain, haunted", style: "low, reflective", paralinguistic: "Breathing", overlap_after: 0.1, ref: 1, track: 0, local_prompt: "hard cut, Speaker A close-up, Speaker A speaks clearly, visible mouth movement, haunted controlled emotion" },
{ id: "line_004", speaker: "B", text: "No. You saw me in mine.", emotion: "mysterious", style: "slow, unsettling", paralinguistic: "none", overlap_after: 0, ref: 2, track: 1, local_prompt: "hard cut, Speaker B close-up, Speaker B speaks clearly, visible mouth movement, quiet mystery" },
];
}
function parseData(node) {
@@ -196,6 +196,53 @@ function ttsExportText(data, mode) {
return String(line.speaker || "A") + " (" + lineDirection(line) + "): " + cleanDialogueText(line.text);
}).join("\n");
}
function srtTime(seconds) {
const total = Math.max(0, Number(seconds || 0));
const whole = Math.floor(total);
const millis = Math.min(999, Math.max(0, Math.round((total - whole) * 1000)));
const h = Math.floor(whole / 3600);
const m = Math.floor((whole % 3600) / 60);
const s = whole % 60;
return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0") + "," + String(millis).padStart(3, "0");
}
function speakerLines(data, speakerIndex = 0) {
const speakerIds = Array.from(new Set((data?.lines || []).map((line) => String(line?.speaker || "A").toUpperCase()))).filter(Boolean);
const speaker = speakerIds[speakerIndex] || (speakerIndex === 0 ? "A" : "B");
return (Array.isArray(data?.lines) ? data.lines : []).filter((line) => String(line?.speaker || "A").toUpperCase() === speaker);
}
function speakerPlainText(data, speakerIndex = 0) {
return speakerLines(data, speakerIndex)
.map((line) => cleanDialogueText(line?.text || ""))
.filter(Boolean)
.join("\n");
}
function speakerSrtText(data, speakerIndex = 0) {
const speakerIds = Array.from(new Set((data?.lines || []).map((line) => String(line?.speaker || "A").toUpperCase()))).filter(Boolean);
const selectedSpeaker = speakerIds[speakerIndex] || (speakerIndex === 0 ? "A" : "B");
const allLines = Array.isArray(data?.lines) ? data.lines : [];
if (!allLines.length) return "";
let cursor = 0;
const selected = [];
allLines.forEach((line) => {
const duration = Number(line?.duration || 0) > 0 ? Number(line.duration) : estimateSeconds(line?.text);
const absoluteStart = line?.start !== undefined && line?.start !== "" ? Number(line.start || 0) : cursor;
if (String(line?.speaker || "A").toUpperCase() === selectedSpeaker) {
selected.push({ line, duration, absoluteStart });
}
cursor = Math.max(cursor, absoluteStart + duration + Number(data?.settings?.default_gap_seconds || 0.12) - Math.max(0, Number(line?.overlap_after || 0)));
});
if (!selected.length) return "";
const localSrt = data?.settings?.speaker_stem_srt_local_zero !== false && modeFromData(data) !== "tts_master_unico";
const offset = localSrt ? Math.min(...selected.map((item) => Number(item.absoluteStart) || 0).filter((value) => Number.isFinite(value))) : 0;
const parts = [];
selected.forEach(({ line, duration, absoluteStart }, index) => {
const start = Math.max(0, absoluteStart - Math.max(0, offset));
const end = Math.max(start + 0.2, start + duration);
const text = cleanDialogueText(line?.text || "");
if (text) parts.push(String(index + 1) + "\n" + srtTime(start) + " --> " + srtTime(end) + "\n" + text + "\n");
});
return parts.join("\n").trim();
}
async function copyTextToClipboard(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(String(text || ""));
@@ -309,9 +356,28 @@ function firstSlotIndex(slots, names = [], typeHint = "") {
function connectBySlotName(source, outputNames, target, inputNames, typeHint = "") {
const out = firstSlotIndex(source?.outputs, outputNames, typeHint);
const input = firstSlotIndex(target?.inputs, inputNames, typeHint);
if (out < 0 || input < 0) return false;
if (out < 0 || input < 0) {
console.warn("[IAMCCS DialogueTagEditor] rig slot not found", {
source: source?.title || source?.type,
target: target?.title || target?.type,
outputNames,
inputNames,
sourceOutputs: (source?.outputs || []).map((slot) => slot?.name || slot?.type),
targetInputs: (target?.inputs || []).map((slot) => slot?.name || slot?.type),
});
return false;
}
try {
if (target?.inputs?.[input]?.link != null) {
target.disconnectInput?.(input);
}
source.connect(out, target, input);
console.info("[IAMCCS DialogueTagEditor] rig connected", {
from: source?.title || source?.type,
output: source?.outputs?.[out]?.name,
to: target?.title || target?.type,
input: target?.inputs?.[input]?.name,
});
return true;
} catch (err) {
console.warn("[IAMCCS DialogueTagEditor] rig connect failed", outputNames, inputNames, err);
@@ -344,22 +410,30 @@ function createGraphNode(lite, graph, type, pos, title = "") {
graph.add(node);
return node;
}
function configureRigTTSNode(node, type, speakerIndex = 0) {
function configureRigTTSNode(node, type, speakerIndex = 0, data = null) {
const lower = String(type || "").toLowerCase();
const routing = emotionRoutingValue(data || {});
if (lower.includes("chatterbox")) {
setNodeWidgetValue(node, ["language"], "English");
setNodeWidgetValue(node, ["device"], "auto");
setNodeWidgetValue(node, ["exaggeration"], speakerIndex === 0 ? 0.5 : 0.35);
setNodeWidgetValue(node, ["model_version"], routing === "chatterbox_v2_tokens" ? "v2" : "v1");
setNodeWidgetValue(node, ["exaggeration"], routing === "chatterbox_v2_tokens" ? 0.78 : (speakerIndex === 0 ? 0.5 : 0.35));
setNodeWidgetValue(node, ["temperature"], speakerIndex === 0 ? 0.75 : 0.88);
setNodeWidgetValue(node, ["cfg_weight"], speakerIndex === 0 ? 0.5 : 0.45);
setNodeWidgetValue(node, ["crash_protection_template"], "hmm ,, {seg} hmm ,,");
}
if (lower.includes("index")) {
const useTextEmotion = routing === "index_tts_text_emotion";
setNodeWidgetValue(node, ["use_emotion_text", "enable_emotion_text", "text_emotion_enabled"], useTextEmotion);
setNodeWidgetValue(node, ["emotion_text", "text_emotion", "emotion_prompt"], useTextEmotion ? "{seg}" : "");
setNodeWidgetValue(node, ["emotion_alpha", "emotion_strength", "emotion_weight"], useTextEmotion ? 1.0 : 0.75);
}
}
function configureRigSRTNode(node, speakerIndex = 0) {
setNodeWidgetValue(node, ["narrator_voice"], speakerIndex === 0 ? "voices_examples/Clint_Eastwood CC3 (enhanced2).wav" : "voices_examples/David_Attenborough CC3.wav");
setNodeWidgetValue(node, ["seed"], speakerIndex === 0 ? 1284582220 : 1284582221);
setNodeWidgetValue(node, ["timing_mode"], "pad_with_silence");
setNodeWidgetValue(node, ["enable_audio_cache"], true);
setNodeWidgetValue(node, ["enable_audio_cache"], false);
setNodeWidgetValue(node, ["fade_for_StretchToFit"], 0.01);
setNodeWidgetValue(node, ["max_stretch_ratio"], 1);
setNodeWidgetValue(node, ["min_stretch_ratio"], 0.8);
@@ -455,9 +529,55 @@ function removeExistingAudioRigNodes(exportInfo, injectInfo) {
.filter(Boolean)
.forEach((targetNode) => {
if (removeNodeSafely(graph, targetNode)) removed += 1;
});
});
return removed;
}
function repairExistingDialogueRigLinks(editorNode, reason = "repair") {
if (!isEditor(editorNode)) return 0;
const graph = app?.graph;
const nodes = Array.isArray(graph?._nodes) ? graph._nodes : [];
const rigNodes = findExistingCineAudioRigNodes(editorNode);
const exportInfo = rigNodes.exportInfo;
if (!exportInfo) return 0;
const data = parseData(editorNode);
let fixed = 0;
nodes.forEach((targetNode) => {
if (nodeType(targetNode) !== "UnifiedTTSSRTNode") return;
const title = String(targetNode?.title || "").toLowerCase();
if (!title.includes("direct tts")) return;
let speakerIndex = -1;
if (/\bb\b|man b|speaker b/.test(title)) speakerIndex = 1;
else if (/\ba\b|man a|speaker a/.test(title)) speakerIndex = 0;
if (speakerIndex < 0) return;
const sourceName = speakerIndex === 0 ? "speaker_a_srt" : "speaker_b_srt";
const inputIndex = firstSlotIndex(targetNode.inputs, ["srt_content"], "");
const outputIndex = firstSlotIndex(exportInfo.outputs, [sourceName], "");
if (inputIndex < 0 || outputIndex < 0) {
console.warn("[IAMCCS DialogueTagEditor] existing A/B rig cannot be repaired", {
reason,
target: targetNode?.title || targetNode?.type,
wantedOutput: sourceName,
});
return;
}
const currentLink = targetNode.inputs?.[inputIndex]?.link;
const link = currentLink != null ? app?.graph?.links?.[currentLink] : null;
const currentOrigin = link ? graph.getNodeById?.(link.origin_id ?? link[1]) : null;
const currentOutputIndex = link ? (link.origin_slot ?? link[2]) : -1;
const currentOutputName = currentOrigin?.outputs?.[currentOutputIndex]?.name || "";
const alreadyCorrect = currentOrigin?.id === exportInfo.id && String(currentOutputName).toLowerCase() === sourceName;
if (!alreadyCorrect) {
if (connectBySlotName(exportInfo, [sourceName], targetNode, ["srt_content"], "")) fixed += 1;
}
setNodeWidgetValue(targetNode, ["enable_audio_cache"], false);
setNodeWidgetValue(targetNode, ["srt_content", "text", "dialogue", "prompt"], speakerSrtText(data, speakerIndex));
});
if (fixed) {
console.info("[IAMCCS DialogueTagEditor] repaired existing A/B SRT rig links", { reason, fixed });
graph?.setDirtyCanvas?.(true, true);
}
return fixed;
}
function addAvailableTTSStarterNodes(data, statusTarget, selectedTypes = []) {
const graph = app.graph;
const lite = window.LiteGraph || globalThis.LiteGraph;
@@ -538,16 +658,17 @@ function createCineAudioRig(editorNode, data, statusTarget, selectedType) {
}
if (!reusableExport) created.push(exportInfo);
if (!reusableInject) created.push(injectInfo);
const cineTtsTextMode = cineAudioTextModeFromEmotionRouting(data);
setNodeWidgetValue(exportInfo, ["mode"], isMono ? "export_tts_srt" : "export_speaker_stems");
setNodeWidgetValue(exportInfo, ["tts_text_mode"], "plain_dialogue");
setNodeWidgetValue(exportInfo, ["tts_text_mode"], cineTtsTextMode);
setNodeWidgetValue(exportInfo, ["frame_rate"], Number(widget(editorNode, "frame_rate")?.value || 24));
setNodeWidgetValue(exportInfo, ["lane_injection_mode"], "speaker_full_timeline_clips");
setNodeWidgetValue(exportInfo, ["file_prefix"], "dialogue_tts_export");
setNodeWidgetValue(injectInfo, ["mode"], isMono ? "inject_generated_audio" : "inject_speaker_stems");
setNodeWidgetValue(injectInfo, ["tts_text_mode"], "plain_dialogue");
setNodeWidgetValue(injectInfo, ["tts_text_mode"], cineTtsTextMode);
setNodeWidgetValue(injectInfo, ["lane_injection_mode"], isMono ? "single_master_clip" : "speaker_full_timeline_clips");
setNodeWidgetValue(injectInfo, ["frame_rate"], Number(widget(editorNode, "frame_rate")?.value || 24));
setNodeWidgetValue(injectInfo, ["file_prefix"], isMono ? "dialogue_tts_master" : "dialogue_tts_stem");
setNodeWidgetValue(injectInfo, ["file_prefix"], isMono ? "dialogue_tts_single_master" : "dialogue_tts_stem");
connectBySlotName(editorNode, ["cine_linx"], exportInfo, ["cine_linx"], "IAMCCS_SUPERNODE_LINX");
if (registry.IAMCCS_DialogueAudioBoardBridge) {
const bridge = rigNodes.bridge || createGraphNode(lite, graph, "IAMCCS_DialogueAudioBoardBridge", [baseX, baseY + 245], "Dialogue AudioBoard Bridge - placeholder lanes");
@@ -565,15 +686,15 @@ function createCineAudioRig(editorNode, data, statusTarget, selectedType) {
const node = createGraphNode(lite, graph, selectedType, [baseX + 300, baseY + index * 245], "DIRECT TTS " + label + " - " + ttsNodeLabel(selectedType));
if (!node) return null;
created.push(node);
configureRigTTSNode(node, selectedType, index);
configureRigTTSNode(node, selectedType, index, data);
const mode = String(data.settings?.tts_export_mode || "qwen");
setNodeWidgetValue(node, ["text", "dialogue", "prompt", "target_text", "srt_content"], isMono ? ttsExportText(data, mode) : "");
setNodeWidgetValue(node, ["instruct", "custom_instruct", "instruction", "system_prompt"], ttsExportText(data, "qwen"));
setNodeWidgetValue(node, ["text", "dialogue", "prompt", "target_text", "srt_content"], isMono ? ttsExportText(data, mode) : speakerPlainText(data, index));
setNodeWidgetValue(node, ["instruct", "custom_instruct", "instruction", "system_prompt"], isMono ? ttsExportText(data, "qwen") : speakerPlainText(data, index));
return node;
};
const sourceFor = (speakerIndex) => {
if (isMono) return ["tts_srt", "tts_text"];
return speakerIndex === 0 ? ["speaker_a_srt", "tts_srt"] : ["speaker_b_srt", "tts_srt"];
return speakerIndex === 0 ? ["speaker_a_srt"] : ["speaker_b_srt"];
};
const targetAudioInput = (speakerIndex) => isMono || speakerIndex === 0 ? ["generated_audio"] : ["generated_audio_b"];
@@ -584,11 +705,13 @@ function createCineAudioRig(editorNode, data, statusTarget, selectedType) {
return created.length;
}
created.push(probe);
configureRigTTSNode(probe, selectedType, 0);
configureRigTTSNode(probe, selectedType, 0, data);
setNodeWidgetValue(probe, ["text", "dialogue", "prompt", "target_text", "srt_content"], isMono ? ttsExportText(data, String(data.settings?.tts_export_mode || "qwen")) : speakerPlainText(data, 0));
setNodeWidgetValue(probe, ["instruct", "custom_instruct", "instruction", "system_prompt"], isMono ? ttsExportText(data, "qwen") : speakerPlainText(data, 0));
const selectedIsEngine = nodeHasOutputType(probe, "TTS_ENGINE");
const selectedIsAudio = nodeHasOutputType(probe, "AUDIO");
if (selectedIsEngine && registry.UnifiedTTSSRTNode) {
setNodeWidgetValue(probe, ["instruct", "custom_instruct", "instruction", "system_prompt"], ttsExportText(data, "qwen"));
setNodeWidgetValue(probe, ["instruct", "custom_instruct", "instruction", "system_prompt"], isMono ? ttsExportText(data, "qwen") : speakerPlainText(data, 0));
const engineNodes = [probe];
if (!isMono) {
const secondEngine = makeSelected(1, "B");
@@ -600,8 +723,9 @@ function createCineAudioRig(editorNode, data, statusTarget, selectedType) {
if (!srt) continue;
created.push(srt);
configureRigSRTNode(srt, i);
setNodeWidgetValue(srt, ["srt_content", "text", "dialogue", "prompt"], isMono ? ttsExportText(data, "chatterbox") : speakerSrtText(data, i));
connectBySlotName(engineNodes[i] || probe, ["TTS_engine", "tts_engine"], srt, ["TTS_engine", "tts_engine"], "TTS_ENGINE");
connectBySlotName(exportInfo, sourceFor(i), srt, ["srt_content"], "STRING");
connectBySlotName(exportInfo, sourceFor(i), srt, ["srt_content"], isMono ? "STRING" : "");
connectBySlotName(srt, ["audio"], injectInfo, targetAudioInput(i), "AUDIO");
connectBySlotName(srt, ["Adjusted_SRT", "adjusted_srt"], injectInfo, isMono || i === 0 ? ["adjusted_srt"] : ["adjusted_srt_b"], "STRING");
if (i === 0) connectBySlotName(srt, ["timing_report"], injectInfo, ["timing_report"], "STRING");
@@ -613,7 +737,7 @@ function createCineAudioRig(editorNode, data, statusTarget, selectedType) {
if (second) nodes.push(second);
}
nodes.forEach((ttsNode, index) => {
connectBySlotName(exportInfo, sourceFor(index), ttsNode, ["text", "dialogue", "prompt", "target_text", "srt_content"], "STRING");
connectBySlotName(exportInfo, sourceFor(index), ttsNode, ["text", "dialogue", "prompt", "target_text", "srt_content"], isMono ? "STRING" : "");
connectBySlotName(ttsNode, ["audio", "AUDIO"], injectInfo, targetAudioInput(index), "AUDIO");
});
} else {
@@ -624,7 +748,7 @@ function createCineAudioRig(editorNode, data, statusTarget, selectedType) {
if (statusTarget) {
const reuseText = reusableExport || reusableInject ? "Reused existing CineAudioInfo nodes. " : "";
const removeText = removedOldRigNodes ? "Removed " + removedOldRigNodes + " old TTS rig node" + (removedOldRigNodes === 1 ? ". " : "s. ") : "";
statusTarget.textContent = reuseText + removeText + "Created " + (isMono ? "mono" : "A/B") + " rig for " + ttsNodeLabel(selectedType) + ".";
statusTarget.textContent = reuseText + removeText + "Created " + (isMono ? "mono" : "A/B") + " rig for " + ttsNodeLabel(selectedType) + ". Emotion routing: " + emotionRoutingLabel(emotionRoutingValue(data)) + ".";
}
return created.length;
}
@@ -635,6 +759,7 @@ function parseScript(text, data) {
const value = raw.trim();
if (!value) return;
const match = value.match(/^\[([^\]]+)\]\s*(.*)$/);
const atSpeakerMatch = value.match(/^@([A-Za-z])#\s*(.*)$/);
const line = { ...(old[index] || {}), id: old[index]?.id || "line_" + String(index + 1).padStart(3, "0"), speaker: index % 2 ? "B" : "A", text: value, emotion: "none", style: "none", paralinguistic: "none", tts_model: "", extra_tags: [], overlap_after: 0, ref: index % 2 ? 2 : 1, track: index % 2 ? 1 : 0 };
if (match) {
line.text = match[2].trim();
@@ -656,9 +781,12 @@ function parseScript(text, data) {
if (/style/i.test(key) && line.style === "none") line.style = val || "none";
}
});
} else if (atSpeakerMatch) {
line.speaker = String(atSpeakerMatch[1] || "A").toUpperCase();
line.text = String(atSpeakerMatch[2] || "").trim();
}
line.track = line.speaker === "B" ? 1 : 0;
line.local_prompt ||= line.speaker === "B" ? "reverse field shot on Man B, natural lip sync driven by external audio" : "field shot on Man A, natural lip sync driven by external audio";
line.local_prompt ||= line.speaker === "B" ? "hard cut, Man B close-up, Man B speaks clearly, visible mouth movement, Man A listens quietly" : "hard cut, Man A close-up, Man A speaks clearly, visible mouth movement, Man B listens quietly";
lines.push(line);
});
return { ...data, lines };
@@ -758,6 +886,126 @@ function applyOverlapToTaggedLine(sourceText, caret, value) {
return { text: updatedText, caret: lineStart + overlapIndex + token.length };
}
function applyMetadataToTaggedLine(sourceText, caret, key, rawValue) {
const text = String(sourceText || "");
const cleanKey = String(key || "").trim();
const cleanValue = String(rawValue || "").trim();
const safeCaret = Math.max(0, Math.min(Number(caret || 0), text.length));
const lineStart = text.lastIndexOf("\n", Math.max(0, safeCaret - 1)) + 1;
const nextNewline = text.indexOf("\n", safeCaret);
const lineEnd = nextNewline === -1 ? text.length : nextNewline;
const line = text.slice(lineStart, lineEnd);
const bracketMatch = line.match(/^\[([^\]]*)\](\s*)(.*)$/);
const atSpeakerMatch = line.match(/^@([A-Za-z])#\s*(.*)$/);
const speaker = bracketMatch
? String(bracketMatch[1] || "A").split("|")[0].trim().slice(0, 1).toUpperCase()
: atSpeakerMatch
? String(atSpeakerMatch[1] || "A").toUpperCase()
: "A";
const body = bracketMatch ? (bracketMatch[3] || "") : atSpeakerMatch ? (atSpeakerMatch[2] || "") : line;
const rawParts = bracketMatch ? String(bracketMatch[1] || "").split("|").map((part) => part.trim()).filter(Boolean) : [speaker];
const parts = [];
let found = false;
rawParts.forEach((part, index) => {
if (index === 0 && !part.includes(":")) {
parts.push(speaker || part);
return;
}
const [partKey] = part.split(":");
if (partKey.trim() === cleanKey) {
parts.push(cleanKey + ":" + cleanValue);
found = true;
} else {
parts.push(part);
}
});
if (!parts.length || parts[0].includes(":")) parts.unshift(speaker || "A");
if (!found) parts.push(cleanKey + ":" + cleanValue);
const updatedLine = "[" + parts.join("|") + "] " + body.trimStart();
const updatedText = text.slice(0, lineStart) + updatedLine + text.slice(lineEnd);
const token = cleanKey + ":" + cleanValue;
const tokenIndex = updatedLine.indexOf(token);
return { text: updatedText, caret: lineStart + (tokenIndex >= 0 ? tokenIndex + token.length : updatedLine.length) };
}
function lineBoundsForCaret(text, caret) {
const value = String(text || "");
const safeCaret = Math.max(0, Math.min(Number(caret || 0), value.length));
const lineStart = value.lastIndexOf("\n", Math.max(0, safeCaret - 1)) + 1;
const nextNewline = value.indexOf("\n", safeCaret);
const lineEnd = nextNewline === -1 ? value.length : nextNewline;
return { safeCaret, lineStart, lineEnd, line: value.slice(lineStart, lineEnd), localCaret: safeCaret - lineStart };
}
function speakerFromTaggedLine(line) {
const bracket = String(line || "").match(/^\[([^\]]*)\]/);
if (bracket) {
const head = String(bracket[1] || "A").split(/[|:]/)[0].trim();
if (head) return head.slice(0, 1).toUpperCase();
}
const at = String(line || "").match(/^@([A-Za-z])#/);
if (at) return String(at[1] || "A").toUpperCase();
return "A";
}
function normalizeInlineTagValue(value) {
return String(value || "").trim().replace(/[\[\]<>|]/g, " ").replace(/\s+/g, "_");
}
function chatterboxTokenFor(kind, value) {
const raw = String(value || "").trim();
const key = raw.toLowerCase();
const map = {
breathing: "inhale", laughter: "laughter", sigh: "sigh", uhm: "UM", "surprise-oh": "gasp",
whisper: "whisper", murmur: "mumble", low: "mumble", shout: "gasp", excited: "gasp", surprised: "gasp", fearful: "gasp", sad: "sigh", angry: "groan"
};
const token = map[key] || (kind === "para" ? raw : "");
return token ? "<" + normalizeInlineTagValue(token) + "> " : "";
}
function performanceTagTokenFor(kind, value, routing, speaker) {
const clean = normalizeInlineTagValue(value);
const route = String(routing || "clean_metadata");
const who = normalizeInlineTagValue(speaker || "A") || "A";
if (!clean) return "";
if (route === "index_tts_character_tags") return "[" + who + ":" + clean + "] ";
if (route === "index_tts_text_emotion") return "[" + who + ":" + clean + "] ";
if (route === "chatterbox_v2_tokens") return chatterboxTokenFor(kind, clean) || "<" + clean + "> ";
if (route === "step_editx_tags") return kind === "para" ? "<" + clean + "> " : "<" + kind + ":" + clean + "> ";
return kind === "para" ? "<" + clean + "> " : "[" + who + ":" + clean + "] ";
}
function insertPerformanceTagAtTaggedCursor(sourceText, caret, kind, rawValue, routing) {
const text = String(sourceText || "");
const bounds = lineBoundsForCaret(text, caret);
const speaker = speakerFromTaggedLine(bounds.line);
const token = performanceTagTokenFor(kind, rawValue, routing, speaker);
if (!token) return { text, caret: bounds.safeCaret };
const structuralMatch = bounds.line.match(/^\[[^\]]*\]/);
const structuralEnd = structuralMatch ? structuralMatch[0].length : 0;
const tagRegex = /(\[[^\]]+:[^\]]+\]|<[^>]+>)/g;
let match;
while ((match = tagRegex.exec(bounds.line))) {
const start = match.index;
const end = start + match[0].length;
if (start === 0 && end <= structuralEnd && bounds.line.slice(0, end).includes("|")) continue;
if (bounds.localCaret >= start && bounds.localCaret <= end) {
const from = bounds.lineStart + start;
const to = bounds.lineStart + end;
const nextText = text.slice(0, from) + token + text.slice(to).replace(/^\s+/, "");
return { text: nextText, caret: from + token.length };
}
}
let insertAt = bounds.safeCaret;
if (structuralEnd && bounds.localCaret <= structuralEnd) insertAt = bounds.lineStart + structuralEnd + (bounds.line.charAt(structuralEnd) === " " ? 1 : 0);
const before = text.slice(0, insertAt);
const after = text.slice(insertAt);
const leftPad = before && !/[\s]$/.test(before) ? " " : "";
const rightTrimmed = after.replace(/^\s+/, "");
const nextText = before + leftPad + token + rightTrimmed;
return { text: nextText, caret: before.length + leftPad.length + token.length };
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
@@ -1001,7 +1249,7 @@ function buildInjectionPayload(data, fps = 24) {
}
const durationFrames = Math.max(0, ...segments.map((s) => s.start + s.length), ...audioSegments.map((s) => s.start + s.length));
const audioTrackCount = modeFromData(data) === "tts_master_unico" ? 1 : 2;
const audioBoard = { schema: "iamccs.audio_board_arranger", schema_version: 1, audioSegments, audioTrackCount, audioSyncMode: "timeline_audio", duration_seconds: durationFrames / fps, frame_rate: fps, masterAudioGain: 1, masterAudioNormalize: false, speakerStemsZeroStart: zeroStartStems, bridgeStatus: { source: "DialogueTagEditor UI Inject", pending_tts: true } };
const audioBoard = { schema: "iamccs.audio_board_arranger", schema_version: 1, audioSegments, audioTrackCount, audioSyncMode: "timeline_audio", duration_seconds: durationFrames / fps, frame_rate: fps, masterAudioGain: 1, masterAudioNormalize: false, speakerStemsZeroStart: zeroStartStems, speakerStemSrtLocalZero: data.settings?.speaker_stem_srt_local_zero !== false, bridgeStatus: { source: "DialogueTagEditor UI Inject", pending_tts: true } };
const timeline = { schema: "iamccs.cine.filmmaker_timeline", schema_version: 2, global_prompt: data.global_prompt || "", prompt: data.global_prompt || "", promptrelay_enabled: true, use_custom_audio: false, audioSyncMode: "timeline_audio", duration_seconds: durationFrames / fps, frame_rate: fps, director_local_prompts: localPrompts.join(" | "), local_prompts: localPrompts.join(" | "), director_segment_lengths: lengths.join(","), segment_lengths: lengths.join(","), segments, audioSegments, audioTrackCount, dialogue: data };
return { audioBoard, timeline };
}
@@ -1068,12 +1316,96 @@ function fieldLabel(text, extra = "") {
label.innerHTML = "<span>" + text + "</span><span>" + extra + "</span>";
return label;
}
function emotionRoutingValue(data) {
const value = String(data?.settings?.emotion_routing || "clean_metadata");
if (value === "index_tts_text_emotion") return "index_tts_text_emotion";
if (value === "index_tts_character_tags") return "index_tts_character_tags";
if (value === "chatterbox_v2_tokens") return "chatterbox_v2_tokens";
if (value === "step_editx_tags") return "step_editx_tags";
return "clean_metadata";
}
function cineAudioTextModeFromEmotionRouting(data) {
const value = emotionRoutingValue(data);
if (value === "step_editx_tags") return "tts_audio_suite_tags";
if (value === "index_tts_text_emotion") return "index_tts_text_emotion";
if (value === "index_tts_character_tags") return "index_tts_character_tags";
if (value === "chatterbox_v2_tokens") return "chatterbox_v2_tokens";
return "plain_dialogue";
}
function emotionRoutingLabel(value) {
const key = String(value || "");
if (key === "index_tts_text_emotion") return "IndexTTS text emotion";
if (key === "index_tts_character_tags") return "IndexTTS character tags";
if (key === "chatterbox_v2_tokens") return "ChatterBox v2 tokens";
if (key === "step_editx_tags") return "Step EditX tags";
return "Clean metadata";
}
function installDialogueLowZoomOverlay(node) {
if (node) node._iamccsDialogueLowZoomOverlay = true;
return;
if (!node || node._iamccsDialogueLowZoomOverlay) return;
const previous = node.onDrawForeground;
node.onDrawForeground = function(ctx) {
if (typeof previous === "function") previous.apply(this, arguments);
const scale = Math.max(0.12, Number(app?.canvas?.ds?.scale || 1));
if (!ctx || scale >= 0.62) return;
let data = {};
try { data = parseData(this); } catch {}
const lines = Array.isArray(data.lines) ? data.lines : [];
const speakers = Array.from(new Set(lines.map((line) => String(line?.speaker || "A").toUpperCase()))).filter(Boolean);
const mode = modeFromData(data);
const routing = emotionRoutingLabel(data.settings?.emotion_routing || "clean_metadata");
const drawLines = [
"Dialogue Tag Editor mini view",
`${lines.length} lines / ${speakers.length || 1} speaker${speakers.length === 1 ? "" : "s"}`,
mode === "tts_master_unico" ? "single master mode" : "A/B speaker stems mode",
`emotion routing: ${routing}`,
];
const nodeW = Math.max(340, Number(this.size?.[0] || 420));
const nodeH = Math.max(180, Number(this.size?.[1] || 240));
const boost = Math.max(1.2, Math.min(3.4, 0.72 / scale));
const pad = 12 * boost;
const lineH = 18 * boost;
const titleFont = Math.round(13 * boost);
const bodyFont = Math.round(11 * boost);
const w = Math.max(240, Math.min(nodeW - pad * 2, 700 * boost));
const h = 34 * boost + drawLines.length * lineH;
const x = pad;
const y = Math.min(Math.max(64, 52 * boost), Math.max(46, nodeH - h - pad));
ctx.save();
ctx.globalAlpha = 0.96;
ctx.fillStyle = "rgba(7,17,18,.93)";
ctx.strokeStyle = "rgba(143,208,204,.72)";
ctx.lineWidth = Math.max(1.5, 1.2 * boost);
if (typeof ctx.roundRect === "function") {
ctx.beginPath();
ctx.roundRect(x, y, w, h, 8 * boost);
ctx.fill();
ctx.stroke();
} else {
ctx.fillRect(x, y, w, h);
ctx.strokeRect(x, y, w, h);
}
ctx.fillStyle = "rgba(239,204,139,.95)";
ctx.fillRect(x, y, Math.max(4, 3 * boost), h);
ctx.fillStyle = "#F4D49E";
ctx.font = `900 ${titleFont}px sans-serif`;
ctx.fillText(drawLines[0], x + 12 * boost, y + 21 * boost);
ctx.fillStyle = "#BFD7D5";
ctx.font = `800 ${bodyFont}px sans-serif`;
for (let i = 1; i < drawLines.length; i += 1) ctx.fillText(drawLines[i], x + 12 * boost, y + 21 * boost + i * lineH);
ctx.restore();
};
node._iamccsDialogueLowZoomOverlay = true;
}
function install(node, reason = "install") {
if (!isEditor(node) || node._iamccsDialogueTagEditorReady) return;
ensureStyle();
node._iamccsDialogueTagEditorReady = true;
installDialogueLowZoomOverlay(node);
["dialogue_data", "frame_rate", "speech_wpm", "min_line_seconds", "default_gap_seconds", "output_mode", "inline_edit_mode"].forEach((name) => hideWidget(widget(node, name)));
let data = parseData(node);
setTimeout(() => repairExistingDialogueRigLinks(node, reason), 0);
let scriptText = linesToText(data);
let zoom = Number(data.settings?.font_zoom || 1);
let light = String(data.settings?.text_theme || "light_boxes") === "light_boxes";
@@ -1124,12 +1456,30 @@ function install(node, reason = "install") {
side.append(fieldLabel("Template", "writes example"), templateSelect);
// By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
side.append(fieldLabel("TTS Mode"));
const ttsMode = select([["double_stem_ab", "Double stem A/B"], ["tts_master_unico", "TTS master unico"]], modeFromData(data));
const ttsMode = select([["double_stem_ab", "Double stem A/B"], ["tts_master_unico", "TTS single master"]], modeFromData(data));
ttsMode.onchange = () => { data.settings.tts_generation_mode = ttsMode.value; data.settings.output_mode = outputMode(ttsMode.value); save(); render(); };
side.append(ttsMode, fieldLabel("Tag Mode"));
const tagMode = select([["metadata_only", "Metadata only"], ["tts_audio_suite_inline_tags", "Inline tags"]], data.settings.inline_edit_mode || "metadata_only");
tagMode.onchange = () => { data.settings.inline_edit_mode = tagMode.value; save(); };
side.append(tagMode);
side.append(fieldLabel("Emotion Routing", "working paths"));
const emotionRoute = select([
["clean_metadata", "Clean metadata"],
["index_tts_text_emotion", "IndexTTS text emotion"],
["index_tts_character_tags", "IndexTTS character tags"],
["chatterbox_v2_tokens", "ChatterBox v2 tokens"],
["step_editx_tags", "Step EditX tags"],
], emotionRoutingValue(data));
emotionRoute.title = "Clean metadata strips tags. IndexTTS text emotion sets emotion text on compatible IndexTTS rigs. IndexTTS character tags exports [Speaker:emotion]. ChatterBox v2 tokens are experimental. Step EditX tags are for the Step EditX inline path.";
emotionRoute.onchange = () => {
data.settings ||= {};
data.settings.emotion_routing = emotionRoute.value;
if (emotionRoute.value.startsWith("index_tts")) data.settings.tts_export_mode = "index";
if (emotionRoute.value === "chatterbox_v2_tokens") data.settings.tts_export_mode = "chatterbox";
save();
render();
};
side.append(emotionRoute);
const speakersTitle = document.createElement("h4");
speakersTitle.textContent = "Speakers";
side.append(speakersTitle);
@@ -1161,7 +1511,7 @@ function install(node, reason = "install") {
const ttsFormatStatus = document.createElement("div");
ttsFormatStatus.className = "iamccs-overlap-hint";
const refreshTtsFormatStatus = () => {
ttsFormatStatus.textContent = "Selected TTS profile: " + activeTtsMode + " | CineAudioInfo export stays plain_dialogue.";
ttsFormatStatus.textContent = "Selected TTS profile: " + activeTtsMode + " | Emotion routing: " + emotionRoutingLabel(emotionRoutingValue(data)) + " / " + cineAudioTextModeFromEmotionRouting(data) + ".";
};
let applyTtsModeToScript = null;
const paintConvertButtons = (successMode = "") => {
@@ -1251,7 +1601,7 @@ function install(node, reason = "install") {
scriptArea.addEventListener("blur", persistScriptHeight);
const statusText = () => {
const overlapCount = (data.lines || []).filter((line) => Number(line.overlap_after || 0) > 0).length;
return data.lines.length + " lines | " + (modeFromData(data) === "tts_master_unico" ? "TTS master unico" : "Double stem A/B") + " | overlap lines " + overlapCount;
return data.lines.length + " lines | " + (modeFromData(data) === "tts_master_unico" ? "TTS single master" : "Double stem A/B") + " | overlap lines " + overlapCount;
};
const overlapValue = document.createElement("input");
overlapValue.type = "number";
@@ -1303,6 +1653,19 @@ function install(node, reason = "install") {
renderCards(cardsGrid);
scriptArea.focus();
};
const insertPerformanceAtCursor = (key, rawValue) => {
const currentText = editorPlainText(scriptArea);
const offset = caretOffset(scriptArea);
const updated = insertPerformanceTagAtTaggedCursor(currentText, offset, key, rawValue, emotionRoutingValue(data));
scriptText = updated.text;
data = parseScript(scriptText, data);
save();
status.textContent = statusText();
paintScriptEditor(false);
setCaretOffset(scriptArea, updated.caret);
renderCards(cardsGrid);
scriptArea.focus();
};
scriptSection.append(fieldLabel("Dialogue Script", "editor colorato"), scriptArea);
const cardsSection = document.createElement("div");
cardsSection.className = "iamccs-dte-section fill";
@@ -1341,9 +1704,12 @@ function install(node, reason = "install") {
tags.className = "iamccs-dte-tags";
// By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
tags.append(
tagPanel("Emotion", EMOTIONS, (name) => modelAwareTagToken("emotion", name, activeTtsMode), () => scriptArea),
tagPanel("Style", STYLES, (name) => modelAwareTagToken("style", name, activeTtsMode), () => scriptArea),
tagPanel("Inline", PARAS, (name) => name.startsWith("pause") ? " [" + name + "]" : " <" + name + ">", () => scriptArea)
tagPanel("Emotion", EMOTIONS, (name) => modelAwareTagToken("emotion", name, activeTtsMode), () => scriptArea, (name) => insertPerformanceAtCursor("emotion", name)),
tagPanel("Style", STYLES, (name) => modelAwareTagToken("style", name, activeTtsMode), () => scriptArea, (name) => insertPerformanceAtCursor("style", name)),
tagPanel("Inline", PARAS, (name) => name.startsWith("pause") ? " [" + name + "]" : " <" + name + ">", () => scriptArea, (name) => {
if (name.startsWith("pause")) insertAt(scriptArea, " [" + name + "] ");
else insertPerformanceAtCursor("para", name);
})
);
main.append(side, center, tags);
const foot = document.createElement("div");
@@ -1466,7 +1832,7 @@ function toolbarButtons(toolbar, target) {
});
}
// By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
function tagPanel(title, values, makeToken, getFallbackTarget = null) {
function tagPanel(title, values, makeToken, getFallbackTarget = null, onPick = null) {
const panel = document.createElement("div");
const h = document.createElement("h4");
h.textContent = title;
@@ -1475,6 +1841,10 @@ function tagPanel(title, values, makeToken, getFallbackTarget = null) {
values.forEach((value) => {
const btn = button(value, "");
btn.onclick = () => {
if (typeof onPick === "function") {
onPick(value);
return;
}
const active = document.activeElement;
const focused = active?.tagName === "TEXTAREA" || active?.isContentEditable ? active : null;
const fallback = typeof getFallbackTarget === "function" ? getFallbackTarget() : null;
@@ -1553,7 +1923,10 @@ app.registerExtension({
setup() {
[300, 1200, 2500].forEach((delay) => setTimeout(() => {
const nodes = Array.isArray(app?.graph?._nodes) ? app.graph._nodes : [];
nodes.forEach((node) => install(node, "scan+" + delay));
nodes.forEach((node) => {
install(node, "scan+" + delay);
if (isEditor(node)) repairExistingDialogueRigLinks(node, "scan+" + delay);
});
}, delay));
},
nodeCreated(node) { [0, 180, 600].forEach((delay) => setTimeout(() => install(node, "nodeCreated+" + delay), delay)); },
File diff suppressed because it is too large Load Diff
+61 -4
View File
@@ -82,11 +82,47 @@ function setTake(node, take) {
setWidget(node, "active_take", safeTake);
try {
window.dispatchEvent(new CustomEvent("iamccs:multigeneration-active-take", {
detail: { nodeId: node?.id, activeTake: safeTake },
detail: {
nodeId: node?.id,
activeTake: safeTake,
timelineId: `T${String(safeTake).padStart(2, "0")}`,
audioLane: `A${safeTake}`,
source: "bridge",
},
}));
} catch {}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function queuePromptOnce() {
if (typeof app?.queuePrompt === "function") {
return await app.queuePrompt(0, 1);
}
const api = window?.comfyAPI?.api;
if (typeof api?.queuePrompt === "function") {
return await api.queuePrompt(0, 1);
}
throw new Error("ComfyUI queuePrompt API not available");
}
async function queueSingleBackendSequence(node, count, statusEl = null) {
const max = Math.max(1, Math.min(maxTakes(node), Math.round(Number(count) || 1)));
setWidget(node, "take_source_mode", "auto_detect_multi_lanes");
setWidget(node, "take_track_layout", "collapse_to_lane_1");
for (let take = 1; take <= max; take += 1) {
setTake(node, take);
if (statusEl) statusEl.textContent = `Queueing T${String(take).padStart(2, "0")} / A${take} on the single backend (${take}/${max})...`;
try { app.graph?.setDirtyCanvas?.(true, true); } catch {}
await sleep(120);
await queuePromptOnce();
await sleep(220);
}
if (statusEl) statusEl.textContent = `Queued ${max} takes on one backend: ${Array.from({ length: max }, (_, i) => `T${String(i + 1).padStart(2, "0")}/A${i + 1}`).join(" -> ")}.`;
}
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
@@ -306,7 +342,7 @@ function installBridgeUI(node, reason = "install") {
const card = document.createElement("button");
card.type = "button";
card.className = `iamccs-mtb-take${take === active ? " is-active" : ""}`;
card.innerHTML = `<strong>T${String(take).padStart(2, "0")}</strong><span>${take === active ? "prepared now" : "click to prepare"}</span>`;
card.innerHTML = `<strong>T${String(take).padStart(2, "0")} / A${take}</strong><span>${take === active ? "prepared now" : "click to prepare"}</span>`;
card.onclick = () => {
setTake(node, take);
render();
@@ -335,11 +371,32 @@ function installBridgeUI(node, reason = "install") {
setTake(node, 1);
render();
};
actions.append(prepare, auto);
const queueSeq = document.createElement("button");
queueSeq.type = "button";
queueSeq.textContent = "Queue Sequence / 1 Backend";
queueSeq.title = "Queues T1/A1, T2/A2, etc. by changing this bridge active_take on the same single backend workflow.";
actions.append(prepare, auto, queueSeq);
const ledger = document.createElement("div");
ledger.className = "iamccs-mtb-ledger";
ledger.textContent = `Active T${String(active).padStart(2, "0")} | ${chunkTemplate === "custom" ? customSeconds + "s" : chunkTemplate} | Auto reads BusOut indexed T lanes: T1-A1, T2-A2, T3-A3. Use TakePicker branches to queue multiple generations in one run.`;
ledger.textContent = `Active T${String(active).padStart(2, "0")} / A${active} | ${chunkTemplate === "custom" ? customSeconds + "s" : chunkTemplate} | Contract: T1=A1, T2=A2, T3=A3. One backend only: queue sequence changes active take between queued prompts.`;
queueSeq.onclick = async () => {
queueSeq.disabled = true;
queueSeq.classList.add("is-primary");
try {
const countMode = String(widget(node, "take_count_mode")?.value || "auto_from_audio");
const desired = countMode === "fixed_take_count"
? Math.max(1, Math.round(num(widget(node, "fixed_take_count")?.value, 3)))
: maxTakes(node);
await queueSingleBackendSequence(node, desired, ledger);
} catch (err) {
ledger.textContent = `Queue sequence failed: ${err?.message || err}`;
console.warn("[IAMCCS MultiTimelineBridge UI] single-backend queue failed", err);
} finally {
queueSeq.disabled = false;
queueSeq.classList.remove("is-primary");
}
};
root.append(head, grid, takeRow, actions, ledger);
};
+471
View File
@@ -0,0 +1,471 @@
import { app } from "../../scripts/app.js";
const STYLE_ID = "iamccs-shotboard-video-editor-style-v3";
const FIXED_SIZE = [1260, 940];
function nodeType(node) {
return String(node?.type || node?.comfyClass || node?.constructor?.type || "");
}
function isEditorNode(node) {
return nodeType(node) === "IAMCCS_ShotboardVideoEditor";
}
function widget(node, name) {
return (node?.widgets || []).find((item) => item?.name === name);
}
function setWidget(node, name, value) {
const item = widget(node, name);
if (!item) return false;
item.value = value;
try { item.callback?.(value); } catch {}
try { node.setDirtyCanvas?.(true, true); } catch {}
try { app.graph?.setDirtyCanvas?.(true, true); } catch {}
return true;
}
function hideWidget(item) {
if (!item) return;
item.hidden = true;
item.type = "hidden";
item.computeSize = () => [0, -4];
item.draw = () => {};
item.options = { ...(item.options || {}), hidden: true };
if (item.inputEl) item.inputEl.style.display = "none";
}
function hideRawWidgets(node) {
[
"editor_mode", "selected_take", "take_order", "audio_policy", "fps_mode", "override_fps",
"global_trim_in_seconds", "global_trim_out_seconds", "concat_plan_json", "clip_edits_json",
"editor_manifest_json",
].forEach((name) => hideWidget(widget(node, name)));
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, Number(value) || 0));
}
function parseOrder(text) {
const nums = String(text || "1,2").split(/[,;\s]+/).map((v) => Math.round(Number(v))).filter((v) => v >= 1 && v <= 8);
return nums.length ? nums : [1, 2];
}
function formatTime(seconds, fps = 24) {
const safe = Math.max(0, Number(seconds) || 0);
const frame = Math.round(safe * fps);
const s = Math.floor(safe % 60).toString().padStart(2, "0");
const m = Math.floor(safe / 60).toString().padStart(2, "0");
return `${m}:${s}.${String(frame % Math.round(fps)).padStart(2, "0")}`;
}
function flash(el, text) {
if (!el) return;
el.textContent = text;
el.classList.add("pulse");
setTimeout(() => el.classList.remove("pulse"), 220);
}
function readClipEdits(node) {
try { return JSON.parse(String(widget(node, "clip_edits_json")?.value || "{}")); } catch { return {}; }
}
function writeClipEdits(node, patch) {
const current = readClipEdits(node);
const next = { ...(current || {}), ...patch, updated_at: Date.now() };
setWidget(node, "clip_edits_json", JSON.stringify(next, null, 2));
}
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.iamccs-sve { box-sizing:border-box; width:100%; height:884px; padding:9px; border:1px solid rgba(244,212,158,.34); border-radius:8px; background:#071012; color:#ebffff; font:11px Inter,Arial,sans-serif; overflow:hidden; pointer-events:auto; }
.iamccs-sve * { box-sizing:border-box; }
.iamccs-sve button { min-height:28px; border-radius:5px; border:1px solid rgba(126,198,196,.52); background:linear-gradient(180deg,#24484d,#142e32); color:#efffff; cursor:pointer; font-weight:950; font-size:10px; padding:0 9px; }
.iamccs-sve button.active,.iamccs-sve button.primary { background:linear-gradient(180deg,#f5d89c,#c5964e); color:#171207; border-color:#ffe3a4; }
.iamccs-sve input,.iamccs-sve select { min-width:0; height:28px; border-radius:5px; border:1px solid rgba(94,161,161,.72); background:#061012; color:#efffff; font-size:10px; font-weight:850; padding:0 7px; }
.iamccs-sve-top { display:grid; grid-template-columns:220px 1fr; grid-template-rows:42px 42px; gap:7px; height:91px; margin-bottom:7px; }
.iamccs-sve-brand,.iamccs-sve-transport,.iamccs-sve-tools { border:1px solid rgba(255,255,255,.09); border-radius:7px; background:linear-gradient(180deg,#132326,#081112); overflow:hidden; }
.iamccs-sve-brand { padding:8px 10px; grid-row:1 / 3; }
.iamccs-sve-title { color:#fff0b8; font-size:13px; font-weight:950; }
.iamccs-sve-sub { color:#8fb4b3; font-size:9px; font-weight:850; margin-top:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.iamccs-sve-transport,.iamccs-sve-tools { display:flex; align-items:center; justify-content:center; gap:6px; padding:6px; min-width:0; }
.iamccs-sve-clock { min-width:104px; padding:5px 7px; border-radius:5px; background:#f3ffe9; border:2px solid #a7d59b; color:#0a7d23; font:12px Consolas,monospace; text-align:center; font-weight:950; }
.iamccs-sve-sourcebar { height:38px; display:grid; grid-template-columns:70px 1fr 130px; align-items:center; gap:7px; padding:6px 8px; border:1px solid rgba(255,255,255,.09); border-radius:7px; background:#081112; margin-bottom:7px; }
.iamccs-sve-sourcebar strong { color:#fff0b8; font-size:10px; }
.iamccs-sve-takes { display:flex; gap:5px; overflow:hidden; }
.iamccs-sve-take { min-width:50px; height:24px; padding:0 6px; font-size:9px; }
.iamccs-sve-monitor-row { display:grid; grid-template-columns:1fr 1fr; gap:8px; height:260px; margin-bottom:7px; }
.iamccs-sve.one-monitor .iamccs-sve-monitor-row { grid-template-columns:1fr; }
.iamccs-sve.one-monitor .iamccs-sve-monitor.source { display:none; }
.iamccs-sve-monitor { border:1px solid rgba(255,255,255,.10); border-radius:7px; background:linear-gradient(180deg,#0d1718,#030607); overflow:hidden; }
.iamccs-sve-monitor-head { height:24px; display:flex; align-items:center; justify-content:space-between; padding:0 8px; background:linear-gradient(180deg,#15292b,#0d181a); border-bottom:1px solid rgba(255,255,255,.08); color:#ffe5a8; font-weight:950; font-size:9px; text-transform:uppercase; }
.iamccs-sve-screen { height:216px; margin:9px; border-radius:6px; border:1px solid rgba(126,198,196,.28); background:radial-gradient(circle at 52% 45%,rgba(99,160,170,.18),transparent 32%),linear-gradient(135deg,#111b20,#06090b 52%,#141008); position:relative; overflow:hidden; }
.iamccs-sve-safe { position:absolute; inset:15px 24px; border:1px dashed rgba(255,255,255,.18); border-radius:4px; }
.iamccs-sve-screen-label { position:absolute; left:10px; bottom:8px; color:#eaffff; background:rgba(0,0,0,.50); border:1px solid rgba(255,255,255,.12); border-radius:4px; padding:4px 6px; font:10px Consolas,monospace; }
.iamccs-sve-controls { display:grid; grid-template-columns:1fr 80px 1fr 1fr 70px; gap:7px; margin-bottom:7px; border:1px solid rgba(255,255,255,.08); border-radius:7px; padding:7px; background:#080f10; }
.iamccs-sve-controls label { display:grid; gap:3px; min-width:0; color:#9db7b8; font-size:8px; text-transform:uppercase; font-weight:950; }
.iamccs-sve-timeline { height:344px; border:1px solid rgba(255,255,255,.10); border-radius:8px; overflow:hidden; background:#05090a; }
.iamccs-sve-ruler { height:34px; margin-left:116px; position:relative; border-bottom:1px solid rgba(255,255,255,.11); background:linear-gradient(180deg,#122022,#091113); cursor:ew-resize; user-select:none; }
.iamccs-sve-ruler-tick { position:absolute; top:0; bottom:0; width:1px; background:rgba(255,255,255,.14); }
.iamccs-sve-ruler-tick span { position:absolute; top:5px; left:4px; color:#b7d0cf; font-size:9px; font-weight:900; }
.iamccs-sve-tracks { height:310px; overflow:auto; position:relative; }
.iamccs-sve-playhead { position:absolute; top:0; bottom:0; width:2px; background:#ffe08d; z-index:20; box-shadow:0 0 10px rgba(255,224,141,.65); pointer-events:none; }
.iamccs-sve-track { min-height:74px; display:grid; grid-template-columns:116px 1fr; border-bottom:1px solid rgba(255,255,255,.12); }
.iamccs-sve-track.audio { min-height:68px; }
.iamccs-sve-label { padding:7px; border-right:1px solid rgba(255,255,255,.10); background:linear-gradient(90deg,rgba(36,68,65,.55),rgba(9,17,18,.92)); }
.iamccs-sve-label strong { color:#fff0b8; font-size:12px; display:block; }
.iamccs-sve-chiprow { display:flex; gap:4px; margin-top:5px; flex-wrap:wrap; }
.iamccs-sve-chip { height:18px; min-width:22px; border-radius:4px; border:1px solid rgba(126,198,196,.45); color:#dfffff; background:#102f34; font-size:8px; font-weight:950; display:flex; align-items:center; justify-content:center; cursor:pointer; }
.iamccs-sve-chip.active { background:#f3d08d; color:#171207; border-color:#ffe3a4; }
.iamccs-sve-lane { position:relative; min-width:980px; border-left:1px solid rgba(255,224,141,.18); background:repeating-linear-gradient(90deg,rgba(255,255,255,.07) 0 1px,transparent 1px 48px),linear-gradient(180deg,rgba(24,31,29,.50),rgba(8,10,11,.90)); }
.iamccs-sve-track:nth-child(even) .iamccs-sve-lane { background:repeating-linear-gradient(90deg,rgba(255,255,255,.06) 0 1px,transparent 1px 48px),linear-gradient(180deg,rgba(30,22,28,.48),rgba(8,10,11,.92)); }
.iamccs-sve-clip { position:absolute; top:10px; height:52px; border-radius:5px; border:1px solid rgba(255,224,160,.65); background:linear-gradient(180deg,#31677a,#16343f); box-shadow:inset 0 0 0 1px rgba(255,255,255,.08); overflow:hidden; cursor:grab; touch-action:none; }
.iamccs-sve-clip.audio { height:46px; background:linear-gradient(180deg,#386d9b,#173b63); border-color:#b9dcff; }
.iamccs-sve-clip.dragging { cursor:grabbing; box-shadow:0 0 0 2px rgba(255,224,141,.55),0 8px 20px rgba(0,0,0,.35); z-index:10; }
.iamccs-sve-clip-title { position:absolute; left:11px; top:5px; z-index:2; color:#fff1ba; font-weight:950; font-size:10px; text-shadow:0 1px 2px #000; pointer-events:none; }
.iamccs-sve-handle { position:absolute; top:0; bottom:0; width:9px; background:#ffe08d; z-index:3; cursor:ew-resize; }
.iamccs-sve-handle.left { left:0; }
.iamccs-sve-handle.right { right:0; }
.iamccs-sve-wave { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; }
.iamccs-sve-clip:not(.audio)::before { content:""; position:absolute; inset:0; background:repeating-linear-gradient(90deg, rgba(255,255,255,.12) 0 2px, transparent 2px 9px, rgba(0,0,0,.25) 9px 34px), linear-gradient(90deg, rgba(93,151,164,.34), rgba(23,54,64,.78), rgba(190,145,78,.28)); opacity:.92; }
.iamccs-sve-clip:not(.audio)::after { content:""; position:absolute; left:0; right:0; top:0; height:7px; background:repeating-linear-gradient(90deg,#071012 0 6px,#e8d8a3 6px 9px); opacity:.75; }
.iamccs-sve-actions { display:flex; gap:6px; align-items:center; justify-content:flex-end; margin-top:7px; }
.iamccs-sve-ledger { margin-top:6px; padding:6px 8px; border:1px solid rgba(255,255,255,.08); border-radius:5px; background:#030708; color:#b8fff1; font:10px Consolas,monospace; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.iamccs-sve-ledger.pulse { color:#171207; background:#f3d08d; }
`;
document.head.appendChild(style);
}
function drawWave(canvas, seed = 1) {
const rect = canvas.getBoundingClientRect();
const dpr = Math.max(1, window.devicePixelRatio || 1);
const w = Math.max(1, Math.round(rect.width * dpr));
const h = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, w, h);
const mid = h * 0.56;
const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, "rgba(235,249,255,.98)");
grad.addColorStop(1, "rgba(130,200,232,.82)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(0, mid);
for (let x = 0; x <= w; x += 2) {
const t = (x / Math.max(1, w)) * 22 + seed;
const amp = (Math.sin(t * 1.7) * .22 + Math.sin(t * 4.6) * .16 + Math.sin(t * 11.3) * .07 + .45);
const env = .28 + .72 * Math.abs(Math.sin(t * .31 + seed));
ctx.lineTo(x, mid - amp * env * h * .38);
}
for (let x = w; x >= 0; x -= 2) {
const t = (x / Math.max(1, w)) * 22 + seed;
const amp = (Math.sin(t * 1.7) * .22 + Math.sin(t * 4.6) * .16 + Math.sin(t * 11.3) * .07 + .45);
const env = .28 + .72 * Math.abs(Math.sin(t * .31 + seed));
ctx.lineTo(x, mid + amp * env * h * .34);
}
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(255,238,181,.72)";
ctx.lineWidth = Math.max(1, dpr);
ctx.beginPath();
ctx.moveTo(0, mid);
ctx.lineTo(w, mid);
ctx.stroke();
}
function installFixedNode(node) {
node.size = [...FIXED_SIZE];
node.resizable = false;
const originalOnResize = node.onResize;
node.onResize = function () {
this.size = [...FIXED_SIZE];
try { originalOnResize?.apply(this, arguments); } catch {}
};
}
function installEditorUI(node, reason = "install") {
if (!isEditorNode(node) || node._iamccsShotboardVideoEditorReady || typeof node.addDOMWidget !== "function") return;
node._iamccsShotboardVideoEditorReady = true;
ensureStyle();
hideRawWidgets(node);
installFixedNode(node);
const root = document.createElement("div");
root.className = "iamccs-sve";
const state = {
playhead: 0,
duration: 24,
oneMonitor: false,
clipMap: {},
buttons: {},
};
const clipKey = (type, take, pos) => `${type}_${take}_${pos}`;
function initClipState(order, perTake) {
order.forEach((take, pos) => {
["v", "a"].forEach((type) => {
const key = clipKey(type, take, pos);
if (!state.clipMap[key]) state.clipMap[key] = { start: pos * perTake, length: perTake };
});
});
}
function writeEditorState(node) {
writeClipEdits(node, {
ui_state: {
playhead: state.playhead,
oneMonitor: state.oneMonitor,
clips: state.clipMap,
},
});
}
function render() {
hideRawWidgets(node);
installFixedNode(node);
root.classList.toggle("one-monitor", Boolean(state.oneMonitor));
const mode = String(widget(node, "editor_mode")?.value || "assemble_sequence");
const selected = clamp(Math.round(Number(widget(node, "selected_take")?.value || 1)), 1, 8);
const orderText = String(widget(node, "take_order")?.value || "1,2");
const order = parseOrder(orderText);
const fps = Number(widget(node, "override_fps")?.value || 24);
const perTake = 8;
initClipState(order, perTake);
state.duration = Math.max(12, ...Object.values(state.clipMap).map((clip) => Number(clip.start || 0) + Number(clip.length || perTake)));
state.playhead = clamp(state.playhead, 0, state.duration);
root.innerHTML = "";
const top = document.createElement("div");
top.className = "iamccs-sve-top";
const brand = document.createElement("div");
brand.className = "iamccs-sve-brand";
brand.innerHTML = `<div class="iamccs-sve-title">Shotboard Video Editor</div><div class="iamccs-sve-sub">timeline editor / monitor / T-A takes</div>`;
const transport = document.createElement("div");
transport.className = "iamccs-sve-transport";
const ledger = document.createElement("div");
ledger.className = "iamccs-sve-ledger";
const clock = document.createElement("div");
clock.className = "iamccs-sve-clock";
const updateClock = () => { clock.textContent = formatTime(state.playhead, fps); };
updateClock();
[
["|<", () => { state.playhead = 0; updatePlayhead(); flash(ledger, "Go to start"); }],
["<<", () => { state.playhead = Math.max(0, state.playhead - .5); updatePlayhead(); flash(ledger, "Step back"); }],
["Play", (btn) => { btn.classList.toggle("active"); flash(ledger, btn.classList.contains("active") ? "Playback armed" : "Playback stopped"); }],
[">>", () => { state.playhead = Math.min(state.duration, state.playhead + .5); updatePlayhead(); flash(ledger, "Step forward"); }],
[">|", () => { state.playhead = state.duration; updatePlayhead(); flash(ledger, "Go to end"); }],
].forEach(([label, cb]) => {
const b = document.createElement("button");
b.type = "button";
b.textContent = label;
if (label === "Play") b.className = "primary";
b.onclick = () => cb(b);
transport.appendChild(b);
});
transport.appendChild(clock);
const tools = document.createElement("div");
tools.className = "iamccs-sve-tools";
[["Cut", "cut"], ["Trim", "trim"], ["Razor", "razor"], ["Snap", "snap"], ["1 Monitor", "oneMonitor"]].forEach(([label, key]) => {
const b = document.createElement("button");
b.type = "button";
b.textContent = label;
b.className = state.buttons[key] ? "active" : "";
b.onclick = () => {
state.buttons[key] = !state.buttons[key];
if (key === "oneMonitor") state.oneMonitor = !state.oneMonitor;
b.classList.toggle("active", Boolean(state.buttons[key]));
writeEditorState(node);
flash(ledger, `${label}: ${state.buttons[key] ? "ON" : "OFF"}`);
if (key === "oneMonitor") render();
};
tools.appendChild(b);
});
top.append(brand, transport, tools);
const sourcebar = document.createElement("div");
sourcebar.className = "iamccs-sve-sourcebar";
sourcebar.innerHTML = `<strong>T/A TAKES</strong>`;
const takeRow = document.createElement("div");
takeRow.className = "iamccs-sve-takes";
for (let take = 1; take <= 8; take += 1) {
const b = document.createElement("button");
b.type = "button";
b.className = `iamccs-sve-take${take === selected ? " active" : ""}`;
b.textContent = `T${take}/A${take}`;
b.onclick = () => {
setWidget(node, "selected_take", take);
setWidget(node, "editor_mode", "preview_selected_take");
flash(ledger, `Selected T${take}/A${take}`);
render();
};
takeRow.appendChild(b);
}
const monitorToggle = document.createElement("button");
monitorToggle.type = "button";
monitorToggle.textContent = state.oneMonitor ? "Two Monitors" : "One Monitor";
monitorToggle.onclick = () => { state.oneMonitor = !state.oneMonitor; render(); };
sourcebar.append(takeRow, monitorToggle);
const monitors = document.createElement("div");
monitors.className = "iamccs-sve-monitor-row";
const makeMonitor = (label, right, cls = "") => {
const m = document.createElement("div");
m.className = `iamccs-sve-monitor ${cls}`;
m.innerHTML = `<div class="iamccs-sve-monitor-head"><span>${label}</span><span>${right}</span></div><div class="iamccs-sve-screen"><div class="iamccs-sve-safe"></div><div class="iamccs-sve-screen-label" data-monitor-label>${label}: ${mode === "preview_selected_take" ? `T${selected}/A${selected}` : order.join(" -> ")}</div></div>`;
return m;
};
monitors.append(makeMonitor("Source Monitor", `T${selected}/A${selected}`, "source"), makeMonitor("Program Monitor", formatTime(state.playhead, fps), "program"));
const controls = document.createElement("div");
controls.className = "iamccs-sve-controls";
const labelWrap = (label, child) => { const w = document.createElement("label"); w.textContent = label; w.appendChild(child); return w; };
const modeSelect = document.createElement("select");
[["assemble_sequence", "Assemble sequence"], ["preview_selected_take", "Manual selected take"]].forEach(([v, l]) => { const o = document.createElement("option"); o.value = v; o.textContent = l; modeSelect.appendChild(o); });
modeSelect.value = mode;
modeSelect.onchange = () => { setWidget(node, "editor_mode", modeSelect.value); render(); };
const selectedInput = document.createElement("input");
selectedInput.type = "number"; selectedInput.min = "1"; selectedInput.max = "8"; selectedInput.value = String(selected);
selectedInput.onchange = () => { setWidget(node, "selected_take", clamp(selectedInput.value, 1, 8)); render(); };
const orderInput = document.createElement("input");
orderInput.value = orderText;
orderInput.onchange = () => { setWidget(node, "take_order", orderInput.value || "1,2"); render(); };
const audioSelect = document.createElement("select");
[["concat_clip_audio", "Concat clip audio"], ["use_master_audio", "Use master audio"], ["first_selected_audio", "Selected clip audio"], ["mix_editor_audio_tracks", "Mix editor audio tracks"], ["concat_editor_audio_tracks", "Concat editor audio tracks"], ["silent", "Silent"]].forEach(([v, l]) => { const o = document.createElement("option"); o.value = v; o.textContent = l; audioSelect.appendChild(o); });
audioSelect.value = String(widget(node, "audio_policy")?.value || "concat_clip_audio");
audioSelect.onchange = () => { setWidget(node, "audio_policy", audioSelect.value); flash(ledger, `Audio policy: ${audioSelect.value}`); };
const fpsInput = document.createElement("input");
fpsInput.type = "number"; fpsInput.step = "0.01"; fpsInput.value = String(fps);
fpsInput.onchange = () => setWidget(node, "override_fps", Math.max(1, Number(fpsInput.value || 24)));
controls.append(labelWrap("Mode", modeSelect), labelWrap("T/A", selectedInput), labelWrap("Order", orderInput), labelWrap("Audio", audioSelect), labelWrap("FPS", fpsInput));
const timeline = document.createElement("div");
timeline.className = "iamccs-sve-timeline";
const ruler = document.createElement("div");
ruler.className = "iamccs-sve-ruler";
for (let sec = 0; sec <= state.duration; sec += 1) {
const tick = document.createElement("div");
tick.className = "iamccs-sve-ruler-tick";
tick.style.left = `${(sec / state.duration) * 100}%`;
if (sec % 2 === 0) tick.innerHTML = `<span>${sec}s<br>${Math.round(sec * fps)}f</span>`;
ruler.appendChild(tick);
}
const tracks = document.createElement("div");
tracks.className = "iamccs-sve-tracks";
const playhead = document.createElement("div");
playhead.className = "iamccs-sve-playhead";
const updatePlayhead = () => {
const width = tracks.querySelector(".iamccs-sve-lane")?.getBoundingClientRect().width || 980;
playhead.style.left = `${116 + (state.playhead / state.duration) * width}px`;
updateClock();
root.querySelectorAll("[data-monitor-label]").forEach((el) => { el.textContent = `${el.textContent.split(":")[0]}: scrub ${formatTime(state.playhead, fps)} / ${mode === "preview_selected_take" ? `T${selected}/A${selected}` : order.join(" -> ")}`; });
};
const scrub = (ev) => {
const box = ruler.getBoundingClientRect();
state.playhead = clamp(((ev.clientX - box.left) / Math.max(1, box.width)) * state.duration, 0, state.duration);
updatePlayhead();
};
ruler.onpointerdown = (ev) => {
ev.preventDefault();
scrub(ev);
const move = (e) => scrub(e);
const up = () => { window.removeEventListener("pointermove", move, true); window.removeEventListener("pointerup", up, true); writeEditorState(node); };
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
};
tracks.appendChild(playhead);
const secondsToPct = (seconds) => `${(seconds / state.duration) * 100}%`;
const clipDrag = (clip, key, lane, mode, pointerEvent) => {
const startBox = lane.getBoundingClientRect();
const startX = pointerEvent.clientX;
const original = { ...state.clipMap[key] };
clip.classList.add("dragging");
const onMove = (ev) => {
ev.preventDefault();
const delta = ((ev.clientX - startX) / Math.max(1, startBox.width)) * state.duration;
if (mode === "left") {
const nextStart = clamp(original.start + delta, 0, original.start + original.length - .25);
state.clipMap[key].start = nextStart;
state.clipMap[key].length = Math.max(.25, original.length + (original.start - nextStart));
} else if (mode === "right") {
state.clipMap[key].length = Math.max(.25, original.length + delta);
} else {
state.clipMap[key].start = clamp(original.start + delta, 0, Math.max(0, state.duration - original.length));
}
clip.style.left = secondsToPct(state.clipMap[key].start);
clip.style.width = secondsToPct(state.clipMap[key].length);
};
const onUp = () => {
clip.classList.remove("dragging");
window.removeEventListener("pointermove", onMove, true);
window.removeEventListener("pointerup", onUp, true);
writeEditorState(node);
flash(ledger, `${key} updated ${state.clipMap[key].start.toFixed(2)}s + ${state.clipMap[key].length.toFixed(2)}s`);
};
window.addEventListener("pointermove", onMove, true);
window.addEventListener("pointerup", onUp, true);
};
const makeTrack = (name, type, trackNo) => {
const row = document.createElement("div");
row.className = `iamccs-sve-track ${type}`;
const label = document.createElement("div");
label.className = "iamccs-sve-label";
label.innerHTML = `<strong>${name}</strong><div class="iamccs-sve-chiprow"></div>`;
const chipRow = label.querySelector(".iamccs-sve-chiprow");
["M", "S", "L"].forEach((txt) => {
const c = document.createElement("span");
c.className = "iamccs-sve-chip";
c.textContent = txt;
c.onclick = () => { c.classList.toggle("active"); flash(ledger, `${name} ${txt}: ${c.classList.contains("active") ? "ON" : "OFF"}`); };
chipRow.appendChild(c);
});
const lane = document.createElement("div");
lane.className = "iamccs-sve-lane";
order.forEach((take, pos) => {
if (type === "video" && trackNo === 2 && pos % 2 === 0) return;
if (type === "video" && trackNo === 1 && pos % 2 === 1) return;
if (type === "audio" && trackNo > 1 && take !== trackNo) return;
const key = clipKey(type === "audio" ? "a" : "v", take, pos);
const clipState = state.clipMap[key] || { start: pos * perTake, length: perTake };
const clip = document.createElement("div");
clip.className = `iamccs-sve-clip${type === "audio" ? " audio" : ""}`;
clip.style.left = secondsToPct(clipState.start);
clip.style.width = secondsToPct(clipState.length);
clip.innerHTML = `<div class="iamccs-sve-handle left"></div><div class="iamccs-sve-handle right"></div>${type === "audio" ? '<canvas class="iamccs-sve-wave"></canvas>' : ""}<div class="iamccs-sve-clip-title">${type === "audio" ? `A${take}` : `T${String(take).padStart(2, "0")}`} ${type === "audio" ? "audio" : "video"}</div>`;
clip.onpointerdown = (ev) => {
ev.preventDefault();
ev.stopPropagation();
const isLeft = ev.target?.classList?.contains("left");
const isRight = ev.target?.classList?.contains("right");
clipDrag(clip, key, lane, isLeft ? "left" : isRight ? "right" : "move", ev);
};
lane.appendChild(clip);
if (type === "audio") requestAnimationFrame(() => drawWave(clip.querySelector("canvas"), take + pos * 2 + trackNo));
});
row.append(label, lane);
return row;
};
tracks.append(makeTrack("V1", "video", 1), makeTrack("V2", "video", 2), makeTrack("A1", "audio", 1), makeTrack("A2", "audio", 2), makeTrack("A3", "audio", 3));
timeline.append(ruler, tracks);
const actions = document.createElement("div");
actions.className = "iamccs-sve-actions";
[["Assemble Hard Cut", () => { setWidget(node, "editor_mode", "assemble_sequence"); flash(ledger, "Assemble mode"); render(); }], [`Manual T${selected}/A${selected}`, () => { setWidget(node, "editor_mode", "preview_selected_take"); flash(ledger, `Manual T${selected}/A${selected}`); render(); }], ["Write Edit Metadata", () => { writeEditorState(node); flash(ledger, "Edit metadata written"); }]].forEach(([label, cb], idx) => {
const b = document.createElement("button");
b.type = "button";
b.textContent = label;
if ((idx === 0 && mode === "assemble_sequence") || (idx === 1 && mode === "preview_selected_take")) b.className = "primary";
b.onclick = cb;
actions.appendChild(b);
});
ledger.textContent = "Ready. Drag clip center to move; drag yellow handles to trim. Ruler scrubs playhead.";
root.append(top, sourcebar, monitors, controls, timeline, actions, ledger);
requestAnimationFrame(updatePlayhead);
}
render();
const uiWidget = node.addDOMWidget("Shotboard Video Editor", "iamccs_shotboard_video_editor_ui", root, { serialize: false });
uiWidget.computeSize = () => [FIXED_SIZE[0] - 24, 900];
console.info("[IAMCCS Shotboard Video Editor UI] interactive NLE installed", { nodeId: node?.id, reason });
}
app.registerExtension({
name: "IAMCCS.ShotboardVideoEditorUI.NLEInteractive",
setup() {
[700, 1800, 3600].forEach((delay) => setTimeout(() => {
const nodes = Array.isArray(app?.graph?._nodes) ? app.graph._nodes : [];
nodes.forEach((node) => installEditorUI(node, `scan+${delay}`));
}, delay));
},
nodeCreated(node) { [0, 250, 900].forEach((delay) => setTimeout(() => installEditorUI(node, `nodeCreated+${delay}`), delay)); },
loadedGraphNode(node) { [0, 250, 900].forEach((delay) => setTimeout(() => installEditorUI(node, `loadedGraphNode+${delay}`), delay)); },
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== "IAMCCS_ShotboardVideoEditor") return;
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
originalOnNodeCreated?.apply(this, arguments);
setTimeout(() => installEditorUI(this, "prototype.onNodeCreated"), 0);
};
},
});