diff --git a/__init__.py b/__init__.py index 591e1ff..b56747d 100644 --- a/__init__.py +++ b/__init__.py @@ -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() diff --git a/audio/audio_phase_lazy.py b/audio/audio_phase_lazy.py new file mode 100644 index 0000000..3ee48b8 --- /dev/null +++ b/audio/audio_phase_lazy.py @@ -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)) diff --git a/audio/cine_audio_info.py b/audio/cine_audio_info.py index 72189af..e693a48 100644 --- a/audio/cine_audio_info.py +++ b/audio/cine_audio_info.py @@ -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": "", + "Laughter": "", + "Sigh": "", + "Surprise-oh": "", + "Uhm": "", + } + if para in token_map: + tokens.append(token_map[para]) + if "whisper" in style: + tokens.append("") + if "murmur" in style or "mumble" in style: + tokens.append("") + 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) diff --git a/audio/dialogue_tag_editor.py b/audio/dialogue_tag_editor.py index 5ecceeb..0c0643b 100644 --- a/audio/dialogue_tag_editor.py +++ b/audio/dialogue_tag_editor.py @@ -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): diff --git a/cine_multigeneration/__init__.py b/cine_multigeneration/__init__.py index 1847e8d..d1f277f 100644 --- a/cine_multigeneration/__init__.py +++ b/cine_multigeneration/__init__.py @@ -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", } diff --git a/cine_shotboard_v4/__init__.py b/cine_shotboard_v4/__init__.py new file mode 100644 index 0000000..9fb3053 --- /dev/null +++ b/cine_shotboard_v4/__init__.py @@ -0,0 +1,1069 @@ +from __future__ import annotations + +import copy +import json +from typing import Any, Dict, List + + +SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX" +MOTION_GUIDE_TYPE = "MOTION_GUIDE_DATA" + + +def _json_loads(value: Any, fallback: Any) -> Any: + if isinstance(value, (dict, list)): + return value + try: + text = str(value or "").strip() + if not text: + return fallback + return json.loads(text) + except Exception: + return fallback + + +def _json_dumps(value: Any) -> str: + return json.dumps(value, indent=2, ensure_ascii=False) + + +def _clone_linx(cine_linx: Any) -> Dict[str, Any]: + if isinstance(cine_linx, dict): + return copy.deepcopy(cine_linx) + return { + "type": SUPERNODE_LINX_TYPE, + "mode": "iamccs_cine_motion_sketch", + "resources": {}, + "outputs": {}, + "chain": [], + "stages": [], + } + + +def _resources(cine_linx: Dict[str, Any]) -> Dict[str, Any]: + resources = cine_linx.setdefault("resources", {}) + if not isinstance(resources, dict): + resources = {} + cine_linx["resources"] = resources + return resources + + +def _outputs(cine_linx: Dict[str, Any]) -> Dict[str, Any]: + outputs = cine_linx.setdefault("outputs", {}) + if not isinstance(outputs, dict): + outputs = {} + cine_linx["outputs"] = outputs + return outputs + + +def _payload(cine_linx: Dict[str, Any]) -> Dict[str, Any]: + resources = _resources(cine_linx) + payload = resources.get("cine_payload") + if not isinstance(payload, dict): + payload = {} + resources["cine_payload"] = payload + return payload + + +def _safe_int(value: Any, fallback: int = 0) -> int: + try: + return int(round(float(value))) + except Exception: + return int(fallback) + + +def _safe_float(value: Any, fallback: float = 0.0) -> float: + try: + return float(value) + except Exception: + return float(fallback) + + +def _refresh_index(cine_linx: Dict[str, Any]) -> None: + resources = _resources(cine_linx) + cine_linx["resource_keys"] = sorted(resources.keys()) + cine_linx["resource_types"] = {key: type(value).__name__ for key, value in resources.items()} + + +def _shotboard_segments(cine_linx: Dict[str, Any]) -> List[Dict[str, Any]]: + payload = _payload(cine_linx) + candidates = [ + payload.get("visual_segments"), + payload.get("segments"), + payload.get("timeline_segments"), + payload.get("rows"), + ] + for candidate in candidates: + if isinstance(candidate, list): + out: List[Dict[str, Any]] = [] + for index, item in enumerate(candidate): + if not isinstance(item, dict): + continue + seg = copy.deepcopy(item) + seg["id"] = str(seg.get("id") or seg.get("segment_id") or f"shot_{index + 1:03d}") + seg["start"] = max(0, _safe_int(seg.get("start", seg.get("frame", 0)), 0)) + seg["length"] = max(1, _safe_int(seg.get("length", seg.get("frames", 1)), 1)) + seg["label"] = str(seg.get("label") or seg.get("name") or seg.get("id")) + out.append(seg) + if out: + return sorted(out, key=lambda seg: _safe_int(seg.get("start"), 0)) + return [] + + +def _duration_frames(cine_linx: Dict[str, Any], sketch: Dict[str, Any]) -> int: + payload = _payload(cine_linx) + candidates = [ + sketch.get("duration_frames"), + payload.get("duration_frames"), + payload.get("max_frames"), + cine_linx.get("duration_frames"), + ] + for value in candidates: + frames = _safe_int(value, 0) + if frames > 0: + return frames + segments = _shotboard_segments(cine_linx) + return max([_safe_int(seg.get("start"), 0) + _safe_int(seg.get("length"), 1) for seg in segments] or [1]) + + +def _normalize_strokes(raw: Any, segments: List[Dict[str, Any]], frame_rate: float) -> List[Dict[str, Any]]: + source = raw if isinstance(raw, list) else [] + segment_by_id = {str(seg.get("id")): seg for seg in segments} + out: List[Dict[str, Any]] = [] + for index, item in enumerate(source): + if not isinstance(item, dict): + continue + stroke = copy.deepcopy(item) + segment_id = str(stroke.get("segment_id") or stroke.get("segmentId") or "") + segment = segment_by_id.get(segment_id) if segment_id else None + start = _safe_int(stroke.get("start_frame", stroke.get("start", segment.get("start", 0) if segment else 0)), 0) + length = _safe_int(stroke.get("length", stroke.get("length_frames", segment.get("length", max(1, round(frame_rate))) if segment else max(1, round(frame_rate)))), 1) + stroke["id"] = str(stroke.get("id") or f"stroke_{index + 1:03d}") + stroke["segment_id"] = segment_id or str(segment.get("id")) if segment else "" + stroke["track"] = str(stroke.get("track") or "camera_path") + stroke["mode"] = str(stroke.get("mode") or "motion_track") + stroke["scope"] = str(stroke.get("scope") or "slot_only") + stroke["start_frame"] = max(0, start) + stroke["length_frames"] = max(1, length) + stroke["strength"] = max(0.0, min(1.0, _safe_float(stroke.get("strength", 0.75), 0.75))) + stroke["attention_strength"] = max(0.0, min(1.0, _safe_float(stroke.get("attention_strength", 0.65), 0.65))) + stroke["radius"] = max(1.0, _safe_float(stroke.get("radius", 28), 28)) + stroke["falloff"] = max(0.0, min(1.0, _safe_float(stroke.get("falloff", 0.35), 0.35))) + stroke["easing"] = str(stroke.get("easing") or "ease_in_out") + points = stroke.get("points") + if not isinstance(points, list): + points = [] + stroke["points"] = [ + [ + max(0.0, min(1.0, _safe_float(point[0], 0.0))), + max(0.0, min(1.0, _safe_float(point[1], 0.0))), + ] + for point in points + if isinstance(point, list) and len(point) >= 2 + ] + out.append(stroke) + return out + + +def _track_color(track: str) -> tuple: + palette = { + "camera_path": (0, 170, 255), + "subject_path": (255, 80, 130), + "object_path": (255, 198, 84), + "background_lock": (80, 245, 170), + "attention_mask": (160, 105, 255), + } + return palette.get(str(track or ""), (120, 220, 255)) + + +def _point_xy(point: Any) -> tuple: + if isinstance(point, dict): + return ( + max(0.0, min(1.0, _safe_float(point.get("x"), 0.0))), + max(0.0, min(1.0, _safe_float(point.get("y"), 0.0))), + ) + if isinstance(point, (list, tuple)) and len(point) >= 2: + return ( + max(0.0, min(1.0, _safe_float(point[0], 0.0))), + max(0.0, min(1.0, _safe_float(point[1], 0.0))), + ) + return (0.0, 0.0) + + +def _stroke_points_px(stroke: Dict[str, Any], width: int, height: int) -> List[tuple]: + points = stroke.get("points") + if not isinstance(points, list): + return [] + out = [] + for point in points: + x, y = _point_xy(point) + out.append((int(round(x * max(1, width - 1))), int(round(y * max(1, height - 1))))) + return out + + +def _motion_parts_from_plan(plan: Dict[str, Any], fallback_strength: float = 0.75, fallback_attention: float = 0.65) -> List[Dict[str, Any]]: + existing = plan.get("motionParts") + if isinstance(existing, list) and existing: + return [copy.deepcopy(part) for part in existing if isinstance(part, dict)] + + duration_frames = max(1, _safe_int(plan.get("duration_frames"), 1)) + strokes = plan.get("strokes") if isinstance(plan.get("strokes"), list) else [] + segments = plan.get("shotboard_segments") if isinstance(plan.get("shotboard_segments"), list) else [] + segments = sorted([seg for seg in segments if isinstance(seg, dict)], key=lambda seg: _safe_int(seg.get("start"), 0)) + segment_by_id = {str(seg.get("id") or seg.get("segment_id") or ""): seg for seg in segments} + def scoped_range(stroke: Dict[str, Any]) -> tuple: + start = max(0, _safe_int(stroke.get("start_frame"), 0)) + length = max(1, _safe_int(stroke.get("length_frames"), duration_frames)) + segment_id = str(stroke.get("segment_id") or "") + segment = segment_by_id.get(segment_id) + if segment: + start = max(0, _safe_int(segment.get("start"), start)) + length = max(1, _safe_int(segment.get("length"), length)) + scope = str(stroke.get("scope") or "slot_only") + if scope == "continue_to_next" and segment: + try: + idx = segments.index(segment) + except ValueError: + idx = -1 + if idx >= 0 and idx + 1 < len(segments): + next_seg = segments[idx + 1] + end = max(start + length, _safe_int(next_seg.get("start"), start) + _safe_int(next_seg.get("length"), 1)) + length = max(1, end - start) + elif scope == "hold_last" and segment: + later = [seg for seg in segments if _safe_int(seg.get("start"), 0) > start] + end = _safe_int(later[0].get("start"), start + length) if later else duration_frames + length = max(1, end - start) + elif scope == "cut_reset": + length = max(1, length) + return start, length, scope + if not strokes: + return [{ + "id": "motion_part_001", + "type": "motion_control", + "start": 0, + "length": duration_frames, + "trimStart": 0, + "videoStrength": max(0.0, min(1.0, _safe_float(fallback_strength, 0.75))), + "videoAttentionStrength": max(0.0, min(1.0, _safe_float(fallback_attention, 0.65))), + "resampleMode": "nearest", + "source": "IAMCCS_MotionGuideBridge", + }] + + parts_by_key: Dict[str, Dict[str, Any]] = {} + for index, stroke in enumerate(strokes): + if not isinstance(stroke, dict): + continue + start, length, scope = scoped_range(stroke) + key = str(stroke.get("segment_id") or f"{start}:{length}") + if scope != "slot_only": + key = f"{key}:{scope}" + part = parts_by_key.get(key) + if not part: + part = { + "id": f"motion_part_{len(parts_by_key) + 1:03d}", + "type": "motion_control", + "segment_id": str(stroke.get("segment_id") or ""), + "scope": scope, + "start": start, + "length": length, + "trimStart": start, + "videoStrength": max(0.0, min(1.0, _safe_float(stroke.get("strength"), fallback_strength))), + "videoAttentionStrength": max(0.0, min(1.0, _safe_float(stroke.get("attention_strength"), fallback_attention))), + "resampleMode": "nearest", + "source": "IAMCCS_MotionGuideBridge", + "tracks": [], + } + parts_by_key[key] = part + part["start"] = min(_safe_int(part.get("start"), start), start) + part_end = max(_safe_int(part.get("start"), start) + _safe_int(part.get("length"), length), start + length) + part["length"] = max(1, part_end - _safe_int(part.get("start"), start)) + track = str(stroke.get("track") or "camera_path") + if track not in part["tracks"]: + part["tracks"].append(track) + return sorted(parts_by_key.values(), key=lambda item: _safe_int(item.get("start"), 0)) + + +def _stroke_scoped_range(plan: Dict[str, Any], stroke: Dict[str, Any]) -> tuple: + duration_frames = max(1, _safe_int(plan.get("duration_frames"), 1)) + segments = plan.get("shotboard_segments") if isinstance(plan.get("shotboard_segments"), list) else [] + segments = sorted([seg for seg in segments if isinstance(seg, dict)], key=lambda seg: _safe_int(seg.get("start"), 0)) + segment_by_id = {str(seg.get("id") or seg.get("segment_id") or ""): seg for seg in segments} + start = max(0, _safe_int(stroke.get("start_frame"), 0)) + length = max(1, _safe_int(stroke.get("length_frames"), duration_frames)) + scope = str(stroke.get("scope") or "slot_only") + segment = segment_by_id.get(str(stroke.get("segment_id") or "")) + if segment: + start = max(0, _safe_int(segment.get("start"), start)) + length = max(1, _safe_int(segment.get("length"), length)) + if scope == "continue_to_next" and segment: + try: + idx = segments.index(segment) + except ValueError: + idx = -1 + if idx >= 0 and idx + 1 < len(segments): + next_seg = segments[idx + 1] + end = max(start + length, _safe_int(next_seg.get("start"), start) + _safe_int(next_seg.get("length"), 1)) + length = max(1, end - start) + elif scope == "hold_last" and segment: + later = [seg for seg in segments if _safe_int(seg.get("start"), 0) > start] + end = _safe_int(later[0].get("start"), start + length) if later else duration_frames + length = max(1, end - start) + return start, length, scope + + +class IAMCCS_CineMotionSketch: + """Shotboard-synced motion sketch layer for IC-LoRA control planning.""" + + DEFAULT_DATA = _json_dumps({ + "schema": "iamccs.shotboard_v4.motion_sketch", + "schema_version": 1, + "frame_rate": 24.0, + "control_family": "auto", + "render_mode": "motion_track_control", + "resize_method": "crop", + "strokes": [], + "view": { + "selected_segment_id": "", + "show_camera_path": True, + "show_subject_path": True, + "show_background_lock": True, + "snap_to_shotboard": True, + }, + }) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "motion_sketch_data": ("STRING", { + "default": cls.DEFAULT_DATA, + "multiline": True, + "tooltip": "Edited by IAMCCS CineMotionSketch UI. Stores camera/subject/object/background-lock strokes in normalized image coordinates.", + }), + "frame_rate": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}), + "control_family": (["auto", "motion_track", "union_control", "depth", "edge", "pose", "hybrid"], {"default": "auto"}), + "sync_policy": (["shotboard_realtime", "metadata_only"], {"default": "shotboard_realtime"}), + }, + "optional": { + "cine_linx": (SUPERNODE_LINX_TYPE,), + }, + "hidden": { + "unique_id": "UNIQUE_ID", + "extra_pnginfo": "EXTRA_PNGINFO", + }, + } + + RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "STRING") + RETURN_NAMES = ("cine_linx", "motion_sketch_json", "report") + FUNCTION = "sketch" + OUTPUT_NODE = True + CATEGORY = "IAMCCS/Cine/Shotboard V4" + + def sketch(self, motion_sketch_data, frame_rate, control_family, sync_policy, cine_linx=None, unique_id=None, extra_pnginfo=None): + linx = _clone_linx(cine_linx) + resources = _resources(linx) + outputs = _outputs(linx) + payload = _payload(linx) + + sketch = _json_loads(motion_sketch_data, {}) + if not isinstance(sketch, dict): + sketch = {} + segments = _shotboard_segments(linx) + fps = max(1.0, _safe_float(frame_rate, _safe_float(sketch.get("frame_rate", 24.0), 24.0))) + strokes = _normalize_strokes(sketch.get("strokes"), segments, fps) + duration_frames = _duration_frames(linx, sketch) + + normalized = { + "schema": "iamccs.shotboard_v4.motion_sketch", + "schema_version": 1, + "source_node": "IAMCCS_CineMotionSketch", + "frame_rate": fps, + "duration_frames": duration_frames, + "control_family": str(control_family or sketch.get("control_family") or "auto"), + "render_mode": str(sketch.get("render_mode") or "motion_track_control"), + "resize_method": str(sketch.get("resize_method") or payload.get("resize_method") or "crop"), + "sync_policy": str(sync_policy or "shotboard_realtime"), + "shotboard_segments": segments, + "strokes": strokes, + "view": sketch.get("view") if isinstance(sketch.get("view"), dict) else {}, + } + + resources["cine_motion_sketch"] = normalized + resources["cine_motion_sketch_json"] = _json_dumps(normalized) + outputs["motion_sketch_json"] = resources["cine_motion_sketch_json"] + payload["motion_sketch"] = normalized + linx.setdefault("chain", []).append({"role": "motion_sketch", "name": "IAMCCS_CineMotionSketch"}) + _refresh_index(linx) + + report = { + "ok": True, + "node": "IAMCCS_CineMotionSketch", + "segments": len(segments), + "strokes": len(strokes), + "duration_frames": duration_frames, + "frame_rate": fps, + "truth": "Motion strokes are stored in cine_linx and remain synced to Shotboard segment ids/start/length.", + } + return (linx, resources["cine_motion_sketch_json"], _json_dumps(report)) + + +class IAMCCS_MotionGuideBridge: + """Builds LTX IC-LoRA-style motion guide data from IAMCCS motion sketch metadata.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "motion_sketch_json": ("STRING", {"default": "{}", "multiline": True}), + "default_video_strength": ("FLOAT", {"default": 0.75, "min": 0.0, "max": 1.0, "step": 0.01}), + "default_attention_strength": ("FLOAT", {"default": 0.65, "min": 0.0, "max": 1.0, "step": 0.01}), + "resample_mode": (["nearest", "linear"], {"default": "nearest"}), + "resize_method": (["crop", "pad", "stretch to fit", "maintain aspect ratio"], {"default": "crop"}), + }, + "optional": { + "cine_linx": (SUPERNODE_LINX_TYPE,), + "control_video_path": ("STRING", { + "default": "", + "multiline": False, + "tooltip": "Optional rendered control video path. When empty, the bridge emits a render plan but no active video segment.", + }), + }, + } + + RETURN_TYPES = (SUPERNODE_LINX_TYPE, MOTION_GUIDE_TYPE, "STRING", "STRING") + RETURN_NAMES = ("cine_linx", "motion_guide_data", "motion_render_plan_json", "report") + FUNCTION = "bridge" + OUTPUT_NODE = True + CATEGORY = "IAMCCS/Cine/Shotboard V4" + + def bridge( + self, + motion_sketch_json, + default_video_strength, + default_attention_strength, + resample_mode, + resize_method, + cine_linx=None, + control_video_path="", + ): + linx = _clone_linx(cine_linx) + resources = _resources(linx) + outputs = _outputs(linx) + payload = _payload(linx) + + sketch = _json_loads(motion_sketch_json, {}) + if not isinstance(sketch, dict) or not sketch: + sketch = resources.get("cine_motion_sketch") if isinstance(resources.get("cine_motion_sketch"), dict) else {} + if not isinstance(sketch, dict): + sketch = {} + + fps = max(1.0, _safe_float(sketch.get("frame_rate", payload.get("frame_rate", 24.0)), 24.0)) + duration_frames = max(1, _safe_int(sketch.get("duration_frames", payload.get("duration_frames", payload.get("max_frames", 1))), 1)) + strokes = sketch.get("strokes") if isinstance(sketch.get("strokes"), list) else [] + family = str(sketch.get("control_family") or "auto") + + render_plan = { + "schema": "iamccs.shotboard_v4.motion_control_render_plan", + "schema_version": 1, + "source_node": "IAMCCS_MotionGuideBridge", + "frame_rate": fps, + "duration_frames": duration_frames, + "control_family": family, + "render_mode": sketch.get("render_mode") or "motion_track_control", + "resize_method": resize_method, + "shotboard_segments": sketch.get("shotboard_segments") if isinstance(sketch.get("shotboard_segments"), list) else [], + "strokes": strokes, + "target": { + "recommended_lora": "ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors" if family in {"auto", "motion_track"} else "ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors", + "backend_contract": "Render strokes to an IMAGE batch/video and feed it to an IC-LoRA guide node before sampling.", + }, + } + motion_parts = _motion_parts_from_plan(render_plan, default_video_strength, default_attention_strength) + for part in motion_parts: + part["videoStrength"] = max(0.0, min(1.0, _safe_float(part.get("videoStrength"), default_video_strength))) + part["videoAttentionStrength"] = max(0.0, min(1.0, _safe_float(part.get("videoAttentionStrength"), default_attention_strength))) + part["resampleMode"] = str(part.get("resampleMode") or resample_mode or "nearest") + render_plan["motionParts"] = motion_parts + + control_path = str(control_video_path or "").strip() + active_segments: List[Dict[str, Any]] = [] + if control_path: + for part in motion_parts: + active_segments.append({ + "id": str(part.get("id") or f"motion_part_{len(active_segments) + 1:03d}"), + "type": "motion_control", + "start": max(0, _safe_int(part.get("start"), 0)), + "length": max(1, _safe_int(part.get("length"), duration_frames)), + "trimStart": max(0, _safe_int(part.get("trimStart"), 0)), + "videoFile": control_path, + "videoStrength": max(0.0, min(1.0, _safe_float(part.get("videoStrength"), default_video_strength))), + "videoAttentionStrength": max(0.0, min(1.0, _safe_float(part.get("videoAttentionStrength"), default_attention_strength))), + "resampleMode": str(part.get("resampleMode") or resample_mode or "nearest"), + "source": "IAMCCS_MotionGuideBridge", + }) + + motion_guide_data = { + "segments": active_segments, + "motionParts": motion_parts, + "frame_rate": fps, + "duration_frames": duration_frames, + "resize_method": resize_method, + "render_plan": render_plan, + "schema": "iamccs.motion_guide_data.iclora_compatible", + } + + resources["cine_motion_parts"] = motion_parts + resources["cine_motion_parts_json"] = _json_dumps(motion_parts) + resources["cine_motion_render_plan"] = render_plan + resources["cine_motion_render_plan_json"] = _json_dumps(render_plan) + resources["cine_motion_guide_data"] = motion_guide_data + resources["cine_motion_guide_data_json"] = _json_dumps(motion_guide_data) + outputs["motion_render_plan_json"] = resources["cine_motion_render_plan_json"] + outputs["motion_guide_data_json"] = resources["cine_motion_guide_data_json"] + payload["motion_guide_data"] = motion_guide_data + linx.setdefault("chain", []).append({"role": "motion_guide_bridge", "name": "IAMCCS_MotionGuideBridge"}) + _refresh_index(linx) + + report = { + "ok": True, + "node": "IAMCCS_MotionGuideBridge", + "active_motion_segments": len(active_segments), + "motionParts": len(motion_parts), + "strokes": len(strokes), + "has_control_video": bool(control_path), + "contract": "IAMCCS uses motionParts as authoring truth; MOTION_GUIDE_DATA.segments is emitted only as the executable guide shape expected by LTX IC-LoRA guide backends.", + "next_step": "Render motion_render_plan_json to control frames and connect IAMCCS Motion Parts IC-LoRA Apply.", + } + return (linx, motion_guide_data, resources["cine_motion_render_plan_json"], _json_dumps(report)) + + +class IAMCCS_MotionSketchRenderer: + """Renders MotionSketch strokes into IC-LoRA-friendly control frames.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "motion_render_plan_json": ("STRING", {"default": "{}", "multiline": True}), + "width": ("INT", {"default": 512, "min": 64, "max": 2048, "step": 8}), + "height": ("INT", {"default": 288, "min": 64, "max": 2048, "step": 8}), + "max_render_frames": ("INT", {"default": 240, "min": 1, "max": 2048, "step": 1}), + "line_width": ("INT", {"default": 8, "min": 1, "max": 96, "step": 1}), + "trail_frames": ("INT", {"default": 12, "min": 0, "max": 240, "step": 1}), + "background_level": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "glow": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + } + + RETURN_TYPES = ("IMAGE", "STRING", "STRING") + RETURN_NAMES = ("control_frames", "control_video_hint_json", "report") + FUNCTION = "render" + OUTPUT_NODE = True + CATEGORY = "IAMCCS/Cine/Shotboard V4" + + def render( + self, + motion_render_plan_json, + width, + height, + max_render_frames, + line_width, + trail_frames, + background_level, + glow, + ): + try: + import numpy as np + import torch + from PIL import Image, ImageDraw, ImageFilter + except Exception as exc: + raise RuntimeError(f"IAMCCS MotionSketch renderer requires torch, numpy and PIL: {exc}") from exc + + plan = _json_loads(motion_render_plan_json, {}) + if not isinstance(plan, dict): + plan = {} + strokes = plan.get("strokes") if isinstance(plan.get("strokes"), list) else [] + duration_frames = max(1, _safe_int(plan.get("duration_frames"), 1)) + frame_count = min(max(1, _safe_int(max_render_frames, 240)), duration_frames) + width = max(64, _safe_int(width, 512)) + height = max(64, _safe_int(height, 288)) + line_width = max(1, _safe_int(line_width, 8)) + trail_frames = max(0, _safe_int(trail_frames, 12)) + bg = int(round(max(0.0, min(1.0, _safe_float(background_level, 0.0))) * 255)) + glow_amount = max(0.0, min(1.0, _safe_float(glow, 0.35))) + + frames = [] + scale = duration_frames / float(frame_count) + for frame_index in range(frame_count): + source_frame = int(round(frame_index * scale)) + base = Image.new("RGB", (width, height), (bg, bg, bg)) + glow_layer = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + line_layer = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + glow_draw = ImageDraw.Draw(glow_layer) + line_draw = ImageDraw.Draw(line_layer) + + for stroke in strokes: + if not isinstance(stroke, dict): + continue + points = _stroke_points_px(stroke, width, height) + if len(points) < 2: + continue + start, length, scope = _stroke_scoped_range(plan, stroke) + end = start + length + if source_frame < start - trail_frames or source_frame > end: + continue + progress = (source_frame - start) / float(length) + progress = max(0.0, min(1.0, progress)) + if scope == "hold_last" and source_frame >= end - trail_frames: + progress = 1.0 + visible_count = max(2, int(round(1 + progress * (len(points) - 1)))) + visible = points[:visible_count] + color = _track_color(str(stroke.get("track") or "camera_path")) + strength = max(0.05, min(1.0, _safe_float(stroke.get("strength"), 0.75))) + alpha = int(round(255 * strength)) + if str(stroke.get("track")) == "background_lock": + alpha = int(round(190 * strength)) + + glow_width = max(line_width + 8, int(round(line_width * 2.5))) + if glow_amount > 0: + glow_draw.line(visible, fill=(*color, int(alpha * glow_amount)), width=glow_width, joint="curve") + line_draw.line(visible, fill=(*color, alpha), width=line_width, joint="curve") + for point in visible[-3:]: + radius = max(3, line_width // 2) + line_draw.ellipse( + (point[0] - radius, point[1] - radius, point[0] + radius, point[1] + radius), + fill=(*color, alpha), + ) + + if glow_amount > 0: + glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=max(1, int(round(line_width * 0.75))))) + base = Image.alpha_composite(base.convert("RGBA"), glow_layer) + base = Image.alpha_composite(base.convert("RGBA"), line_layer).convert("RGB") + frames.append(np.asarray(base, dtype=np.float32) / 255.0) + + if not frames: + frames.append(np.zeros((height, width, 3), dtype=np.float32)) + batch = torch.from_numpy(np.stack(frames, axis=0)) + hint = { + "schema": "iamccs.shotboard_v4.motion_control_frames", + "frame_count": int(batch.shape[0]), + "width": width, + "height": height, + "source_duration_frames": duration_frames, + "frame_sampling": "uniform_from_shotboard_duration", + "intended_backend": "Feed control_frames into the local LTX 2.3 IC-LoRA guide node as the control image sequence.", + } + report = { + "ok": True, + "node": "IAMCCS_MotionSketchRenderer", + "frames": int(batch.shape[0]), + "strokes": len(strokes), + "size": [width, height], + "note": "Rendered actual control frames from MotionSketch strokes; this is the first executable backend layer after authoring.", + } + return (batch, _json_dumps(hint), _json_dumps(report)) + + +class IAMCCS_MotionPartsICLoRAApply: + """Applies rendered motionParts control frames to an LTX latent through the IC-LoRA guide path.""" + + @classmethod + def INPUT_TYPES(cls): + try: + import folder_paths + loras = folder_paths.get_filename_list("loras") + except Exception: + loras = [] + if not loras: + loras = ["None"] + return { + "required": { + "positive": ("CONDITIONING",), + "negative": ("CONDITIONING",), + "vae": ("VAE",), + "latent": ("LATENT",), + "control_frames": ("IMAGE",), + "motion_render_plan_json": ("STRING", {"default": "{}", "multiline": True}), + "default_strength": ("FLOAT", {"default": 0.75, "min": 0.0, "max": 1.0, "step": 0.01}), + "default_attention_strength": ("FLOAT", {"default": 0.65, "min": 0.0, "max": 1.0, "step": 0.01}), + "latent_downscale_factor": ("FLOAT", {"default": 1.0, "min": 1.0, "max": 10.0, "step": 1.0}), + "crop": (["disabled", "center"], {"default": "center"}), + "use_tiled_encode": ("BOOLEAN", {"default": False}), + "tile_size": ("INT", {"default": 256, "min": 64, "max": 512, "step": 32}), + "tile_overlap": ("INT", {"default": 64, "min": 16, "max": 256, "step": 16}), + }, + "optional": { + "guide_data": ("GUIDE_DATA",), + "model": ("MODEL",), + "ic_lora_name": (["None"] + [name for name in loras if name != "None"], {"default": "None"}), + "ic_lora_strength": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}), + "image_attention_strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "scale_by": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 8.0, "step": 0.01}), + "upscale_method": (["nearest-exact", "bilinear", "area", "bicubic", "bislerp"], {"default": "bicubic"}), + }, + } + + RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT", "MODEL", "FLOAT", "STRING") + RETURN_NAMES = ("positive", "negative", "latent", "model", "latent_downscale_factor", "report") + FUNCTION = "apply" + CATEGORY = "IAMCCS/Cine/Shotboard V4" + + @staticmethod + def _conditioning_entries(conditioning): + for item in conditioning: + meta = item[1] if isinstance(item, (list, tuple)) and len(item) > 1 else {} + entries = meta.get("guide_attention_entries") if isinstance(meta, dict) else None + if entries is not None: + return list(entries) + return [] + + @staticmethod + def _set_conditioning_entries(conditioning, entries): + import node_helpers + return node_helpers.conditioning_set_values(conditioning, {"guide_attention_entries": entries}) + + @classmethod + def _append_attention_entry(cls, conditioning, token_count, latent_shape, attention_strength): + entries = cls._conditioning_entries(conditioning) + entries.append({ + "pre_filter_count": int(token_count), + "strength": float(attention_strength), + "pixel_mask": None, + "latent_shape": list(latent_shape), + }) + return cls._set_conditioning_entries(conditioning, entries) + + @staticmethod + def _resample_frames(frames, target_count, mode): + import torch + n = int(frames.shape[0]) + target_count = max(1, int(target_count)) + if n <= 0: + raise ValueError("control_frames is empty") + if n == target_count: + return frames + if n == 1: + return frames.repeat(target_count, 1, 1, 1) + positions = torch.linspace(0, n - 1, target_count, device=frames.device, dtype=torch.float32) + if str(mode or "nearest") == "nearest": + idx = torch.round(positions).long().clamp(0, n - 1) + return frames.index_select(0, idx) + idx0 = torch.floor(positions).long().clamp(0, n - 1) + idx1 = torch.ceil(positions).long().clamp(0, n - 1) + alpha = (positions - idx0.to(positions.dtype)).view(-1, 1, 1, 1) + f0 = frames.index_select(0, idx0).to(torch.float32) + f1 = frames.index_select(0, idx1).to(torch.float32) + return (f0 * (1.0 - alpha) + f1 * alpha).to(frames.dtype) + + @staticmethod + def _dilate_latent(samples, mask, horizontal_scale, vertical_scale): + import torch + if horizontal_scale == 1 and vertical_scale == 1: + return samples, mask + out_shape = samples.shape[:3] + ( + samples.shape[3] * vertical_scale, + samples.shape[4] * horizontal_scale, + ) + out = torch.zeros(out_shape, device=samples.device, dtype=samples.dtype, requires_grad=False) + out[..., ::vertical_scale, ::horizontal_scale] = samples + mask_shape = (samples.shape[0], 1, samples.shape[2], out_shape[3], out_shape[4]) + out_mask = torch.full(mask_shape, -1.0, device=samples.device, dtype=samples.dtype, requires_grad=False) + out_mask[..., ::vertical_scale, ::horizontal_scale] = mask if mask is not None else 1.0 + return out, out_mask + + @staticmethod + def _load_ic_lora(model, ic_lora_name, ic_lora_strength, fallback_downscale): + if model is None or not ic_lora_name or ic_lora_name == "None": + return model, fallback_downscale, False + import comfy + import comfy.sd + import folder_paths + lora_path = folder_paths.get_full_path_or_raise("loras", ic_lora_name) + lora, metadata = comfy.utils.load_torch_file(lora_path, safe_load=True, return_metadata=True) + try: + downscale = float(metadata.get("reference_downscale_factor", fallback_downscale)) + except Exception: + downscale = fallback_downscale + if float(ic_lora_strength) != 0.0: + model, _ = comfy.sd.load_lora_for_models(model, None, lora, float(ic_lora_strength), 0) + return model, max(1.0, float(downscale)), True + + def apply( + self, + positive, + negative, + vae, + latent, + control_frames, + motion_render_plan_json, + default_strength, + default_attention_strength, + latent_downscale_factor, + crop, + use_tiled_encode, + tile_size, + tile_overlap, + guide_data=None, + model=None, + ic_lora_name="None", + ic_lora_strength=1.0, + image_attention_strength=1.0, + scale_by=1.0, + upscale_method="bicubic", + ): + import comfy.utils + import node_helpers + import torch + from comfy_extras import nodes_lt + + plan = _json_loads(motion_render_plan_json, {}) + if not isinstance(plan, dict): + plan = {} + motion_parts = _motion_parts_from_plan(plan, default_strength, default_attention_strength) + if not motion_parts: + raise ValueError("No motionParts available for IC-LoRA apply.") + if control_frames is None or int(control_frames.shape[0]) <= 0: + raise ValueError("control_frames is empty.") + + latent_image = latent["samples"].clone() + noise_mask = latent.get("noise_mask") + if noise_mask is None: + noise_mask = torch.ones((latent_image.shape[0], 1, latent_image.shape[2], 1, 1), device=latent_image.device, dtype=torch.float32) + else: + noise_mask = noise_mask.clone() + + model, latent_downscale_factor, lora_loaded = self._load_ic_lora( + model, ic_lora_name, ic_lora_strength, max(1.0, _safe_float(latent_downscale_factor, 1.0)) + ) + + scale_factors = vae.downscale_index_formula + if float(scale_by or 1.0) != 1.0: + batch, channels, frames, height, width = latent_image.shape + scaled_width = max(1, round(width * float(scale_by))) + scaled_height = max(1, round(height * float(scale_by))) + latent_4d = latent_image.permute(0, 2, 1, 3, 4).reshape(batch * frames, channels, height, width) + latent_4d = comfy.utils.common_upscale(latent_4d, scaled_width, scaled_height, str(upscale_method or "bicubic"), "disabled") + latent_image = latent_4d.reshape(batch, frames, channels, scaled_height, scaled_width).permute(0, 2, 1, 3, 4) + if noise_mask is not None and (noise_mask.shape[-1] > 1 or noise_mask.shape[-2] > 1): + mask_4d = noise_mask.permute(0, 2, 1, 3, 4).reshape(batch * frames, 1, height, width) + mask_4d = comfy.utils.common_upscale(mask_4d, scaled_width, scaled_height, str(upscale_method or "bicubic"), "disabled") + noise_mask = mask_4d.reshape(batch, frames, 1, scaled_height, scaled_width).permute(0, 2, 1, 3, 4) + time_scale_factor, width_scale_factor, height_scale_factor = scale_factors + _, _, latent_length, latent_height, latent_width = latent_image.shape + initial_latent_length = int(latent_length) + applied = 0 + skipped = 0 + image_guides_applied = 0 + + images = guide_data.get("images", []) if isinstance(guide_data, dict) else [] + insert_frames = guide_data.get("insert_frames", []) if isinstance(guide_data, dict) else [] + strengths = guide_data.get("strengths", []) if isinstance(guide_data, dict) else [] + for idx, img_tensor in enumerate(images): + try: + frame = insert_frames[idx] if idx < len(insert_frames) else 0 + strength = max(0.0, min(1.0, _safe_float(strengths[idx] if idx < len(strengths) else 1.0, 1.0))) + if strength <= 0.0: + continue + if not torch.is_tensor(img_tensor): + skipped += 1 + continue + target_pix_w = int(latent_width * 32) + target_pix_h = int(latent_height * 32) + if img_tensor.shape[2] != target_pix_w or img_tensor.shape[1] != target_pix_h: + img_resized = comfy.utils.common_upscale( + img_tensor.permute(0, 3, 1, 2), + target_pix_w, + target_pix_h, + str(upscale_method or "bicubic"), + "disabled", + ).permute(0, 2, 3, 1) + img_tensor = img_resized + image_pixels, guide_latent = nodes_lt.LTXVAddGuide.encode(vae, latent_width, latent_height, img_tensor, scale_factors) + frame_idx, latent_idx = nodes_lt.LTXVAddGuide.get_latent_index(positive, latent_length, len(image_pixels), int(frame), scale_factors) + if latent_idx >= latent_length: + skipped += 1 + continue + max_frames = latent_length - latent_idx + if guide_latent.shape[2] > max_frames: + guide_latent = guide_latent[:, :, :max_frames] + if guide_latent.shape[2] <= 0: + skipped += 1 + continue + tokens_added = guide_latent.shape[2] * guide_latent.shape[3] * guide_latent.shape[4] + guide_orig_shape = list(guide_latent.shape[2:]) + positive, negative, latent_image, noise_mask = nodes_lt.LTXVAddGuide.append_keyframe( + positive, negative, frame_idx, latent_image, noise_mask, guide_latent, strength, scale_factors + ) + if lora_loaded: + att = max(0.0, min(1.0, _safe_float(image_attention_strength, 1.0))) + positive = self._append_attention_entry(positive, tokens_added, guide_orig_shape, att) + negative = self._append_attention_entry(negative, tokens_added, guide_orig_shape, att) + image_guides_applied += 1 + except Exception as exc: + raise RuntimeError(f"IAMCCS Director-compatible image guide failed at index {idx}: {exc}") from exc + + for part in motion_parts: + try: + start_frame = max(0, _safe_int(part.get("start"), 0)) + length_frames = max(1, _safe_int(part.get("length"), control_frames.shape[0])) + trim_start = max(0, _safe_int(part.get("trimStart"), start_frame)) + strength = max(0.0, min(1.0, _safe_float(part.get("videoStrength"), default_strength))) + attention_strength = max(0.0, min(1.0, _safe_float(part.get("videoAttentionStrength"), default_attention_strength))) + if strength <= 0.0: + skipped += 1 + continue + + frame_start = min(trim_start, int(control_frames.shape[0]) - 1) + frame_end = min(int(control_frames.shape[0]), frame_start + length_frames) + frames = control_frames[frame_start:frame_end] + frames = self._resample_frames(frames, length_frames, part.get("resampleMode", "nearest")) + keep = ((frames.shape[0] - 1) // time_scale_factor) * time_scale_factor + 1 + frames = frames[:keep] + causal_fix = start_frame == 0 or int(frames.shape[0]) == 1 + encode_frames = frames if causal_fix else torch.cat([frames[:1], frames], dim=0) + + ldf_float = max(1.0, float(latent_downscale_factor)) + ldf_int = int(max(1, round(ldf_float))) + if ldf_int > 1: + # IC-LoRA reference guides are encoded at a lower latent grid and + # dilated back. Odd latent dimensions (for example 544px -> 17) + # must use ceil here; floor would dilate 8 -> 16 and fail when + # concatenated with a 17-high latent video. + import math + target_latent_w = int(math.ceil(float(latent_width) / float(ldf_int))) + target_latent_h = int(math.ceil(float(latent_height) / float(ldf_int))) + target_w = max(8, int(target_latent_w * width_scale_factor)) + target_h = max(8, int(target_latent_h * height_scale_factor)) + else: + target_w = max(8, int(latent_width * width_scale_factor / ldf_float)) + target_h = max(8, int(latent_height * height_scale_factor / ldf_float)) + pixels = comfy.utils.common_upscale( + encode_frames.movedim(-1, 1), + target_w, + target_h, + "bilinear", + crop="center" if crop == "center" else "disabled", + ).movedim(1, -1) + pixels = pixels[:, :, :, :3] + if use_tiled_encode: + guide_latent = vae.encode_tiled(pixels, tile_x=tile_size, tile_y=tile_size, overlap=tile_overlap) + else: + guide_latent = vae.encode(pixels) + guide_latent = guide_latent.to(device=latent_image.device, dtype=latent_image.dtype) + + if not causal_fix: + guide_latent = guide_latent[:, :, 1:, :, :] + + frame_idx = start_frame + latent_idx = (frame_idx + time_scale_factor - 1) // time_scale_factor if frame_idx > 0 else 0 + if latent_idx >= latent_length: + skipped += 1 + continue + if start_frame > 0 and guide_latent.shape[2] > 1: + guide_latent = guide_latent[:, :, 1:, :, :] + frame_idx += time_scale_factor + latent_idx += 1 + if latent_idx >= latent_length: + skipped += 1 + continue + + max_frames = latent_length - latent_idx + if guide_latent.shape[2] > max_frames: + guide_latent = guide_latent[:, :, :max_frames] + if guide_latent.shape[2] <= 0: + skipped += 1 + continue + + guide_orig_shape = list(guide_latent.shape[2:]) + guide_mask = torch.ones((guide_latent.shape[0], 1, guide_latent.shape[2], guide_latent.shape[3], guide_latent.shape[4]), device=guide_latent.device, dtype=guide_latent.dtype) + ldf = int(max(1, round(float(latent_downscale_factor)))) + if ldf > 1: + guide_latent, guide_mask = self._dilate_latent(guide_latent, guide_mask, ldf, ldf) + # After ceil+dilate, odd dimensions can overshoot by one cell. + # Match the active latent exactly before append_keyframe. + if guide_latent.shape[3] != latent_height or guide_latent.shape[4] != latent_width: + fixed_latent = torch.zeros( + guide_latent.shape[:3] + (latent_height, latent_width), + device=guide_latent.device, + dtype=guide_latent.dtype, + requires_grad=False, + ) + fixed_mask = torch.full( + (guide_mask.shape[0], 1, guide_mask.shape[2], latent_height, latent_width), + -1.0, + device=guide_mask.device, + dtype=guide_mask.dtype, + requires_grad=False, + ) + copy_h = min(int(latent_height), int(guide_latent.shape[3])) + copy_w = min(int(latent_width), int(guide_latent.shape[4])) + fixed_latent[..., :copy_h, :copy_w] = guide_latent[..., :copy_h, :copy_w] + fixed_mask[..., :copy_h, :copy_w] = guide_mask[..., :copy_h, :copy_w] + guide_latent, guide_mask = fixed_latent, fixed_mask + + tokens_added = guide_latent.shape[2] * guide_latent.shape[3] * guide_latent.shape[4] + positive, negative, latent_image, noise_mask = nodes_lt.LTXVAddGuide.append_keyframe( + positive, + negative, + frame_idx, + latent_image, + noise_mask, + guide_latent, + strength, + scale_factors, + guide_mask=guide_mask, + latent_downscale_factor=float(latent_downscale_factor), + causal_fix=causal_fix, + ) + positive = self._append_attention_entry(positive, tokens_added, guide_orig_shape, attention_strength) + negative = self._append_attention_entry(negative, tokens_added, guide_orig_shape, attention_strength) + applied += 1 + except Exception as exc: + raise RuntimeError(f"IAMCCS motionPart IC-LoRA apply failed for {part}: {exc}") from exc + + exact_crop_frames = max(0, int(latent_image.shape[2]) - initial_latent_length) + positive = node_helpers.conditioning_set_values(positive, {"nghtdrp_guide_crop_latent_frames": exact_crop_frames}) + negative = node_helpers.conditioning_set_values(negative, {"nghtdrp_guide_crop_latent_frames": exact_crop_frames}) + + report = { + "ok": True, + "node": "IAMCCS_MotionPartsICLoRAApply", + "backend_mode": "director_compatible_b", + "image_guides_applied": image_guides_applied, + "motionParts": len(motion_parts), + "applied": applied, + "skipped": skipped, + "lora_loaded": bool(lora_loaded), + "ic_lora_name": str(ic_lora_name or "None"), + "latent_downscale_factor": float(latent_downscale_factor), + "exact_crop_frames": exact_crop_frames, + "truth": "Backend B: applies IAMCCS Shotboard GUIDE_DATA first, then timeline motionParts control frames, then emits crop metadata like the Director guide stage.", + } + return (positive, negative, {"samples": latent_image, "noise_mask": noise_mask}, model, float(latent_downscale_factor), _json_dumps(report)) + + +class IAMCCS_CineDirectorGuideB(IAMCCS_MotionPartsICLoRAApply): + """Director-compatible IAMCCS guide stage. + + This node keeps the IAMCCS Shotboard/cine_linx/motionParts authoring model, + while executing the same essential guide-stage contract: Shotboard image + GUIDE_DATA first, then motion guide parts, then crop metadata for the + downstream crop guide node. + """ + + CATEGORY = "IAMCCS/Cine/Shotboard V4" + + +NODE_CLASS_MAPPINGS = { + "IAMCCS_CineMotionSketch": IAMCCS_CineMotionSketch, + "IAMCCS_MotionGuideBridge": IAMCCS_MotionGuideBridge, + "IAMCCS_MotionSketchRenderer": IAMCCS_MotionSketchRenderer, + "IAMCCS_MotionPartsICLoRAApply": IAMCCS_MotionPartsICLoRAApply, + "IAMCCS_CineDirectorGuideB": IAMCCS_CineDirectorGuideB, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "IAMCCS_CineMotionSketch": "IAMCCS Cine Motion Sketch", + "IAMCCS_MotionGuideBridge": "IAMCCS Motion Guide Bridge", + "IAMCCS_MotionSketchRenderer": "IAMCCS Motion Sketch Renderer", + "IAMCCS_MotionPartsICLoRAApply": "IAMCCS Motion Parts IC-LoRA Apply", + "IAMCCS_CineDirectorGuideB": "IAMCCS Cine Director Guide B", +} diff --git a/iamccs_cine_nodes.py b/iamccs_cine_nodes.py index 8ce9dc1..adcb516 100644 --- a/iamccs_cine_nodes.py +++ b/iamccs_cine_nodes.py @@ -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) diff --git a/iamccs_ideogram_storyboard_frame_designer.py b/iamccs_ideogram_storyboard_frame_designer.py index ad1b053..81e440f 100644 --- a/iamccs_ideogram_storyboard_frame_designer.py +++ b/iamccs_ideogram_storyboard_frame_designer.py @@ -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 ." + ) + + +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 .\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 .\n" + ) + + +def _strip_gemma_response_noise(raw: Any) -> str: + text = _clean_text(raw) + if not text: + return "" + for marker in ("", "", ""): + 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"system\n{system.strip()}\n" + f"user\n{prompt.strip()}\n" + "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() diff --git a/iamccs_scail_extends.py b/iamccs_scail_extends.py index 2f830ca..0736cb5 100644 --- a/iamccs_scail_extends.py +++ b/iamccs_scail_extends.py @@ -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, diff --git a/iamccs_wananimate_extends.py b/iamccs_wananimate_extends.py new file mode 100644 index 0000000..5e7c10b --- /dev/null +++ b/iamccs_wananimate_extends.py @@ -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", +} diff --git a/web/iamccs_cine_audio_dialogue_ui.js b/web/iamccs_cine_audio_dialogue_ui.js index 708193b..95f1834 100644 --- a/web/iamccs_cine_audio_dialogue_ui.js +++ b/web/iamccs_cine_audio_dialogue_ui.js @@ -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"); diff --git a/web/iamccs_cine_motion_sketch_ui.js b/web/iamccs_cine_motion_sketch_ui.js new file mode 100644 index 0000000..d19fedd --- /dev/null +++ b/web/iamccs_cine_motion_sketch_ui.js @@ -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 = ` +
+
Connect Shotboard V3 cine_linx -> Motion Sketch cine_linx
+
+ + + +
+
+
+
+
+
TIME
+
SHOT
+
CAMERA
+
SUBJECT
+
OBJECT
+
LOCK
+
+
+
+
+ + +
NO SHOT IMAGE
+
+
+

MOTION TRACK

+ + + + + + + +

MOTION PARTS

+
+
Disegna sull'immagine selezionata. Ogni tratto diventa una motionPart sincronizzata allo start/length dello shot nella timeline.
+
+
`; + + 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); + }, +}); diff --git a/web/iamccs_cine_timeline_ui.js b/web/iamccs_cine_timeline_ui.js index a6caabb..c58edd0 100644 --- a/web/iamccs_cine_timeline_ui.js +++ b/web/iamccs_cine_timeline_ui.js @@ -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); diff --git a/web/iamccs_dialogue_tag_editor_ui.js b/web/iamccs_dialogue_tag_editor_ui.js index a3c704f..c8d1aa5 100644 --- a/web/iamccs_dialogue_tag_editor_ui.js +++ b/web/iamccs_dialogue_tag_editor_ui.js @@ -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, "&") @@ -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 = "" + text + "" + extra + ""; 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)); }, diff --git a/web/iamccs_ideogram_storyboard_frame_designer.js b/web/iamccs_ideogram_storyboard_frame_designer.js index 9f8e581..6f45020 100644 --- a/web/iamccs_ideogram_storyboard_frame_designer.js +++ b/web/iamccs_ideogram_storyboard_frame_designer.js @@ -12,11 +12,13 @@ const TYPE_V2 = "IAMCCS_StoryboardFrameDesignerV2"; const TYPE_JSON_PASS = "IAMCCS_IdeogramJSONPreviewPass"; const STYLE_ID = "iamccs-ideogram-storyboard-frame-style"; const IDEOBOARD_SCHEMA = "iamccs.ideoboard.package"; -const JS_BUILD = "IAMCCS-GALLERY-LIVE-REFRESH-20260620"; +const JS_BUILD = "IAMCCS-INTERNAL-GEMMA4-ASSISTANT-APPLY-20260622"; console.info("[IAMCCS FrameDesigner] loaded", JS_BUILD, import.meta.url); let lastIamccsResultImage = null; let lastIamccsStoryboardSourceImage = null; +let lastIamccsPrimaryResultImage = null; +let iamccsActivePromptKey = ""; const iamccsLiveFrameNodes = new Set(); const iamccsFrameDesignerNodes = new Set(); @@ -47,26 +49,61 @@ function iamccsShouldIgnoreAutoCompareImage(img) { ); } +function iamccsEventPromptKey(event) { + return String( + event?.detail?.prompt_id || + event?.detail?.promptId || + event?.detail?.prompt?.id || + iamccsActivePromptKey || + "default" + ); +} + +function iamccsEventNode(event) { + const rawId = event?.detail?.node ?? event?.detail?.node_id ?? event?.detail?.nodeId; + if (rawId == null) return null; + const id = Number(rawId); + const graph = app?.graph; + return graph?.getNodeById?.(id) || graph?._nodes_by_id?.[id] || graph?._nodes_by_id?.[String(rawId)] || null; +} + +function iamccsIsSaveImageEvent(event) { + const graphNode = iamccsEventNode(event); + const type = String( + graphNode?.type || + graphNode?.comfyClass || + graphNode?.properties?.["Node name for S&R"] || + "" + ).toLowerCase(); + return type === "saveimage" || type.endsWith(".saveimage"); +} + try { + api?.addEventListener?.("execution_start", (event) => { + iamccsActivePromptKey = String(event?.detail?.prompt_id || event?.detail?.promptId || Date.now()); + lastIamccsPrimaryResultImage = null; + lastIamccsStoryboardSourceImage = null; + }); api?.addEventListener?.("executed", (event) => { const images = event?.detail?.output?.images; if (!Array.isArray(images) || !images.length) return; - lastIamccsResultImage = images[images.length - 1]; - if (iamccsIsStoryboardSourceImage(lastIamccsResultImage)) lastIamccsStoryboardSourceImage = lastIamccsResultImage; - if (iamccsShouldIgnoreAutoCompareImage(lastIamccsResultImage)) return; - const url = iamccsResultViewUrl(lastIamccsResultImage); + if (!iamccsIsSaveImageEvent(event)) return; + const img = images[0]; + if (iamccsShouldIgnoreAutoCompareImage(img)) return; + const promptKey = iamccsEventPromptKey(event); + if (lastIamccsPrimaryResultImage && promptKey === iamccsActivePromptKey) return; + iamccsActivePromptKey = promptKey; + lastIamccsPrimaryResultImage = img; + lastIamccsResultImage = img; + if (iamccsIsStoryboardSourceImage(img)) lastIamccsStoryboardSourceImage = img; + const url = iamccsResultViewUrl(img); iamccsLiveFrameNodes.forEach((node) => node?._iamccsSetResultBackground?.(url, true)); // FrameDesigner compare overlays are intentionally disabled: results must not // inject extra UI/image layers into the editable canvas. }); api?.addEventListener?.("b_preview", (event) => { - const blob = event?.detail; - if (!blob || !iamccsLiveFrameNodes.size) return; - const url = URL.createObjectURL(blob); - iamccsLiveFrameNodes.forEach((node) => node?._iamccsSetResultBackground?.(url, false)); - window.setTimeout(() => { - try { URL.revokeObjectURL(url); } catch {} - }, 30000); + // Do not use live sampler previews as the under-box image. They can be + // sigma/debug previews and would replace the real first SaveImage result. }); } catch {} @@ -458,6 +495,59 @@ const TARGET_RESOLUTION_PRESETS = { custom: { label: "Custom - use Width / Height", width: 0, height: 0, note: "Manual canvas values stay editable." }, }; +const PALETTE_PRESETS = { + custom: { + label: "Custom / Current", + colors: [], + summary: "Keep the palette currently typed in the field.", + }, + photoreal_neutral_color: { + label: "Photoreal Neutral Color", + colors: ["#101014", "#2E3438", "#7A6A58", "#D6C7AA", "#8C3F2E"], + summary: "Grounded cinematic color for realistic faces, costume, and natural interiors.", + }, + silver_halide_bw: { + label: "Silver Halide B/W", + colors: ["#050505", "#1A1A1A", "#444444", "#8A8A8A", "#D8D8D8", "#F5F5F5"], + summary: "True black-and-white documentary print values.", + }, + faded_eastmancolor: { + label: "Faded Eastmancolor", + colors: ["#151718", "#47565B", "#8D7652", "#C7B383", "#B4563D", "#E4D6B5"], + summary: "Vintage 1960s-1970s film color with muted cyan, amber, and aged skin tones.", + }, + cold_scifi_ice: { + label: "Cold Sci-Fi Ice", + colors: ["#06111A", "#244454", "#557C8A", "#A8D8E8", "#E6E1C6", "#E87C45"], + summary: "Frozen planet / space-survival palette with cold blues and small warm accents.", + }, + candle_gothic: { + label: "Candle Gothic", + colors: ["#090706", "#241813", "#5E3B24", "#A26F3A", "#D9B978", "#F2E3C2"], + summary: "Low-key candlelit interiors, aged wood, stone, and warm practical light.", + }, + desert_epic: { + label: "Desert Epic", + colors: ["#100D0A", "#4B3828", "#8F7048", "#C6A46A", "#E5D0A1", "#48646B"], + summary: "Dry sand, sun-baked fabric, dust, and restrained blue-green shadows.", + }, + muted_fairytale_horror: { + label: "Muted Fairytale Horror", + colors: ["#0B0E10", "#273135", "#5B5245", "#91785D", "#BCA683", "#6E2328"], + summary: "Dirty storybook realism, sickly fabrics, old rooms, and restrained blood-red accents.", + }, + neon_night_city: { + label: "Neon Night City", + colors: ["#05070A", "#102035", "#00A6B8", "#9D4EDD", "#F72585", "#F5F0D8"], + summary: "Wet night exteriors, signs, reflections, and graphic cyberpunk contrast.", + }, + oz_sickly_green_gold: { + label: "Sickly Green / Gold", + colors: ["#08100A", "#27351E", "#5F6F35", "#A58D3D", "#D9C071", "#D8C8A8"], + summary: "Grotesque fairytale golds, green shadows, antique costume texture.", + }, +}; + const WORKFLOW_MODES = { single_image: { label: "Single Image", @@ -536,6 +626,26 @@ function targetResolutionEntries() { return { ...TARGET_RESOLUTION_PRESETS }; } +function paletteEntries() { + return { ...PALETTE_PRESETS }; +} + +function paletteKeyForColors(colors) { + const normalized = paletteList(colors, []).map((color) => color.toUpperCase()).join(","); + if (!normalized) return "custom"; + for (const [key, preset] of Object.entries(PALETTE_PRESETS)) { + const value = paletteList(preset.colors, []).map((color) => color.toUpperCase()).join(","); + if (value && value === normalized) return key; + } + return "custom"; +} + +function applyPalettePresetToList(key, fallback = []) { + const preset = paletteEntries()[key]; + if (!preset || key === "custom") return paletteList(fallback, []); + return paletteList(preset.colors, fallback); +} + function workflowModeEntries() { return { ...WORKFLOW_MODES }; } @@ -1134,6 +1244,18 @@ function defaultData() { "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": [] @@ -1520,6 +1642,22 @@ function toPrompt(data) { } catch {} } const items = Array.isArray(data?.items) ? data.items.map(normalizeItem).filter((item) => item.kind !== "image" && item.kind !== "mask") : []; + const monochrome = isMonochromeDesign(data); + const scenePalette = paletteList(data?.scene?.color_palette, []); + const fallbackMonoPalette = ["#050505", "#1A1A1A", "#777777", "#F2F2F2"]; + const elementForItem = (item) => { + const palette = monochrome + ? (scenePalette.length ? scenePalette : fallbackMonoPalette) + : paletteList(item.color_palette, []); + const entry = { + type: item.kind === "text" ? "text" : "obj", + bbox: [item.y, item.x, Math.min(1000, item.y + item.h), Math.min(1000, item.x + item.w)], + desc: item.desc, + ...(item.kind === "text" ? { text: item.text || item.label } : {}), + }; + if (palette.length) entry.color_palette = palette; + return entry; + }; return { high_level_description: cleanText(data?.scene?.high_level_description), style_description: { @@ -1532,17 +1670,25 @@ function toPrompt(data) { }, compositional_deconstruction: { background: cleanText(data?.scene?.background), - elements: items.map((item) => ({ - type: item.kind === "text" ? "text" : "obj", - bbox: [item.y, item.x, Math.min(1000, item.y + item.h), Math.min(1000, item.x + item.w)], - desc: item.desc, - color_palette: paletteList(item.color_palette, ["#FFE4B5", "#1A1A2E"]), - ...(item.kind === "text" ? { text: item.text || item.label } : {}), - })), + elements: items.map(elementForItem), }, }; } +function isMonochromeDesign(data) { + const scene = data?.scene || {}; + const text = [ + scene.high_level_description, + scene.aesthetics, + scene.lighting, + scene.photo, + scene.medium, + scene.art_style, + scene.background, + ].map(cleanText).join(" ").toLowerCase(); + return /\b(black and white|black-and-white|monochrome|grayscale|grey scale|silver halide|silver gelatin)\b/.test(text); +} + function safeFilename(value, fallback = "IAMCCS_Ideoboard") { const cleaned = cleanText(value).replace(/[\\/:*?"<>|]+/g, "_").replace(/\s+/g, " ").trim(); return cleaned || fallback; @@ -1711,6 +1857,8 @@ function ensureStyles() { .iamccs-isf-btn.primary { background:linear-gradient(180deg,#ffb65d,#f28b45); color:#24170b; border-color:#ffcb86; } .iamccs-isf-btn[data-action="toggle-fullscreen"] { background:linear-gradient(180deg,#9ad7ff,#3a8fd6); border-color:#b8e3ff; color:#071625; } .iamccs-isf.iamccs-isf-fullscreen .iamccs-isf-btn[data-action="toggle-fullscreen"] { background:linear-gradient(180deg,#ffd6a0,#e47a36); border-color:#ffe0b8; color:#241207; } + .iamccs-isf-btn[data-action="toggle-show-img"] { background:linear-gradient(180deg,#bfefff,#4898c8); border-color:#bdeaff; color:#071723; } + .iamccs-isf-btn[data-action="toggle-show-img"].off { background:#152329; border-color:#36545d; color:#8da7ad; } .iamccs-isf-btn[data-action="toggle-paper"] { background:linear-gradient(180deg,#fff7e7,#d9c49a); border-color:#ffe1a8; color:#17110a; } .iamccs-isf-btn[data-action="save-ideoboard"], .iamccs-isf-btn[data-action="copy-json"] { background:#173923; border-color:#3b8f5b; } .iamccs-isf-btn[data-action="import-ideoboard"], .iamccs-isf-btn[data-action="import-preset-gallery"] { background:#182d4f; border-color:#4d83d5; } @@ -1726,6 +1874,23 @@ function ensureStyles() { .iamccs-isf-pane:last-child { border-right:none; border-left:1px solid rgba(120,200,198,.14); } .iamccs-isf-panel { border:1px solid rgba(100,188,184,.22); border-radius:12px; background:linear-gradient(180deg,rgba(18,30,34,.96),rgba(10,17,19,.96)); padding:12px; margin-bottom:12px; box-shadow:inset 0 1px 0 rgba(255,255,255,.035); } .iamccs-isf-panel h4 { margin:0 0 10px; font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:#ffcf8f; } + .iamccs-isf-panel-headline { display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 10px; } + .iamccs-isf-panel-headline h4 { margin:0; } + .iamccs-isf-beta { display:inline-flex; align-items:center; margin-left:6px; padding:2px 6px; border-radius:999px; border:1px solid rgba(255,184,196,.46); color:#ffd6de; background:#36121c; font-size:9px; letter-spacing:.08em; vertical-align:middle; } + .iamccs-isf-gemma-panel { border-color:rgba(151,229,171,.36); background:linear-gradient(180deg,rgba(16,43,28,.96),rgba(8,18,14,.94)); } + .iamccs-isf-gemma-panel h4 { color:#bff0c8; } + .iamccs-isf-gemma-body[hidden], .iamccs-isf-refine-body[hidden] { display:none !important; } + .iamccs-isf-gemma-result { margin-top:10px; border:1px solid rgba(129,230,170,.34); background:rgba(4,14,10,.72); border-radius:10px; padding:9px; } + .iamccs-isf-gemma-result[hidden] { display:none !important; } + .iamccs-isf-gemma-result-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:7px; } + .iamccs-isf-gemma-result-head strong { color:#d9ffe6; font-size:12px; } + .iamccs-isf-gemma-result-head span { color:#9ee7bd; font-size:10px; text-transform:uppercase; letter-spacing:.04em; } + .iamccs-isf-gemma-result textarea { width:100%; min-height:130px; max-height:260px; resize:vertical; border:1px solid rgba(129,230,170,.36); border-radius:8px; padding:8px; background:#06110d; color:#eafff1; font:11px/1.42 Consolas,monospace; outline:none; } + .iamccs-isf-scene-panel { border-color:rgba(119,188,255,.30); background:linear-gradient(180deg,rgba(14,30,42,.96),rgba(8,15,22,.94)); } + .iamccs-isf-scene-panel h4 { color:#b9dcff; } + .iamccs-isf-art-panel, .iamccs-isf-style-preset-panel { border-color:rgba(255,207,143,.30); background:linear-gradient(180deg,rgba(37,27,16,.96),rgba(14,17,18,.94)); } + .iamccs-isf-refine-panel { border-color:rgba(255,139,159,.34); background:linear-gradient(180deg,rgba(42,17,25,.96),rgba(14,16,19,.94)); } + .iamccs-isf-refine-panel h4 { color:#ffc1cc; } .iamccs-isf-panel.mode-panel { border-color:rgba(255,207,143,.42); background:linear-gradient(180deg,rgba(42,31,16,.96),rgba(13,18,20,.94)); } .iamccs-isf-mode-grid { display:grid; grid-template-columns:1fr; gap:7px; } .iamccs-isf-mode-btn { text-align:left; border:1px solid rgba(255,207,143,.24); background:#0b1417; color:#eaf6f4; border-radius:10px; padding:9px; cursor:pointer; } @@ -1798,7 +1963,11 @@ function ensureStyles() { .iamccs-isf-field textarea { min-height:86px; resize:vertical; } .iamccs-isf-field-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:5px; } .iamccs-isf-field-head label { margin:0; } + .iamccs-isf-field-actions { display:inline-flex; align-items:center; justify-content:flex-end; gap:4px; flex:0 0 auto; min-width:0; } .iamccs-isf-zoom-btn { display:inline-grid; place-items:center; width:24px; height:22px; border:1px solid rgba(255,207,143,.45); background:#2a2115; color:#ffe2ac; border-radius:7px; cursor:pointer; font-weight:900; line-height:1; } + .iamccs-isf-gemma-mini { display:none; width:24px; height:22px; min-width:24px; border-color:rgba(129,230,170,.58); background:#143628; color:#ccffe1; font-size:11px; } + .iamccs-isf-gemma-mini.critic { border-color:rgba(159,144,255,.58); background:#2a244b; color:#eeeaff; } + .iamccs-isf.gemma-enabled .iamccs-isf-gemma-mini { display:inline-grid; } .iamccs-isf-zoom-modal { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:2147483646; display:flex; align-items:center; justify-content:center; padding:24px; box-sizing:border-box; } .iamccs-isf-zoom-card { width:min(1080px,94vw); max-height:92vh; display:grid; grid-template-rows:auto 1fr auto; background:#f7f2e8; color:#121212; border:1px solid #d5c4a3; border-radius:12px; box-shadow:0 30px 90px rgba(0,0,0,.55); overflow:hidden; } .iamccs-isf-zoom-head { display:flex; justify-content:space-between; align-items:center; gap:12px; padding:14px 16px; background:#fff9ec; border-bottom:1px solid #ddcaa8; } @@ -1837,6 +2006,13 @@ function ensureStyles() { .iamccs-isf-board-preview { min-height:0; height:100%; overflow:auto; border:1px solid rgba(120,200,198,.22); border-radius:10px; background:#0b1013; padding:0; box-sizing:border-box; color:#111; display:flex; flex-direction:column; justify-content:flex-start; align-items:stretch; gap:0; } .iamccs-isf-board-artboard { position:relative; flex:0 0 auto; width:100%; max-width:none; aspect-ratio:var(--iamccs-artboard-aspect, 16 / 9); min-height:320px; background:linear-gradient(90deg,rgba(255,255,255,.035) 1px,transparent 1px),linear-gradient(0deg,rgba(255,255,255,.035) 1px,transparent 1px),#16282e; background-size:32px 32px; border:0; box-shadow:none; overflow:hidden; isolation:isolate; } .iamccs-isf-board-artboard::before { content:""; position:absolute; inset:0; pointer-events:none; background:linear-gradient(180deg,rgba(255,255,255,.06),transparent 18%,rgba(0,0,0,.12)); z-index:0; } + .iamccs-isf-result-bg { position:absolute; inset:0; z-index:1; pointer-events:none; background-position:center; background-size:cover; background-repeat:no-repeat; opacity:var(--iamccs-result-opacity,.82); filter:saturate(1.02); } + .iamccs-isf-result-bg::after { content:""; position:absolute; inset:0; background:rgba(0,0,0,.06); pointer-events:none; } + .iamccs-isf-show-img-panel { border-color:rgba(134,213,255,.38); background:linear-gradient(180deg,rgba(13,35,48,.96),rgba(8,17,23,.94)); } + .iamccs-isf-show-img-row { display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center; } + .iamccs-isf-range-row { display:grid; grid-template-columns:1fr 42px; gap:8px; align-items:center; margin-top:10px; } + .iamccs-isf-range-row input[type="range"] { width:100%; accent-color:#8bdcff; } + .iamccs-isf-show-img-note { margin:8px 0 0; color:#9ec1c7; font-size:11px; line-height:1.35; } .iamccs-isf-board-artboard-meta { flex:0 0 auto; width:100%; max-width:none; color:#d9fffb; background:rgba(2,9,12,.72); border:0; border-bottom:1px solid rgba(184,255,248,.35); border-radius:0; padding:5px 8px; font-size:11px; font-weight:900; pointer-events:none; text-align:right; } .iamccs-isf-board-artboard-empty { position:absolute; inset:0; display:grid; place-items:center; text-align:center; padding:28px; color:#d9fffb; font-size:16px; line-height:1.4; font-weight:800; z-index:1; pointer-events:none; } .iamccs-isf-mask-panel { border-color:rgba(255,90,115,.36); background:linear-gradient(180deg,rgba(45,16,24,.96),rgba(13,17,20,.94)); } @@ -1874,6 +2050,9 @@ function ensureStyles() { .iamccs-isf-zoom-btn { flex:0 0 auto; display:inline-grid; place-items:center; width:26px; height:24px; min-width:26px; border:1px solid rgba(255,207,143,.55); background:#2a2115; color:#ffe2ac; border-radius:7px; cursor:pointer; font-size:15px; font-weight:900; line-height:1; padding:0; text-align:center; } .iamccs-isf-field-head { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; margin-bottom:5px; } .iamccs-isf-field-head label { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .iamccs-isf-gemma-mini { width:26px; height:24px; min-width:26px; font-size:11px; } + .iamccs-isf .iamccs-isf-gemma-mini { display:none; } + .iamccs-isf.gemma-enabled .iamccs-isf-gemma-mini { display:inline-grid; } .iamccs-isf-toast { position:fixed; z-index:2147483647; max-width:420px; padding:12px 14px; border:1px solid rgba(154,215,255,.45); border-radius:12px; background:rgba(8,14,18,.94); color:#f4fbff; box-shadow:0 18px 44px rgba(0,0,0,.42); font:12px/1.35 "Segoe UI",system-ui,sans-serif; pointer-events:none; opacity:0; transform:translateY(6px); transition:opacity .18s ease,transform .18s ease; } .iamccs-isf-toast.visible { opacity:1; transform:translateY(0); } @@ -1883,7 +2062,7 @@ function ensureStyles() { .iamccs-isf-direct-note { margin:8px 0 0; color:#a9c2bf; font-size:11px; } .iamccs-isf-direct-actions { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-top:8px; } .iamccs-isf-item { z-index:2; } - .iamccs-isf-item { position:absolute; display:block; min-width:48px; min-height:42px; border-radius:4px; border:3px solid rgba(190,255,248,.95); background:rgba(8,22,26,.82); box-shadow:0 0 0 1px rgba(0,0,0,.82),0 8px 22px rgba(0,0,0,.32); cursor:default; overflow:hidden; } + .iamccs-isf-item { position:absolute; display:block; min-width:48px; min-height:42px; border-radius:4px; border:3px solid rgba(190,255,248,.95); background:rgba(8,22,26,var(--iamccs-box-fill-opacity,.82)); box-shadow:0 0 0 1px rgba(0,0,0,.82),0 8px 22px rgba(0,0,0,.32); cursor:default; overflow:hidden; } .iamccs-isf-item.text { border-color:rgba(255,205,128,.96); background:rgba(38,22,8,.82); } .iamccs-isf-item.image { border-color:rgba(119,188,255,.98); background:rgba(10,28,52,.58); } .iamccs-isf-item.mask { border-color:rgba(255,255,255,.96); background:rgba(255,255,255,.25); box-shadow:0 0 0 2px rgba(255,77,109,.42), inset 0 0 18px rgba(255,255,255,.2), 0 8px 22px rgba(0,0,0,.22); } @@ -1894,7 +2073,7 @@ function ensureStyles() { .iamccs-isf-item.image .iamccs-isf-item-head, .iamccs-isf-item.image .iamccs-isf-item-body { position:relative; z-index:1; } .iamccs-isf-item.image .iamccs-isf-item-body { background:linear-gradient(180deg,rgba(0,0,0,.15),rgba(0,0,0,.62)); position:absolute; left:0; right:0; bottom:0; max-height:54%; overflow:hidden; } .iamccs-isf-item.selected { z-index:900 !important; box-shadow:0 0 0 2px rgba(255,255,255,.18),0 0 0 5px rgba(72,194,186,.28),0 14px 28px rgba(0,0,0,.3); } - .iamccs-isf-item-head { display:flex; justify-content:space-between; align-items:center; gap:8px; padding:8px 10px; background:rgba(3,7,9,.86); font-weight:900; color:#fff8ea; text-shadow:0 1px 0 rgba(0,0,0,.55); cursor:grab; font-size:16px; line-height:1.05; } + .iamccs-isf-item-head { display:flex; justify-content:space-between; align-items:center; gap:8px; padding:8px 10px; background:rgba(3,7,9,var(--iamccs-box-head-opacity,.86)); font-weight:900; color:#fff8ea; text-shadow:0 1px 0 rgba(0,0,0,.55); cursor:grab; font-size:16px; line-height:1.05; } .iamccs-isf-item-kind { font-size:12px; letter-spacing:.05em; text-transform:uppercase; color:#96e8e2; } .iamccs-isf-item.text .iamccs-isf-item-kind { color:#ffd39e; } .iamccs-isf-item-body { padding:10px; color:#f4fffe; font-size:18px; line-height:1.22; font-weight:750; text-shadow:0 1px 2px rgba(0,0,0,.9); } @@ -1924,7 +2103,7 @@ function ensureStyles() { border-color:#24494b; box-shadow:none; } - .iamccs-isf-item-body[contenteditable="true"] { cursor:text; outline:none; border-radius:8px; background:rgba(0,0,0,.22); min-height:28px; } + .iamccs-isf-item-body[contenteditable="true"] { cursor:text; outline:none; border-radius:8px; background:rgba(0,0,0,var(--iamccs-box-body-opacity,.22)); min-height:28px; } .iamccs-isf-item-body[contenteditable="true"]:focus { box-shadow:inset 0 0 0 1px rgba(255,207,143,.75); background:rgba(0,0,0,.38); color:#fff6e7; } .iamccs-isf-handle { position:absolute; z-index:3; width:20px; height:20px; right:8px; bottom:8px; border-radius:50%; border:2px solid rgba(20,30,38,.9); background:#fff; cursor:nwse-resize; box-shadow:0 2px 10px rgba(0,0,0,.42); } .iamccs-isf-list { display:flex; flex-direction:column; gap:8px; min-height:260px; max-height:430px; overflow:auto; } @@ -1979,9 +2158,11 @@ function install(node) { resultOverlayOpacity: 0.82, resultOverlaySplit: 50, resultOverlayVisible: true, + showResultImage: true, autoResultCompare: true, liveResult: false, showSheetSpecs: false, + showRefineGuide: true, paperMode: false, gallerySelectedKey: "", styleSelectedKey: "default_photoreal_cinema", @@ -1991,8 +2172,16 @@ function install(node) { paintTool: "select", paintDrawing: null, maskDebugLog: [], + gemmaRunning: false, + gemmaAbortController: null, + gemmaPending: null, + gemmaRunId: 0, + gemmaCanceledRunIds: new Set(), }; state.paperMode = Boolean(state.data?.ui?.paper_mode); + state.showResultImage = state.data?.ui?.show_img !== false; + state.showRefineGuide = state.data?.ui?.show_refine_guide !== false; + state.resultOverlayOpacity = Math.max(0, Math.min(1, Number(state.data?.ui?.show_img_opacity ?? state.resultOverlayOpacity) || state.resultOverlayOpacity)); state.workflowModeKey = inferWorkflowModeFromDesign(state.data, state.data?.workflow_mode || "single_image"); state.data.workflow_mode = state.workflowModeKey; if (state.data?.grid_key && gridEntries()[state.data.grid_key]) { @@ -2022,6 +2211,7 @@ function install(node) { + @@ -2059,6 +2249,7 @@ function install(node) { const footStatus = root.querySelector('[data-foot-status]'); const stageSize = root.querySelector('[data-stage-size]'); const fullscreenButton = root.querySelector('[data-action="toggle-fullscreen"]'); + const showImgButton = root.querySelector('[data-action="toggle-show-img"]'); const paperButton = root.querySelector('[data-action="toggle-paper"]'); const importInput = root.querySelector('[data-role="ideoboard-import"]'); const presetGalleryInput = root.querySelector('[data-role="preset-gallery-import"]'); @@ -2149,15 +2340,25 @@ function install(node) { } function setResultBackground(url, final = true, status = "") { - state.resultBgUrl = ""; - state.resultBgFinal = false; - state.resultOverlayVisible = false; - if (status) footStatus.textContent = "Result compare disabled"; + state.resultBgUrl = cleanText(url); + state.resultBgFinal = Boolean(final); + state.resultOverlayVisible = Boolean(state.resultBgUrl); + if (status) footStatus.textContent = status; + if (state.resultBgUrl && state.showResultImage && state.workflowModeKey !== "image_refine") { + renderArtboard(); + } else { + applyResultCompareCss(); + } } - node._iamccsSetResultBackground = () => { - // Result compare is disabled for FrameDesigner. Generated images must not - // auto-inject into the editable canvas while painting masks or arranging boxes. + node._iamccsSetResultBackground = (url, final = true) => { + if (!state.showResultImage) { + state.resultBgUrl = cleanText(url || state.resultBgUrl); + state.resultBgFinal = Boolean(final); + state.resultOverlayVisible = Boolean(state.resultBgUrl); + return; + } + setResultBackground(url, final, final ? "Generated image shown under boxes" : "Live preview shown under boxes"); }; function grabLastResultBackground(anchor = null) { @@ -2177,6 +2378,8 @@ function install(node) { state.resultBgUrl = ""; state.resultBgFinal = false; state.resultOverlayVisible = false; + syncShowImgControls(); + renderArtboard(); } @@ -2196,6 +2399,32 @@ function install(node) { if (paperButton) paperButton.textContent = state.paperMode ? "Paper On" : "Paper"; } + function persistUiState() { + state.data.ui = state.data.ui && typeof state.data.ui === "object" ? state.data.ui : {}; + state.data.ui.paper_mode = Boolean(state.paperMode); + state.data.ui.show_img = Boolean(state.showResultImage); + state.data.ui.show_refine_guide = Boolean(state.showRefineGuide); + state.data.ui.show_img_opacity = Math.max(0, Math.min(1, Number(state.resultOverlayOpacity) || 0)); + writeData(node, state.data); + } + + function syncShowImgControls() { + if (state.showResultImage) iamccsLiveFrameNodes.add(node); + else iamccsLiveFrameNodes.delete(node); + if (showImgButton) { + showImgButton.textContent = state.showResultImage ? "Show Img On" : "Show Img"; + showImgButton.classList.toggle("off", !state.showResultImage); + showImgButton.setAttribute("aria-pressed", state.showResultImage ? "true" : "false"); + } + const toggle = root.querySelector('[data-role="show-img-toggle"]'); + const opacity = root.querySelector('[data-role="show-img-opacity"]'); + const readout = root.querySelector('[data-role="show-img-opacity-readout"]'); + if (toggle) toggle.checked = Boolean(state.showResultImage); + if (opacity) opacity.value = String(Math.round(Math.max(0, Math.min(1, Number(state.resultOverlayOpacity) || 0)) * 100)); + if (readout) readout.textContent = `${Math.round(Math.max(0, Math.min(1, Number(state.resultOverlayOpacity) || 0)) * 100)}%`; + applyResultCompareCss(); + } + function toggleFullscreen(force) { const next = typeof force === 'boolean' ? force : !root.classList.contains('iamccs-isf-fullscreen'); if (next) { @@ -2414,6 +2643,357 @@ function install(node) { } } + function buildGemmaFrontendRequest(mode, brief, extra = {}) { + return { + mode: mode || "full_json_enhance", + brief: brief || "", + selected_id: extra.selectedId || state.selectedId || "", + target_field: extra.targetField || "", + current_text: extra.currentText || "", + speed: extra.speed || state.data.gemma_assistant?.speed || "fast", + design_data: state.data, + prompt_json: toPrompt(state.data), + }; + } + + function applyGemmaFieldPatch(payload, request) { + const field = cleanText(payload?.field_key || request?.target_field); + const text = cleanText(payload?.text || payload?.replacement || payload?.desc || payload?.description); + if (!field || !text) return false; + const selectedId = cleanText(payload?.selected_id || request?.selected_id || state.selectedId); + const item = selectedId ? state.data.items?.find((entry) => entry.id === selectedId) : currentItem(); + if (field === "scene.high_level_description") state.data.scene.high_level_description = text; + else if (field === "scene.background") state.data.scene.background = text; + else if (field === "scene.aesthetics") state.data.scene.aesthetics = text; + else if (field === "scene.lighting") state.data.scene.lighting = text; + else if (field === "scene.medium") state.data.scene.medium = text; + else if (field === "scene.photo") state.data.scene.photo = text; + else if (field === "scene.color_palette") state.data.scene.color_palette = paletteList(text.split(","), state.data.scene.color_palette); + else if (field === "item.desc" && item) item.desc = text; + else if (field === "item.text" && item) item.text = text; + else if (field === "item.label" && item) item.label = text; + else if (field === "item.palette" && item) item.color_palette = paletteList(text.split(","), item.color_palette); + else return false; + if (selectedId) state.selectedId = selectedId; + return true; + } + + function gemmaPendingPreview(payload, request, notes = "") { + const mode = cleanText(payload?.mode || request?.mode || ""); + const rawText = cleanText(payload?.raw_text || ""); + const withRaw = (mainText) => { + const main = cleanText(mainText); + if (!rawText || main.includes(rawText) || rawText.includes(main)) return main; + return `${main}\n\n--- RAW GEMMA OUTPUT ---\n${rawText}`; + }; + if (payload?.field_patch) { + const patch = payload.field_patch; + return withRaw([ + `MODE: ${mode || "field_enhance"}`, + `FIELD: ${cleanText(patch.field_key || request?.target_field || "")}`, + `SELECTED ID: ${cleanText(patch.selected_id || request?.selected_id || "") || "none"}`, + "", + cleanText(patch.text || patch.replacement || patch.desc || patch.description || ""), + "", + notes ? `NOTES: ${notes}` : "", + ].filter((line) => line !== "").join("\n")); + } + if (payload?.design_data) { + return withRaw([ + `MODE: ${mode || "full_json_enhance"}`, + notes ? `NOTES: ${notes}` : "", + "", + JSON.stringify(payload.design_data, null, 2), + ].filter((line) => line !== "").join("\n")); + } + if (payload?.raw_response) { + return withRaw([ + `MODE: ${mode || request?.mode || "gemma"}`, + notes ? `NOTES: ${notes}` : "", + "", + typeof payload.raw_response === "string" ? payload.raw_response : JSON.stringify(payload.raw_response, null, 2), + ].filter((line) => line !== "").join("\n")); + } + return rawText || notes || "Gemma returned an empty proposal."; + } + + function renderGemmaPendingResult() { + const box = root.querySelector('[data-role="gemma-result"]'); + if (!box) return; + const pending = state.gemmaPending; + const area = box.querySelector('[data-role="gemma-result-text"]'); + const label = box.querySelector('[data-role="gemma-result-label"]'); + const apply = box.querySelector('[data-role="gemma-apply"]'); + const copy = box.querySelector('[data-role="gemma-copy-result"]'); + const reject = box.querySelector('[data-role="gemma-reject"]'); + if (!pending) { + box.hidden = true; + if (area) area.value = ""; + if (label) label.textContent = ""; + if (apply) apply.disabled = true; + if (copy) copy.disabled = true; + if (reject) reject.disabled = true; + return; + } + box.hidden = false; + if (label) label.textContent = pending.applicable ? "Ready to apply" : "Critic notes"; + if (area) area.value = pending.preview || ""; + if (apply) apply.disabled = !pending.applicable || state.gemmaRunning; + if (copy) copy.disabled = false; + if (reject) reject.disabled = false; + } + + function setGemmaPending(payload, request, notes = "", anchor = null, statusHost = null) { + const mode = cleanText(request?.mode || payload?.mode || ""); + const applicable = mode !== "prompt_critic" && Boolean(payload?.field_patch || payload?.design_data); + state.gemmaPending = { + mode, + payload, + request, + notes, + applicable, + preview: gemmaPendingPreview(payload, request, notes), + createdAt: Date.now(), + }; + renderGemmaPendingResult(); + const message = applicable + ? "Gemma proposal ready. Review it, then Apply / Reject / Copy." + : "Gemma critic ready. Review or Copy the notes."; + if (statusHost) statusHost.textContent = message; + showToast(message, { anchor, tone: "success", ms: 3600 }); + } + + function clearGemmaPending(statusHost = null, message = "Gemma proposal rejected.") { + state.gemmaPending = null; + renderGemmaPendingResult(); + if (statusHost) statusHost.textContent = message; + } + + function applyGemmaPending(anchor = null, statusHost = null) { + const pending = state.gemmaPending; + if (!pending) { + showToast("No Gemma proposal to apply.", { anchor, tone: "warn", ms: 2400 }); + return; + } + if (!pending.applicable) { + showToast("This Gemma result is critic notes only; nothing to apply.", { anchor, tone: "warn", ms: 3200 }); + return; + } + const payload = pending.payload || {}; + const request = pending.request || {}; + const mode = pending.mode || request.mode || ""; + let applied = false; + if (payload.field_patch && mode !== "prompt_critic") { + applied = applyGemmaFieldPatch(payload.field_patch, request); + if (applied) writeInputSignature(node, `__gemma_field_${Date.now()}__`); + } else if (payload.design_data && mode !== "prompt_critic") { + const next = normalizeDesignObject(payload.design_data, state.data); + state.data = next; + state.workflowModeKey = inferWorkflowModeFromDesign(state.data, state.data.workflow_mode || state.workflowModeKey); + state.selectedId = payload.selected_id && state.data.items?.some((item) => item.id === payload.selected_id) + ? payload.selected_id + : (state.data.items?.[0]?.id || null); + writeInputSignature(node, `__gemma_${Date.now()}__`); + applied = true; + } + if (!applied) { + showToast("Gemma proposal could not be applied to this board.", { anchor, tone: "error", ms: 4200 }); + return; + } + state.gemmaPending = null; + persist(); + render(); + if (statusHost) statusHost.textContent = "Gemma proposal applied."; + showToast("Gemma proposal applied to FrameDesigner.", { anchor, tone: "success", ms: 3200 }); + } + + async function copyGemmaPending(anchor = null) { + const pending = state.gemmaPending; + if (!pending) { + showToast("No Gemma proposal to copy.", { anchor, tone: "warn", ms: 2400 }); + return; + } + const visibleArea = root.querySelector('[data-role="gemma-result-text"]'); + const text = visibleArea?.value || pending.preview || JSON.stringify(pending.payload || {}, null, 2); + try { + await navigator.clipboard?.writeText(text); + showToast("Gemma proposal copied.", { anchor, tone: "success", ms: 2400 }); + } catch (_) { + showToast("Clipboard copy failed.", { anchor, tone: "error", ms: 3000 }); + } + } + + function setGemmaRunning(running, statusHost = null, message = "") { + state.gemmaRunning = Boolean(running); + root.classList.toggle("gemma-running", state.gemmaRunning); + root.querySelectorAll('[data-role="gemma-run"], .iamccs-isf-gemma-mini').forEach((button) => { + button.disabled = state.gemmaRunning; + }); + root.querySelectorAll('[data-role="gemma-stop"]').forEach((button) => { + button.disabled = !state.gemmaRunning; + }); + renderGemmaPendingResult(); + if (statusHost && message) statusHost.textContent = message; + } + + async function stopGemmaAssistant(anchor = null, statusHost = null) { + if (!state.gemmaRunning && !state.gemmaAbortController) { + showToast("Gemma is not running.", { anchor, tone: "warn", ms: 2200 }); + return; + } + const activeRunId = state.gemmaRunId; + if (activeRunId) state.gemmaCanceledRunIds.add(activeRunId); + state.gemmaAbortController = null; + setGemmaRunning(false, statusHost, "Gemma stop requested."); + showToast("Gemma stop requested.", { anchor, tone: "warn", ms: 2600 }); + try { + await api.fetchApi("/iamccs/framedesigner/gemma_abort", { method: "POST" }); + } catch (error) { + console.warn("[IAMCCS FrameDesigner] Gemma abort request failed", error); + } + } + + async function runGemmaAssistant({ mode, model, brief, targetField = "", currentText = "", selectedId = "", speed = "", statusHost, anchor }) { + if (!state.data.gemma_assistant?.enabled) { + showToast("Enable Gemma Assistant first.", { anchor, tone: "warn", ms: 2600 }); + return; + } + if (state.gemmaRunning) { + showToast("Gemma is already working. Press Stop Gemma first if you want to abort it.", { anchor, tone: "warn", ms: 3200 }); + return; + } + const speedMode = cleanText(speed || state.data.gemma_assistant?.speed || gemmaPanelValue("gemma-speed", "fast")) || "fast"; + state.data.gemma_assistant = { + ...(state.data.gemma_assistant || {}), + enabled: true, + provider: "local_gemma", + mode: mode || "full_json_enhance", + speed: speedMode === "detailed" ? "detailed" : "fast", + model: cleanText(model || state.data.gemma_assistant?.model) || "text_encoders\\gemma4_e4b_it_fp8_scaled.safetensors", + selected_id: selectedId || state.selectedId || "", + target_field: targetField || "", + current_text: currentText || "", + brief: cleanText(brief) || "", + request_ready: false, + updated_at: Date.now(), + }; + persist(); + const request = buildGemmaFrontendRequest(mode, brief, { targetField, currentText, selectedId, speed: state.data.gemma_assistant.speed }); + request.model = state.data.gemma_assistant.model; + const runId = Date.now(); + state.gemmaRunId = runId; + state.gemmaAbortController = new AbortController(); + setGemmaRunning(true, statusHost, `Gemma is working (${state.data.gemma_assistant.speed}) with ${request.model}...`); + showToast("Gemma assistant started. It updates text/JSON only, not Ideogram.", { anchor, tone: "success", ms: 2600 }); + try { + const response = await api.fetchApi("/iamccs/framedesigner/gemma_assist", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + signal: state.gemmaAbortController.signal, + }); + const payload = await response.json(); + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || `HTTP ${response.status}`); + } + if (state.gemmaCanceledRunIds.has(runId) || state.gemmaRunId !== runId) { + state.gemmaCanceledRunIds.delete(runId); + if (statusHost) statusHost.textContent = "Gemma result ignored after stop."; + return; + } + const notes = cleanText(payload.notes || payload.report || "Gemma assistant finished."); + setGemmaPending(payload, request, notes, anchor, statusHost); + } catch (error) { + if (error?.name === "AbortError") { + const message = "Gemma assistant stopped."; + if (statusHost) statusHost.textContent = message; + showToast(message, { anchor, tone: "warn", ms: 2600 }); + return; + } + const message = `Gemma assistant failed: ${error?.message || error}`; + if (statusHost) statusHost.textContent = message; + showToast(message, { anchor, tone: "error", ms: 5200 }); + console.error("[IAMCCS FrameDesigner] Gemma assistant failed", error); + } finally { + if (state.gemmaRunId === runId) { + state.gemmaRunId = 0; + state.gemmaAbortController = null; + setGemmaRunning(false); + } + } + } + + function gemmaPanelValue(role, fallback = "") { + const el = root.querySelector(`[data-role="${role}"]`); + return el?.value ?? fallback; + } + + async function loadGemmaModelOptions(select, statusHost = null) { + if (!select) return; + const current = cleanText(select.value || state.data.gemma_assistant?.model || "text_encoders\\gemma4_e4b_it_fp8_scaled.safetensors"); + select.disabled = true; + select.innerHTML = ''; + const loading = document.createElement('option'); + loading.value = current; + loading.textContent = current ? `Loading models... (${current})` : 'Loading models...'; + select.appendChild(loading); + try { + const response = await api.fetchApi("/iamccs/framedesigner/gemma_models", { method: "GET" }); + const payload = await response.json(); + if (!response.ok || !payload?.ok) throw new Error(payload?.error || `HTTP ${response.status}`); + const models = Array.isArray(payload.models) ? payload.models.filter(Boolean) : []; + const selected = models.includes(current) ? current : (models.includes(payload.default) ? payload.default : (models[0] || current)); + select.innerHTML = ''; + models.forEach((name) => { + const option = document.createElement('option'); + option.value = name; + option.textContent = name; + select.appendChild(option); + }); + if (!models.length && current) { + const option = document.createElement('option'); + option.value = current; + option.textContent = current; + select.appendChild(option); + } + select.value = selected; + state.data.gemma_assistant = { + ...(state.data.gemma_assistant || {}), + provider: "local_gemma", + model: selected || current, + }; + persist(); + if (statusHost) statusHost.textContent = models.length + ? `Gemma model list loaded: ${models.length} text encoder(s).` + : 'No text encoder model found by ComfyUI.'; + } catch (error) { + select.innerHTML = ''; + const option = document.createElement('option'); + option.value = current; + option.textContent = current || 'text_encoders\\gemma4_e4b_it_fp8_scaled.safetensors'; + select.appendChild(option); + select.value = option.value; + if (statusHost) statusHost.textContent = `Gemma model list failed: ${error?.message || error}`; + showToast(`Gemma model list failed: ${error?.message || error}`, { tone: "error", ms: 4200 }); + } finally { + select.disabled = false; + } + } + + function runOrPrepareGemma({ mode = "full_json_enhance", brief = "", targetField = "", currentText = "", selectedId = "", speed = "", anchor = null, statusHost = null } = {}) { + runGemmaAssistant({ + mode, + model: gemmaPanelValue("gemma-model", state.data.gemma_assistant?.model || "text_encoders\\gemma4_e4b_it_fp8_scaled.safetensors"), + brief, + targetField, + currentText, + selectedId, + speed: speed || gemmaPanelValue("gemma-speed", state.data.gemma_assistant?.speed || "fast"), + statusHost: statusHost || root.querySelector('[data-role="gemma-status"]'), + anchor, + }); + } + function updatePromptPreview() { if (!previewField) return; try { @@ -2658,12 +3238,53 @@ function install(node) { input.type = opts.type || 'text'; } head.appendChild(lbl); + const actions = document.createElement('div'); + actions.className = 'iamccs-isf-field-actions'; input._iamccsFieldWrap = wrap; input._iamccsFieldLabel = lbl; if (opts.type === 'number') { lbl.dataset.dragNumber = 'true'; lbl.title = 'Drag left/right to change value; press Enter or leave the field to apply typed values.'; } + if (opts.gemmaField) { + const enhance = document.createElement('button'); + enhance.type = 'button'; + enhance.className = 'iamccs-isf-zoom-btn iamccs-isf-gemma-mini'; + enhance.textContent = 'G'; + enhance.title = `Gemma enhance: ${label}`; + enhance.setAttribute('aria-label', `Gemma enhance ${label}`); + enhance.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + runOrPrepareGemma({ + mode: "field_enhance", + targetField: opts.gemmaField, + currentText: input.value, + selectedId: opts.itemScoped ? state.selectedId : "", + brief: input.value, + anchor: event.currentTarget, + }); + }); + const critic = document.createElement('button'); + critic.type = 'button'; + critic.className = 'iamccs-isf-zoom-btn iamccs-isf-gemma-mini critic'; + critic.textContent = 'C'; + critic.title = `Gemma critic: ${label}`; + critic.setAttribute('aria-label', `Gemma critic ${label}`); + critic.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + runOrPrepareGemma({ + mode: "prompt_critic", + targetField: opts.gemmaField, + currentText: input.value, + selectedId: opts.itemScoped ? state.selectedId : "", + brief: input.value, + anchor: event.currentTarget, + }); + }); + actions.append(enhance, critic); + } if (opts.multiline || ['high','background','aesthetics','lighting','photo','desc','text'].includes(String(key))) { const zoom = document.createElement('button'); zoom.type = 'button'; @@ -2676,8 +3297,9 @@ function install(node) { event.stopPropagation(); openFieldEditor(label, input); }); - head.appendChild(zoom); + actions.appendChild(zoom); } + if (actions.childElementCount) head.appendChild(actions); wrap.append(head, input); container.appendChild(wrap); return input; @@ -3699,7 +4321,7 @@ function install(node) { state.data.direct_prompt = { enabled: false, text: "" }; const stylePresetPanel = document.createElement('div'); - stylePresetPanel.className = 'iamccs-isf-panel'; + stylePresetPanel.className = 'iamccs-isf-panel iamccs-isf-style-preset-panel'; stylePresetPanel.innerHTML = `

Style Preset

@@ -3721,40 +4343,199 @@ function install(node) { styleSelect?.addEventListener('change', () => { state.styleSelectedKey = styleSelect.value; refreshPresetDropdowns("style select changed"); }); const summary = document.createElement('div'); - summary.className = 'iamccs-isf-panel'; + summary.className = 'iamccs-isf-panel iamccs-isf-scene-panel'; summary.innerHTML = '

Scene Direction

'; scenePane.appendChild(summary); - sceneFields.high = makeField(summary, 'High-Level Description', 'high', { multiline: true }); - sceneFields.background = makeField(summary, 'Background', 'background', { multiline: true, small: true }); + sceneFields.high = makeField(summary, 'High-Level Description', 'high', { multiline: true, gemmaField: 'scene.high_level_description' }); + sceneFields.background = makeField(summary, 'Background', 'background', { multiline: true, small: true, gemmaField: 'scene.background' }); const style = document.createElement('div'); - style.className = 'iamccs-isf-panel'; + style.className = 'iamccs-isf-panel iamccs-isf-art-panel'; style.innerHTML = '

Art Direction

'; scenePane.appendChild(style); - sceneFields.aesthetics = makeField(style, 'Aesthetics', 'aesthetics', { multiline: true, small: true }); - sceneFields.lighting = makeField(style, 'Lighting', 'lighting', { multiline: true, small: true }); - sceneFields.medium = makeField(style, 'Medium', 'medium'); - sceneFields.photo = makeField(style, 'Photo / Lens Notes', 'photo', { multiline: true, small: true }); - sceneFields.palette = makeField(style, 'Global Palette', 'palette'); + sceneFields.aesthetics = makeField(style, 'Aesthetics', 'aesthetics', { multiline: true, small: true, gemmaField: 'scene.aesthetics' }); + sceneFields.lighting = makeField(style, 'Lighting', 'lighting', { multiline: true, small: true, gemmaField: 'scene.lighting' }); + sceneFields.medium = makeField(style, 'Medium', 'medium', { gemmaField: 'scene.medium' }); + sceneFields.photo = makeField(style, 'Photo / Lens Notes', 'photo', { multiline: true, small: true, gemmaField: 'scene.photo' }); + sceneFields.palette = makeField(style, 'Global Palette', 'palette', { gemmaField: 'scene.color_palette' }); + const scenePaletteTools = document.createElement('div'); + scenePaletteTools.className = 'iamccs-isf-field iamccs-isf-palette-tools'; + scenePaletteTools.innerHTML = ` + + +

+ `; + style.appendChild(scenePaletteTools); + sceneFields.palettePreset = scenePaletteTools.querySelector('[data-role="scene-palette-preset"]'); + sceneFields.paletteNote = scenePaletteTools.querySelector('[data-role="scene-palette-note"]'); + renderSelectOptions(sceneFields.palettePreset, paletteEntries(), paletteKeyForColors(state.data.scene.color_palette)); + sceneFields.palettePreset?.addEventListener('change', (event) => { + const key = event.currentTarget.value || "custom"; + if (key === "custom") return; + state.data.scene.color_palette = applyPalettePresetToList(key, state.data.scene.color_palette); + persist(); + render(); + showToast(`Global palette: ${paletteEntries()[key]?.label || key}`, { anchor: event.currentTarget, tone: "success", ms: 1800 }); + }); + + const gemmaPanel = document.createElement('div'); + gemmaPanel.className = 'iamccs-isf-panel iamccs-isf-gemma-panel'; + gemmaPanel.innerHTML = ` +
+

Gemma Assistant

+ +
+
+

Local Gemma 4 assistant for box enhancement, prompt critique, and full ideoboard JSON preparation. It updates text/JSON only; it never queues Ideogram image generation.

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+ +
+

Ready. This does not queue image generation.

+ `; + scenePane.insertBefore(gemmaPanel, stylePresetPanel); + const gemmaEnabled = gemmaPanel.querySelector('[data-role="gemma-enabled"]'); + const gemmaBody = gemmaPanel.querySelector('[data-role="gemma-body"]'); + const gemmaMode = gemmaPanel.querySelector('[data-role="gemma-mode"]'); + const gemmaModel = gemmaPanel.querySelector('[data-role="gemma-model"]'); + const gemmaSpeed = gemmaPanel.querySelector('[data-role="gemma-speed"]'); + const gemmaBrief = gemmaPanel.querySelector('[data-role="gemma-brief"]'); + const gemmaStatus = gemmaPanel.querySelector('[data-role="gemma-status"]'); + if (gemmaSpeed) gemmaSpeed.value = state.data.gemma_assistant?.speed === "detailed" ? "detailed" : "fast"; + renderGemmaPendingResult(); + const syncGemmaEnabledUi = () => { + const enabled = Boolean(state.data.gemma_assistant?.enabled); + if (gemmaEnabled) gemmaEnabled.checked = enabled; + if (gemmaBody) gemmaBody.hidden = !enabled; + root.classList.toggle('gemma-enabled', enabled); + if (gemmaStatus) gemmaStatus.textContent = enabled + ? 'Ready. Uses local Gemma 4 TextGenerate backend only.' + : 'Disabled. Enable Gemma Assistant to show and use field buttons.'; + }; + syncGemmaEnabledUi(); + loadGemmaModelOptions(gemmaModel, gemmaStatus); + gemmaEnabled?.addEventListener('change', () => { + state.data.gemma_assistant = { + ...(state.data.gemma_assistant || {}), + enabled: Boolean(gemmaEnabled.checked), + provider: "local_gemma", + model: gemmaModel?.value || "text_encoders\\gemma4_e4b_it_fp8_scaled.safetensors", + }; + persist(); + syncGemmaEnabledUi(); + }); + gemmaSpeed?.addEventListener('change', () => { + state.data.gemma_assistant = { + ...(state.data.gemma_assistant || {}), + speed: gemmaSpeed.value === "detailed" ? "detailed" : "fast", + }; + persist(); + if (gemmaStatus) gemmaStatus.textContent = `Gemma speed set to ${state.data.gemma_assistant.speed}.`; + }); + gemmaModel?.addEventListener('change', () => { + state.data.gemma_assistant = { + ...(state.data.gemma_assistant || {}), + provider: "local_gemma", + model: gemmaModel.value || "text_encoders\\gemma4_e4b_it_fp8_scaled.safetensors", + }; + persist(); + }); + gemmaPanel.querySelector('[data-role="gemma-run"]')?.addEventListener('click', (event) => { + runOrPrepareGemma({ + mode: gemmaMode?.value || "full_json_enhance", + brief: gemmaBrief?.value || "", + speed: gemmaSpeed?.value || "fast", + statusHost: gemmaStatus, + anchor: event.currentTarget, + }); + }); + gemmaPanel.querySelector('[data-role="gemma-stop"]')?.addEventListener('click', (event) => { + stopGemmaAssistant(event.currentTarget, gemmaStatus); + }); + gemmaPanel.querySelector('[data-role="gemma-apply"]')?.addEventListener('click', (event) => { + applyGemmaPending(event.currentTarget, gemmaStatus); + }); + gemmaPanel.querySelector('[data-role="gemma-reject"]')?.addEventListener('click', (event) => { + clearGemmaPending(gemmaStatus, "Gemma proposal rejected."); + showToast("Gemma proposal rejected.", { anchor: event.currentTarget, tone: "warn", ms: 2400 }); + }); + gemmaPanel.querySelector('[data-role="gemma-copy-result"]')?.addEventListener('click', (event) => { + copyGemmaPending(event.currentTarget); + }); + gemmaPanel.querySelector('[data-role="gemma-copy-request"]')?.addEventListener('click', (event) => { + const request = buildGemmaFrontendRequest(gemmaMode?.value || "full_json_enhance", gemmaBrief?.value || "", { speed: gemmaSpeed?.value || "fast" }); + request.model = gemmaModel?.value || "text_encoders\\gemma4_e4b_it_fp8_scaled.safetensors"; + navigator.clipboard?.writeText(JSON.stringify(request, null, 2)); + showToast("Gemma request copied", { anchor: event.currentTarget, tone: "success" }); + }); if (isV2) { const i2iPanel = document.createElement('div'); - i2iPanel.className = 'iamccs-isf-panel'; + i2iPanel.className = 'iamccs-isf-panel iamccs-isf-refine-panel'; i2iPanel.innerHTML = ` -

Refine / Image Guide

- +
+

Refine / Image Guide BETA

+ +
+
+ +
`; scenePane.appendChild(i2iPanel); + const refineBody = i2iPanel.querySelector('[data-role="refine-guide-body"]'); + const refineToggle = i2iPanel.querySelector('[data-role="toggle-refine-guide"]'); + refineToggle?.addEventListener('change', () => { + state.showRefineGuide = Boolean(refineToggle.checked); + if (refineBody) refineBody.hidden = !state.showRefineGuide; + persistUiState(); + }); i2iPanel.querySelector('[data-action="refine"]')?.addEventListener('click', applyRefinePreset); - i2iFields.enabled = makeField(i2iPanel, 'Use Canvas/Image as i2i Guide', 'i2i_enabled', { type: 'checkbox' }); + i2iFields.enabled = makeField(refineBody || i2iPanel, 'Use Canvas/Image as i2i Guide', 'i2i_enabled', { type: 'checkbox' }); i2iFields.enabled.type = 'checkbox'; const i2iGuideNote = document.createElement('p'); i2iGuideNote.className = 'iamccs-isf-direct-note'; i2iGuideNote.textContent = 'ON: the current canvas or imported image keeps composition and subject placement. OFF: Ideogram follows the prompt more freely.'; - i2iPanel.appendChild(i2iGuideNote); + (refineBody || i2iPanel).appendChild(i2iGuideNote); const denoiseGuide = document.createElement('div'); denoiseGuide.className = 'iamccs-isf-denoise-guide'; denoiseGuide.innerHTML = ` @@ -3772,13 +4553,13 @@ function install(node) {

This writes to the node output i2i_denoise. Lower values keep the canvas image; higher values let Ideogram rebuild more of the shot.

`; - i2iPanel.appendChild(denoiseGuide); + (refineBody || i2iPanel).appendChild(denoiseGuide); - i2iFields.denoise = makeField(i2iPanel, 'Manual Denoise Value', 'i2i_denoise', { type: 'number' }); + i2iFields.denoise = makeField(refineBody || i2iPanel, 'Manual Denoise Value', 'i2i_denoise', { type: 'number' }); i2iFields.denoise.min = '0'; i2iFields.denoise.max = '1'; i2iFields.denoise.step = '0.01'; - i2iFields.step = makeField(i2iPanel, 'Low Sigma Start Step', 'low_sigma_start_step', { type: 'number' }); + i2iFields.step = makeField(refineBody || i2iPanel, 'Low Sigma Start Step', 'low_sigma_start_step', { type: 'number' }); i2iPanel.querySelectorAll('[data-denoise-preset]').forEach((button) => { button.addEventListener('click', () => { @@ -3846,7 +4627,7 @@ function install(node) { enableNumberDrag(input, (value, fromDrag) => commitCanvasDimension(dimension, value, fromDrag), { step: 16, min: 256, max: 16384 }); }); Object.entries(sceneFields).forEach(([key, input]) => { - if (['width', 'height', 'targetResolution', 'targetResolutionNote'].includes(key)) return; + if (['width', 'height', 'targetResolution', 'targetResolutionNote', 'palettePreset', 'paletteNote'].includes(key)) return; input.addEventListener('input', () => { if (key === 'aspect') state.data.canvas.aspect_label = input.value; else if (key === 'high') state.data.scene.high_level_description = input.value; @@ -3904,6 +4685,38 @@ function install(node) { }); maskPanel.querySelector('[data-mask-clear]')?.addEventListener('click', (event) => clearCurrentImageMask(event.currentTarget)); } + const showImgPanel = document.createElement('div'); + showImgPanel.className = 'iamccs-isf-panel iamccs-isf-show-img-panel'; + showImgPanel.innerHTML = ` +

Show Img Under Boxes

+
+ + +
+
+ + 82% +
+
+ Visual only: this does not enter the JSON, IDEO_LINX, crops, or final render. + +
`; + inspectorPane.appendChild(showImgPanel); + showImgPanel.querySelector('[data-role="show-img-toggle"]')?.addEventListener('change', (event) => { + state.showResultImage = Boolean(event.currentTarget.checked); + if (state.showResultImage && state.resultBgUrl) state.resultOverlayVisible = true; + persistUiState(); + syncShowImgControls(); + renderArtboard(); + }); + showImgPanel.querySelector('[data-role="show-img-opacity"]')?.addEventListener('input', (event) => { + state.resultOverlayOpacity = Math.max(0, Math.min(1, Number(event.currentTarget.value || 0) / 100)); + persistUiState(); + syncShowImgControls(); + }); + showImgPanel.querySelector('[data-role="show-img-grab"]')?.addEventListener('click', (event) => grabLastResultBackground(event.currentTarget)); + showImgPanel.querySelector('[data-role="show-img-clear"]')?.addEventListener('click', () => clearResultBackground()); + const layers = document.createElement('div'); layers.className = 'iamccs-isf-panel layers-panel'; layers.innerHTML = '

Frame Layers

'; @@ -3926,10 +4739,30 @@ function install(node) { itemPanel.className = 'iamccs-isf-panel editor-panel'; itemPanel.innerHTML = '

Selected Layer

'; inspectorPane.appendChild(itemPanel); - itemFields.label = makeField(itemPanel, 'Layer Label', 'label'); - itemFields.text = makeField(itemPanel, 'Rendered Text', 'text', { multiline: true, small: true }); - itemFields.desc = makeField(itemPanel, 'Visual Description', 'desc', { multiline: true }); - itemFields.palette = makeField(itemPanel, 'Layer Palette', 'palette'); + itemFields.label = makeField(itemPanel, 'Layer Label', 'label', { gemmaField: 'item.label', itemScoped: true }); + itemFields.text = makeField(itemPanel, 'Rendered Text', 'text', { multiline: true, small: true, gemmaField: 'item.text', itemScoped: true }); + itemFields.desc = makeField(itemPanel, 'Visual Description', 'desc', { multiline: true, gemmaField: 'item.desc', itemScoped: true }); + itemFields.palette = makeField(itemPanel, 'Layer Palette', 'palette', { gemmaField: 'item.palette', itemScoped: true }); + const layerPaletteTools = document.createElement('div'); + layerPaletteTools.className = 'iamccs-isf-field iamccs-isf-palette-tools'; + layerPaletteTools.innerHTML = ` + + +

+ `; + itemPanel.appendChild(layerPaletteTools); + itemFields.palettePreset = layerPaletteTools.querySelector('[data-role="layer-palette-preset"]'); + itemFields.paletteNote = layerPaletteTools.querySelector('[data-role="layer-palette-note"]'); + renderSelectOptions(itemFields.palettePreset, paletteEntries(), "custom"); + itemFields.palettePreset?.addEventListener('change', (event) => { + const item = currentItem(); + const key = event.currentTarget.value || "custom"; + if (!item || key === "custom") return; + item.color_palette = applyPalettePresetToList(key, item.color_palette); + persist(); + render(); + showToast(`Layer palette: ${paletteEntries()[key]?.label || key}`, { anchor: event.currentTarget, tone: "success", ms: 1800 }); + }); itemFields.image_path = makeField(itemPanel, 'Image Path', 'image_path'); itemFields.fit = makeField(itemPanel, 'Image Fit', 'fit'); itemFields.opacity = makeField(itemPanel, 'Image Opacity', 'opacity', { type: 'number' }); @@ -3985,6 +4818,7 @@ function install(node) { }); Object.entries(itemFields).forEach(([key, input]) => { + if (['palettePreset', 'paletteNote'].includes(key)) return; input.addEventListener('input', () => { const item = currentItem(); if (!item) return; @@ -4119,6 +4953,14 @@ function install(node) { sceneFields.medium.value = state.data.scene.medium || ''; sceneFields.photo.value = state.data.scene.photo || ''; sceneFields.palette.value = (state.data.scene.color_palette || []).join(', '); + if (sceneFields.palettePreset) { + renderSelectOptions(sceneFields.palettePreset, paletteEntries(), paletteKeyForColors(state.data.scene.color_palette)); + sceneFields.palettePreset.value = paletteKeyForColors(state.data.scene.color_palette); + } + if (sceneFields.paletteNote) { + const key = paletteKeyForColors(state.data.scene.color_palette); + sceneFields.paletteNote.textContent = paletteEntries()[key]?.summary || "Typed hex palette is exported to Ideogram JSON."; + } state.data.reference_mode = normalizeReferenceMode(state.data.reference_mode); if (workflowModeEntries()[state.data.workflow_mode]) state.workflowModeKey = state.data.workflow_mode; state.workflowModeKey = currentWorkflowMode().key; @@ -4648,6 +5490,15 @@ function install(node) { itemFields.text.disabled = item.kind !== 'text'; itemFields.desc.value = item.desc || ''; itemFields.palette.value = (item.color_palette || []).join(', '); + if (itemFields.palettePreset) { + renderSelectOptions(itemFields.palettePreset, paletteEntries(), paletteKeyForColors(item.color_palette)); + itemFields.palettePreset.value = paletteKeyForColors(item.color_palette); + itemFields.palettePreset.disabled = disabled; + } + if (itemFields.paletteNote) { + const key = paletteKeyForColors(item.color_palette); + itemFields.paletteNote.textContent = paletteEntries()[key]?.summary || "Typed hex palette is exported on this layer."; + } itemFields.image_path.value = item.image_path || ''; itemFields.fit.value = item.fit || 'cover'; itemFields.opacity.value = item.opacity ?? 1; @@ -5001,13 +5852,21 @@ function install(node) { } const rawItems = Array.isArray(state.data.items) ? state.data.items : []; const items = rawItems.map((item, index) => normalizeItem(item, index)); - if (JSON.stringify(rawItems) !== JSON.stringify(items)) state.data.items = items; + state.data.items = items; const canvas = state.data.canvas || {}; const canvasW = Math.max(1, Number(canvas.width || 1024)); const canvasH = Math.max(1, Number(canvas.height || 1024)); const aspect = canvasW / canvasH; const visibleItems = items.filter((item) => item?.kind !== "mask"); const meta = `${canvasW} x ${canvasH} - ${canvas.aspect_label || "Canvas"} - ${visibleItems.length} layer${visibleItems.length === 1 ? "" : "s"}`; + const showResultBg = Boolean(state.showResultImage && state.resultOverlayVisible && cleanText(state.resultBgUrl)); + const resultOpacity = Math.max(0, Math.min(1, Number(state.resultOverlayOpacity) || 0)); + const boxFillOpacity = showResultBg ? Math.max(0.08, 0.82 - (resultOpacity * 0.74)) : 0.82; + const boxHeadOpacity = showResultBg ? Math.max(0.26, 0.86 - (resultOpacity * 0.58)) : 0.86; + const boxBodyOpacity = showResultBg ? Math.max(0.04, 0.22 - (resultOpacity * 0.18)) : 0.22; + const resultBgHtml = showResultBg + ? `` + : ""; const itemHtml = visibleItems.map((item, index) => { const kind = cleanText(item.kind || "obj") || "obj"; const label = cleanText(item.label || item.id || `Layer ${index + 1}`); @@ -5036,10 +5895,12 @@ function install(node) { }).join(""); boardPreview.innerHTML = `
${escapeXml(meta)}
-
- ${itemHtml || `
Add Object, Add Text, or Add Image. Drag boxes directly on this board; resize with the white handle.
`} +
+ ${resultBgHtml} + ${itemHtml || (showResultBg ? "" : `
Add Object, Add Text, or Add Image. Drag boxes directly on this board; resize with the white handle.
`)}
`; artboard = boardPreview.querySelector('[data-artboard]') || boardPreview; + applyResultCompareCss(); footStatus.textContent = `Canvas layers visible: ${visibleItems.length}`; artboard.querySelectorAll('[data-item-id]').forEach((el) => { const id = el.getAttribute('data-item-id'); @@ -5063,14 +5924,46 @@ function install(node) { startDrag(event, item, mode); }); const body = el.querySelector('[data-edit-body]'); + const commitBodyEdit = () => { + const stored = (state.data.items || []).find((entry) => entry.id === item.id) || item; + const value = cleanText(body?.textContent || ""); + if (stored.kind === 'text') { + stored.text = value; + item.text = value; + } else if (stored.kind !== 'image') { + stored.desc = value; + item.desc = value; + } + if (state.selectedId === stored.id) { + if (stored.kind === 'text' && itemFields.text && document.activeElement !== itemFields.text) itemFields.text.value = stored.text || ""; + if (stored.kind !== 'image' && itemFields.desc && document.activeElement !== itemFields.desc) itemFields.desc.value = stored.desc || ""; + } + writeData(node, state.data); + updatePromptPreview(); + }; body?.addEventListener('pointerdown', (event) => { - if (body.isContentEditable) event.stopPropagation(); + if (body.isContentEditable) { + state.selectedId = item.id; + syncItemFields(); + renderLayerList(); + event.stopPropagation(); + } + }); + body?.addEventListener('input', () => { + commitBodyEdit(); + }); + body?.addEventListener('paste', () => { + window.setTimeout(commitBodyEdit, 0); + }); + body?.addEventListener('keydown', (event) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { + event.preventDefault(); + commitBodyEdit(); + showToast("Box text saved to Visual Description", { anchor: body, tone: "success", ms: 1400 }); + } }); body?.addEventListener('blur', () => { - const value = cleanText(body.textContent || ""); - if (item.kind === 'text') item.text = value; - else if (item.kind !== 'image') item.desc = value; - persist(); + commitBodyEdit(); syncItemFields(); renderLayerList(); updatePromptPreview(); @@ -5118,6 +6011,7 @@ function install(node) { renderLayerList(); renderArtboard(); updatePromptPreview(); + syncShowImgControls(); } async function uploadImageFiles(files) { @@ -5316,8 +6210,17 @@ function install(node) { state.paperMode = !state.paperMode; state.data.ui = { ...(state.data.ui || {}), paper_mode: state.paperMode }; syncPaperModeButton(); + persistUiState(); persist(); return; + } else if (action === 'toggle-show-img') { + state.showResultImage = !state.showResultImage; + if (state.showResultImage && state.resultBgUrl) state.resultOverlayVisible = true; + persistUiState(); + syncShowImgControls(); + renderArtboard(); + showToast(state.showResultImage ? "Generated image will show under boxes" : "Generated image hidden under boxes", { anchor: button, tone: "success", ms: 2200 }); + return; } else if (action === 'copy-json') { updatePromptPreview(); const payload = previewField?.value || JSON.stringify(toPrompt(state.data), null, 2); diff --git a/web/iamccs_multitimeline_bridge_ui.js b/web/iamccs_multitimeline_bridge_ui.js index 371b961..660b6a4 100644 --- a/web/iamccs_multitimeline_bridge_ui.js +++ b/web/iamccs_multitimeline_bridge_ui.js @@ -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 = `T${String(take).padStart(2, "0")}${take === active ? "prepared now" : "click to prepare"}`; + card.innerHTML = `T${String(take).padStart(2, "0")} / A${take}${take === active ? "prepared now" : "click to prepare"}`; 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); }; diff --git a/web/iamccs_shotboard_video_editor_ui.js b/web/iamccs_shotboard_video_editor_ui.js new file mode 100644 index 0000000..f1c8d0b --- /dev/null +++ b/web/iamccs_shotboard_video_editor_ui.js @@ -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 = `
Shotboard Video Editor
timeline editor / monitor / T-A takes
`; + 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 = `T/A TAKES`; + 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 = `
${label}${right}
${label}: ${mode === "preview_selected_take" ? `T${selected}/A${selected}` : order.join(" -> ")}
`; + 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 = `${sec}s
${Math.round(sec * fps)}f
`; + 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 = `${name}
`; + 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 = `
${type === "audio" ? '' : ""}
${type === "audio" ? `A${take}` : `T${String(take).padStart(2, "0")}`} ${type === "audio" ? "audio" : "video"}
`; + 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); + }; + }, +});