Bug fixed and new nodes utilities added - Node Update 1.4.9
This commit is contained in:
+32
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
|
||||
|
||||
SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX"
|
||||
|
||||
|
||||
def _base_linx(phase, enabled):
|
||||
return {
|
||||
"schema": "iamccs_audio_phase_lazy_gate",
|
||||
"version": 1,
|
||||
"phase": str(phase or "dialogue_to_audioboard"),
|
||||
"enabled": bool(enabled),
|
||||
"resources": {},
|
||||
"meta": {
|
||||
"created_at": time.time(),
|
||||
"truth": "IAMCCS lazy gates evaluate only the active audio/video phase and pass CineLinx/audio timeline payloads without changing their contents.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _clone_linx(cine_linx):
|
||||
if isinstance(cine_linx, dict):
|
||||
try:
|
||||
return copy.deepcopy(cine_linx)
|
||||
except Exception:
|
||||
return dict(cine_linx)
|
||||
if isinstance(cine_linx, str) and cine_linx.strip():
|
||||
try:
|
||||
parsed = json.loads(cine_linx)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class IAMCCS_AudioPhaseLazyGate:
|
||||
"""
|
||||
Lazy CineLinx/audio phase gate for splitting large IAMCCS audio workflows into explicit stages.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"phase": ([
|
||||
"dialogue_to_audioboard",
|
||||
"publish_to_shotboard",
|
||||
"video_from_shotboard",
|
||||
], {"default": "dialogue_to_audioboard"}),
|
||||
"enabled": ("BOOLEAN", {"default": True, "label_on": "ACTIVE", "label_off": "BYPASS"}),
|
||||
},
|
||||
"optional": {
|
||||
"cine_linx": (SUPERNODE_LINX_TYPE, {"lazy": True}),
|
||||
"audio_timeline_json": ("STRING", {"forceInput": True, "lazy": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "BOOLEAN", "STRING")
|
||||
RETURN_NAMES = ("cine_linx", "audio_timeline_json", "enabled", "report")
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "IAMCCS/Audio Tools"
|
||||
|
||||
def check_lazy_status(self, phase, enabled=True, cine_linx=None, audio_timeline_json=None):
|
||||
if not enabled:
|
||||
return []
|
||||
requested = []
|
||||
if cine_linx is None:
|
||||
requested.append("cine_linx")
|
||||
return requested
|
||||
|
||||
def run(self, phase, enabled=True, cine_linx=None, audio_timeline_json=""):
|
||||
phase = str(phase or "dialogue_to_audioboard")
|
||||
if not enabled:
|
||||
payload = _base_linx(phase, False)
|
||||
report = {
|
||||
"node": "IAMCCS_AudioPhaseLazyGate",
|
||||
"phase": phase,
|
||||
"enabled": False,
|
||||
"truth": "Disabled lazy gate did not request upstream lazy inputs.",
|
||||
}
|
||||
return (payload, "", False, json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
payload = _clone_linx(cine_linx) or _base_linx(phase, True)
|
||||
resources = payload.setdefault("resources", {})
|
||||
timeline_json = str(audio_timeline_json or resources.get("cine_audio_timeline_json") or resources.get("audio_timeline_json") or "")
|
||||
resources["iamccs_audio_phase_lazy_gate"] = {
|
||||
"phase": phase,
|
||||
"enabled": True,
|
||||
"has_audio_timeline_json": bool(timeline_json.strip()),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
if timeline_json.strip():
|
||||
resources["cine_audio_timeline_json"] = timeline_json
|
||||
resources["audio_timeline_json"] = timeline_json
|
||||
chain = payload.setdefault("iamccs_phase_chain", [])
|
||||
if isinstance(chain, list):
|
||||
chain.append({"node": "IAMCCS_AudioPhaseLazyGate", "phase": phase, "enabled": True})
|
||||
report = {
|
||||
"node": "IAMCCS_AudioPhaseLazyGate",
|
||||
"phase": phase,
|
||||
"enabled": True,
|
||||
"has_cine_linx": cine_linx is not None,
|
||||
"has_audio_timeline_json": bool(timeline_json.strip()),
|
||||
"truth": "Enabled lazy gate passes CineLinx and audio timeline payloads unchanged except for phase metadata.",
|
||||
}
|
||||
return (payload, timeline_json, True, json.dumps(report, ensure_ascii=False, indent=2))
|
||||
+105
-9
@@ -107,6 +107,10 @@ def _speaker_order(dialogue: Dict[str, Any]) -> List[str]:
|
||||
|
||||
|
||||
def _dialogue_speaker_stem_start_frames(dialogue: Dict[str, Any], fps: float) -> Dict[str, int]:
|
||||
settings = dialogue.get("settings") if isinstance(dialogue.get("settings"), dict) else {}
|
||||
template = dialogue.get("audio_board_template") if isinstance(dialogue.get("audio_board_template"), dict) else {}
|
||||
if bool(settings.get("speaker_stems_zero_start") or template.get("speakerStemsZeroStart")):
|
||||
return {key: 0 for key in _speaker_order(dialogue)}
|
||||
explicit = dialogue.get("speaker_stem_start_frames") if isinstance(dialogue.get("speaker_stem_start_frames"), dict) else {}
|
||||
if explicit:
|
||||
out = {}
|
||||
@@ -205,6 +209,18 @@ def _line_to_srt(index: int, start: float, end: float, text: str) -> str:
|
||||
return f"{index}\n{_srt_timestamp(start)} --> {_srt_timestamp(end)}\n{text.strip()}\n\n"
|
||||
|
||||
|
||||
def _speaker_stem_srt_local_zero(dialogue: Dict[str, Any]) -> bool:
|
||||
"""Keep generated per-speaker WAVs local, while timeline lanes store the real offset."""
|
||||
settings = dialogue.get("settings") if isinstance(dialogue.get("settings"), dict) else {}
|
||||
template = dialogue.get("audio_board_template") if isinstance(dialogue.get("audio_board_template"), dict) else {}
|
||||
for key in ("speaker_stem_srt_local_zero", "speakerStemSrtLocalZero"):
|
||||
if key in settings:
|
||||
return bool(settings.get(key))
|
||||
if key in template:
|
||||
return bool(template.get(key))
|
||||
return True
|
||||
|
||||
|
||||
def _strip_inline_tts_tags(text: str) -> str:
|
||||
# In plain dialogue mode, keep only words meant to be spoken. Metadata stays in cine_linx.
|
||||
clean = re.sub(r"<[^>]+>", "", str(text or ""))
|
||||
@@ -212,11 +228,52 @@ def _strip_inline_tts_tags(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", clean).strip()
|
||||
|
||||
|
||||
def _strip_angle_tts_tags(text: str) -> str:
|
||||
clean = re.sub(r"<[^>]+>", "", str(text or ""))
|
||||
return re.sub(r"\s+", " ", clean).strip()
|
||||
|
||||
|
||||
def _has_indextts_character_tags(text: str) -> bool:
|
||||
return bool(re.search(r"\[[^\]\n:]+:[^\]\n]+\]", str(text or "")))
|
||||
|
||||
|
||||
def _line_text(line: Dict[str, Any], mode: str) -> str:
|
||||
text = str(line.get("ttsText") or line.get("text") or line.get("dialogueText") or "").strip()
|
||||
speaker = str(line.get("speaker") or line.get("speakerName") or "").strip()
|
||||
if mode == "plain_dialogue":
|
||||
return _strip_inline_tts_tags(text)
|
||||
if mode == "index_tts_text_emotion":
|
||||
return _strip_inline_tts_tags(text)
|
||||
if mode == "index_tts_character_tags":
|
||||
clean = _strip_angle_tts_tags(text)
|
||||
if _has_indextts_character_tags(clean):
|
||||
return clean
|
||||
clean = _strip_inline_tts_tags(clean)
|
||||
emotion = str(line.get("emotion") or "none").strip()
|
||||
tag = speaker or str(line.get("speakerName") or "Speaker").strip() or "Speaker"
|
||||
if emotion and emotion.lower() != "none":
|
||||
tag = f"{tag}:{emotion}"
|
||||
return f"[{tag}] {clean}".strip()
|
||||
if mode == "chatterbox_v2_tokens":
|
||||
clean = _strip_inline_tts_tags(text)
|
||||
para = str(line.get("paralinguistic") or line.get("para") or "none").strip()
|
||||
style = str(line.get("style") or "none").strip().lower()
|
||||
tokens = []
|
||||
token_map = {
|
||||
"Breathing": "<inhale>",
|
||||
"Laughter": "<laughter>",
|
||||
"Sigh": "<sigh>",
|
||||
"Surprise-oh": "<gasp>",
|
||||
"Uhm": "<UM>",
|
||||
}
|
||||
if para in token_map:
|
||||
tokens.append(token_map[para])
|
||||
if "whisper" in style:
|
||||
tokens.append("<whisper>")
|
||||
if "murmur" in style or "mumble" in style:
|
||||
tokens.append("<mumble>")
|
||||
prefix = " ".join(tokens).strip()
|
||||
return f"{prefix} {clean}".strip()
|
||||
if speaker and not text.startswith("[") and mode in {"speaker_tags", "tts_audio_suite_tags"}:
|
||||
text = f"[{speaker}|en] {text}"
|
||||
if mode == "tts_audio_suite_tags":
|
||||
@@ -235,8 +292,19 @@ def _line_text(line: Dict[str, Any], mode: str) -> str:
|
||||
def _export_speaker_srts(dialogue: Dict[str, Any], segments: List[Dict[str, Any]], fps: float, mode: str) -> Dict[str, str]:
|
||||
order = _speaker_order(dialogue)
|
||||
grouped: Dict[str, List[str]] = {key: [] for key in order}
|
||||
settings = dialogue.get("settings") if isinstance(dialogue.get("settings"), dict) else {}
|
||||
template = dialogue.get("audio_board_template") if isinstance(dialogue.get("audio_board_template"), dict) else {}
|
||||
zero_start_stems = _speaker_stem_srt_local_zero(dialogue)
|
||||
lines = dialogue.get("export_lines") if isinstance(dialogue.get("export_lines"), list) else dialogue.get("lines")
|
||||
if isinstance(lines, list) and lines:
|
||||
offsets: Dict[str, float] = {}
|
||||
if zero_start_stems:
|
||||
for line in lines:
|
||||
if not isinstance(line, dict):
|
||||
continue
|
||||
key = str(line.get("speaker") or line.get("speakerName") or order[0])
|
||||
start = max(0.0, _safe_float(line.get("start", 0.0), 0.0))
|
||||
offsets[key] = min(offsets.get(key, start), start)
|
||||
counters: Dict[str, int] = {}
|
||||
for line in lines:
|
||||
if not isinstance(line, dict):
|
||||
@@ -244,12 +312,23 @@ def _export_speaker_srts(dialogue: Dict[str, Any], segments: List[Dict[str, Any]
|
||||
key = str(line.get("speaker") or line.get("speakerName") or order[0])
|
||||
if key not in grouped:
|
||||
grouped[key] = []
|
||||
start = _safe_float(line.get("start", 0.0), 0.0)
|
||||
absolute_start = _safe_float(line.get("start", 0.0), 0.0)
|
||||
duration = _safe_float(line.get("duration", 0.0), 0.0)
|
||||
end = _safe_float(line.get("end", start + duration), start + max(0.8, duration))
|
||||
absolute_end = _safe_float(line.get("end", absolute_start + duration), absolute_start + max(0.8, duration))
|
||||
offset = offsets.get(key, 0.0)
|
||||
start = max(0.0, absolute_start - offset)
|
||||
end = max(start + 0.2, absolute_end - offset)
|
||||
counters[key] = counters.get(key, 0) + 1
|
||||
grouped[key].append(_line_to_srt(counters[key], start, max(end, start + 0.2), _line_text(line, mode)))
|
||||
grouped[key].append(_line_to_srt(counters[key], start, end, _line_text(line, mode)))
|
||||
else:
|
||||
offsets: Dict[str, float] = {}
|
||||
if zero_start_stems:
|
||||
for seg in segments:
|
||||
if not isinstance(seg, dict):
|
||||
continue
|
||||
key = str(seg.get("speaker") or seg.get("speakerName") or order[0])
|
||||
start = max(0.0, _safe_int(seg.get("start", 0), 0) / max(1.0, fps))
|
||||
offsets[key] = min(offsets.get(key, start), start)
|
||||
counters: Dict[str, int] = {}
|
||||
for seg in segments:
|
||||
if not isinstance(seg, dict):
|
||||
@@ -257,8 +336,9 @@ def _export_speaker_srts(dialogue: Dict[str, Any], segments: List[Dict[str, Any]
|
||||
key = str(seg.get("speaker") or seg.get("speakerName") or order[0])
|
||||
if key not in grouped:
|
||||
grouped[key] = []
|
||||
start = _safe_int(seg.get("start", 0), 0) / max(1.0, fps)
|
||||
absolute_start = _safe_int(seg.get("start", 0), 0) / max(1.0, fps)
|
||||
length = max(1, _safe_int(seg.get("length", 1), 1)) / max(1.0, fps)
|
||||
start = max(0.0, absolute_start - offsets.get(key, 0.0))
|
||||
counters[key] = counters.get(key, 0) + 1
|
||||
grouped[key].append(_line_to_srt(counters[key], start, start + length, _line_text(seg, mode)))
|
||||
return {key: "".join(parts).strip() for key, parts in grouped.items()}
|
||||
@@ -420,6 +500,9 @@ class IAMCCS_CineAudioInfo:
|
||||
"frame_rate": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}),
|
||||
"tts_text_mode": ([
|
||||
"tts_audio_suite_tags",
|
||||
"index_tts_text_emotion",
|
||||
"index_tts_character_tags",
|
||||
"chatterbox_v2_tokens",
|
||||
"speaker_tags",
|
||||
"plain_dialogue",
|
||||
], {"default": "tts_audio_suite_tags"}),
|
||||
@@ -430,7 +513,7 @@ class IAMCCS_CineAudioInfo:
|
||||
"attach_to_first_lane",
|
||||
], {"default": "slice_master_by_existing_lanes"}),
|
||||
"save_subfolder": ("STRING", {"default": "IAMCCS_generated_audio", "multiline": False}),
|
||||
"file_prefix": ("STRING", {"default": "dialogue_tts_master", "multiline": False}),
|
||||
"file_prefix": ("STRING", {"default": "dialogue_tts_single_master", "multiline": False}),
|
||||
},
|
||||
"optional": {
|
||||
"cine_linx": (SUPERNODE_LINX_TYPE,),
|
||||
@@ -461,9 +544,14 @@ class IAMCCS_CineAudioInfo:
|
||||
speaker_order = _speaker_order(dialogue)
|
||||
speaker_a_srt = speaker_srts.get(speaker_order[0], "") if speaker_order else ""
|
||||
speaker_b_srt = speaker_srts.get(speaker_order[1], "") if len(speaker_order) > 1 else ""
|
||||
effective_lane_injection_mode = str(lane_injection_mode)
|
||||
if str(mode) == "export_speaker_stems":
|
||||
tts_srt = speaker_a_srt
|
||||
# Keep tts_srt as the master dialogue export. Speaker-specific outputs
|
||||
# must remain the only source for A/B stems, otherwise B can silently
|
||||
# fall back to Speaker A in existing graphs.
|
||||
tts_text = "\n".join(part.strip() for part in [speaker_a_srt, speaker_b_srt] if part.strip())
|
||||
if str(mode) == "inject_speaker_stems" and effective_lane_injection_mode == "slice_master_by_existing_lanes":
|
||||
effective_lane_injection_mode = "speaker_full_timeline_clips"
|
||||
|
||||
duration_frames = _max_end_frames(segments)
|
||||
duration_seconds = float(duration_frames) / fps if duration_frames else _safe_float(timeline.get("duration_seconds", outputs.get("duration_seconds", 0.0)), 0.0)
|
||||
@@ -486,7 +574,7 @@ class IAMCCS_CineAudioInfo:
|
||||
audio_frames = max(1, int(math.ceil(audio_duration * fps)))
|
||||
duration_frames = max(duration_frames, audio_frames)
|
||||
duration_seconds = max(duration_seconds, audio_duration)
|
||||
if str(lane_injection_mode) == "speaker_full_timeline_clips" or not segments:
|
||||
if effective_lane_injection_mode == "speaker_full_timeline_clips" or not segments:
|
||||
speaker_start_frames = _dialogue_speaker_stem_start_frames(dialogue, fps)
|
||||
for source_seg in source_segments:
|
||||
key = str(source_seg.get("speaker") or source_seg.get("speakerName") or "")
|
||||
@@ -555,7 +643,7 @@ class IAMCCS_CineAudioInfo:
|
||||
segments = [{
|
||||
"id": "dialogue_tts_master",
|
||||
"type": "audio",
|
||||
"name": "Dialogue TTS Master",
|
||||
"name": "Dialogue TTS Single Master",
|
||||
"track": 0,
|
||||
"start": 0,
|
||||
"length": audio_frames,
|
||||
@@ -628,7 +716,8 @@ class IAMCCS_CineAudioInfo:
|
||||
resources.update({
|
||||
"cine_audio_info": {
|
||||
"mode": str(mode),
|
||||
"lane_injection_mode": str(lane_injection_mode),
|
||||
"lane_injection_mode": effective_lane_injection_mode,
|
||||
"requested_lane_injection_mode": str(lane_injection_mode),
|
||||
"tts_text_mode": str(tts_text_mode),
|
||||
"generated_audio_file": injected_file,
|
||||
"speaker_stem_files": dict(([(str(speaker_order[0]), file_a if "file_a" in locals() else "")] if speaker_order else []) + ([(str(speaker_order[1]), file_b if "file_b" in locals() else "")] if len(speaker_order) > 1 else [])),
|
||||
@@ -685,12 +774,19 @@ class IAMCCS_CineAudioInfo:
|
||||
report = json.dumps({
|
||||
"node": "IAMCCS_CineAudioInfo",
|
||||
"mode": str(mode),
|
||||
"lane_injection_mode": effective_lane_injection_mode,
|
||||
"segments": len(segments),
|
||||
"tracks": track_count,
|
||||
"has_media": bool(has_media),
|
||||
"audio_file": injected_file,
|
||||
"speaker_stem_files": resources.get("cine_audio_info", {}).get("speaker_stem_files", {}),
|
||||
"duration_seconds": float(duration_seconds),
|
||||
"tts_srt_chars": len(tts_srt),
|
||||
"speaker_a_srt_chars": len(resources["cine_dialogue_speaker_a_srt"]),
|
||||
"speaker_b_srt_chars": len(resources["cine_dialogue_speaker_b_srt"]),
|
||||
"speaker_srts_identical": bool(resources["cine_dialogue_speaker_a_srt"] and resources["cine_dialogue_speaker_a_srt"] == resources["cine_dialogue_speaker_b_srt"]),
|
||||
"speaker_a_srt_preview": str(resources["cine_dialogue_speaker_a_srt"])[:120],
|
||||
"speaker_b_srt_preview": str(resources["cine_dialogue_speaker_b_srt"])[:120],
|
||||
"truth": "CineAudioInfo exports dialogue SRT to TTS and injects generated AUDIO back into Shotboard-compatible AudioBoard lanes through cine_linx.",
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -432,8 +432,60 @@ def _apply_active_take(
|
||||
outputs["max_frames"] = int(duration_frames)
|
||||
|
||||
|
||||
def _take_audio_lane_name(take_index: Any) -> str:
|
||||
take = max(1, _safe_int(take_index, 1))
|
||||
return f"A{take}"
|
||||
|
||||
|
||||
def _take_timeline_id(take_index: Any) -> str:
|
||||
take = max(1, _safe_int(take_index, 1))
|
||||
return f"T{take:02d}"
|
||||
|
||||
|
||||
def _make_take_audio_contract(takes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
contract: List[Dict[str, Any]] = []
|
||||
for idx, take in enumerate(takes):
|
||||
take_index = max(1, _safe_int(take.get("take_index", idx + 1), idx + 1))
|
||||
timeline_id = str(take.get("timeline_id") or _take_timeline_id(take_index))
|
||||
audio_lane = str(take.get("audio_lane") or _take_audio_lane_name(take_index))
|
||||
audio_track_index = max(0, _safe_int(take.get("audio_track_index", take_index - 1), take_index - 1))
|
||||
contract.append({
|
||||
"take_index": take_index,
|
||||
"timeline_id": timeline_id,
|
||||
"audio_lane": audio_lane,
|
||||
"audio_track_index": audio_track_index,
|
||||
"mapping": f"{timeline_id}->{audio_lane}",
|
||||
"rule": "one_indexed_audio_lane_per_timeline",
|
||||
})
|
||||
return contract
|
||||
|
||||
|
||||
def _make_sequence_plan(index: Dict[str, Any]) -> Dict[str, Any]:
|
||||
takes = index.get("takes") if isinstance(index.get("takes"), list) else []
|
||||
contract = _make_take_audio_contract(takes)
|
||||
return {
|
||||
"schema": "iamccs.multigeneration.sequence_plan",
|
||||
"schema_version": 1,
|
||||
"source": "IAMCCS_MultiTimelineBridge",
|
||||
"mode": "manual_or_sequential",
|
||||
"manual_active_take": _safe_int(index.get("active_take", 1), 1),
|
||||
"queue_policy": "manual_take_picker_branches_or_external_sequential_queue",
|
||||
"contract": contract,
|
||||
"steps": [{
|
||||
"step_index": idx + 1,
|
||||
"take_index": item["take_index"],
|
||||
"timeline_id": item["timeline_id"],
|
||||
"audio_lane": item["audio_lane"],
|
||||
"bridge_action": f"prepare_{item['timeline_id']}_{item['audio_lane']}",
|
||||
"expected_video_slot": f"video_take_{item['take_index']:02d}",
|
||||
} for idx, item in enumerate(contract)],
|
||||
"truth": "T1 uses A1, T2 uses A2, T3 uses A3. Manual mode prepares one take; sequential mode queues each step in order and sends generated clips to the Shotboard Video Editor.",
|
||||
}
|
||||
|
||||
|
||||
def _make_concat_plan(index: Dict[str, Any], source_bus: str) -> Dict[str, Any]:
|
||||
takes = index.get("takes") if isinstance(index.get("takes"), list) else []
|
||||
contract = _make_take_audio_contract(takes)
|
||||
return {
|
||||
"schema": "iamccs.multigeneration.concat_plan",
|
||||
"schema_version": 1,
|
||||
@@ -441,9 +493,11 @@ def _make_concat_plan(index: Dict[str, Any], source_bus: str) -> Dict[str, Any]:
|
||||
"source_bus": str(source_bus),
|
||||
"final_audio_policy": "restore_original_master_or_selected_bus_after_video_concat",
|
||||
"video_concat_policy": "hard_cut_in_take_order",
|
||||
"take_audio_contract": contract,
|
||||
"takes": [{
|
||||
"take_index": _safe_int(take.get("take_index", idx + 1), idx + 1),
|
||||
"timeline_id": str(take.get("timeline_id", f"T{idx + 1:02d}")),
|
||||
"audio_lane": contract[idx]["audio_lane"] if idx < len(contract) else _take_audio_lane_name(idx + 1),
|
||||
"global_start_frames": _safe_int(take.get("global_start_frames", 0), 0),
|
||||
"duration_frames": _safe_int(take.get("duration_frames", 0), 0),
|
||||
"expected_video_slot": f"video_take_{idx + 1:02d}",
|
||||
@@ -584,9 +638,18 @@ class IAMCCS_MultiTimelineBridge:
|
||||
takes.append(take)
|
||||
|
||||
active_index = max(1, min(len(takes), _safe_int(active_take, 1))) - 1
|
||||
for idx, take in enumerate(takes):
|
||||
take_index = max(1, _safe_int(take.get("take_index", idx + 1), idx + 1))
|
||||
take["take_index"] = take_index
|
||||
take["timeline_id"] = str(take.get("timeline_id") or _take_timeline_id(take_index))
|
||||
take["audio_lane"] = _take_audio_lane_name(take_index)
|
||||
take["audio_track_index"] = take_index - 1
|
||||
take["timeline_audio_contract"] = f"{take['timeline_id']}->{take['audio_lane']}"
|
||||
|
||||
take_audio_contract = _make_take_audio_contract(takes)
|
||||
generation_index = {
|
||||
"schema": "iamccs.multigeneration.index",
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"source": "IAMCCS_MultiTimelineBridge",
|
||||
"frame_rate": float(fps),
|
||||
"chunk_template": str(chunk_template),
|
||||
@@ -598,11 +661,15 @@ class IAMCCS_MultiTimelineBridge:
|
||||
"source_duration_seconds": source_duration / fps,
|
||||
"take_count": int(len(takes)),
|
||||
"active_take": int(active_index + 1),
|
||||
"active_timeline_id": _take_timeline_id(active_index + 1),
|
||||
"active_audio_lane": _take_audio_lane_name(active_index + 1),
|
||||
"take_track_layout": str(take_track_layout),
|
||||
"takes": takes,
|
||||
"take_audio_contract": take_audio_contract,
|
||||
"bus_generation_index": manifest.get("generation_index") if isinstance(manifest.get("generation_index"), dict) else {},
|
||||
"truth": "Audio remains BusOut/AudioBoard custom-audio metadata. Each take receives a local audio window for sequential video-driven generation, then video takes are hard-concatenated.",
|
||||
"truth": "T1=A1, T2=A2, T3=A3. Audio remains BusOut/AudioBoard custom-audio metadata. Each take receives a local audio window for sequential video-driven generation, then video takes are hard-concatenated.",
|
||||
}
|
||||
sequence_plan = _make_sequence_plan(generation_index)
|
||||
concat_plan = _make_concat_plan(generation_index, str(source_bus))
|
||||
|
||||
out_linx = _clone_linx(cine_linx)
|
||||
@@ -610,6 +677,9 @@ class IAMCCS_MultiTimelineBridge:
|
||||
outputs = _outputs(out_linx)
|
||||
resources["cine_multigeneration_concat_plan"] = concat_plan
|
||||
resources["cine_multigeneration_concat_plan_json"] = _json_dump(concat_plan)
|
||||
resources["cine_multigeneration_sequence_plan"] = sequence_plan
|
||||
resources["cine_multigeneration_sequence_plan_json"] = _json_dump(sequence_plan)
|
||||
resources["cine_multigeneration_take_audio_contract"] = take_audio_contract
|
||||
resources["cine_multigeneration_bus_manifest"] = manifest
|
||||
_apply_active_take(out_linx, generation_index, takes[active_index], str(take_track_layout))
|
||||
outputs["concat_plan_json"] = _json_dump(concat_plan)
|
||||
@@ -632,12 +702,145 @@ class IAMCCS_MultiTimelineBridge:
|
||||
"source_segments": len(source_segments),
|
||||
"active_segments": len(takes[active_index].get("audioSegments", [])),
|
||||
"prechunked": bool(takes[active_index].get("prechunked", False)),
|
||||
"active_timeline_id": _take_timeline_id(active_index + 1),
|
||||
"active_audio_lane": _take_audio_lane_name(active_index + 1),
|
||||
"contract": [item.get("mapping") for item in take_audio_contract],
|
||||
"concat_policy": concat_plan["video_concat_policy"],
|
||||
"sequence_steps": len(sequence_plan.get("steps", [])),
|
||||
})
|
||||
outputs["report"] = report
|
||||
return out_linx, _json_dump(generation_index), _json_dump(takes[active_index]), _json_dump(concat_plan), report
|
||||
|
||||
|
||||
|
||||
class IAMCCS_MultiTimelineSequentialPicker:
|
||||
"""Expose T1-A1..T5-A5 as parallel cine_linx outputs for one-queue staged generation."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"generation_index_json": ("STRING", {"default": "", "multiline": True}),
|
||||
"take_track_layout": (["collapse_to_lane_1", "preserve_bus_tracks"], {"default": "collapse_to_lane_1"}),
|
||||
"enabled_takes": ("INT", {"default": 3, "min": 1, "max": 5, "step": 1}),
|
||||
},
|
||||
"optional": {
|
||||
"cine_linx": (SUPERNODE_LINX_TYPE,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (
|
||||
SUPERNODE_LINX_TYPE,
|
||||
SUPERNODE_LINX_TYPE,
|
||||
SUPERNODE_LINX_TYPE,
|
||||
SUPERNODE_LINX_TYPE,
|
||||
SUPERNODE_LINX_TYPE,
|
||||
"STRING",
|
||||
"STRING",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"cine_linx_T1_A1",
|
||||
"cine_linx_T2_A2",
|
||||
"cine_linx_T3_A3",
|
||||
"cine_linx_T4_A4",
|
||||
"cine_linx_T5_A5",
|
||||
"sequence_plan_json",
|
||||
"report",
|
||||
)
|
||||
FUNCTION = "pick_sequence"
|
||||
CATEGORY = "IAMCCS/Cine/Multigeneration"
|
||||
|
||||
def pick_sequence(self, generation_index_json, take_track_layout, enabled_takes, cine_linx=None):
|
||||
generation_index = _safe_json_loads(generation_index_json, {})
|
||||
if not isinstance(generation_index, dict):
|
||||
generation_index = {}
|
||||
takes = generation_index.get("takes") if isinstance(generation_index.get("takes"), list) else []
|
||||
if not takes:
|
||||
generation_index.setdefault("frame_rate", 24.0)
|
||||
takes = []
|
||||
for index in range(max(1, min(5, _safe_int(enabled_takes, 3)))):
|
||||
take = {
|
||||
"schema": "iamccs.multigeneration.take",
|
||||
"schema_version": 1,
|
||||
"take_index": index + 1,
|
||||
"timeline_id": _take_timeline_id(index + 1),
|
||||
"audio_lane": _take_audio_lane_name(index + 1),
|
||||
"duration_frames": 1,
|
||||
"audioSegments": [],
|
||||
}
|
||||
takes.append(take)
|
||||
generation_index["takes"] = takes
|
||||
max_enabled = max(1, min(5, _safe_int(enabled_takes, 3)))
|
||||
outputs: List[Dict[str, Any]] = []
|
||||
steps: List[Dict[str, Any]] = []
|
||||
for index in range(5):
|
||||
source_take = copy.deepcopy(takes[index]) if index < len(takes) else {
|
||||
"schema": "iamccs.multigeneration.take",
|
||||
"schema_version": 1,
|
||||
"take_index": index + 1,
|
||||
"timeline_id": _take_timeline_id(index + 1),
|
||||
"audio_lane": _take_audio_lane_name(index + 1),
|
||||
"duration_frames": 1,
|
||||
"audioSegments": [],
|
||||
"disabled": True,
|
||||
}
|
||||
take_index = index + 1
|
||||
source_take["take_index"] = take_index
|
||||
source_take["timeline_id"] = str(source_take.get("timeline_id") or _take_timeline_id(take_index))
|
||||
source_take["audio_lane"] = _take_audio_lane_name(take_index)
|
||||
source_take["audio_track_index"] = take_index - 1
|
||||
source_take["sequence_enabled"] = take_index <= max_enabled and index < len(takes)
|
||||
out_linx = _clone_linx(cine_linx)
|
||||
local_index = copy.deepcopy(generation_index)
|
||||
local_index["active_take"] = take_index
|
||||
local_index["active_timeline_id"] = source_take["timeline_id"]
|
||||
local_index["active_audio_lane"] = source_take["audio_lane"]
|
||||
_apply_active_take(out_linx, local_index, source_take, str(take_track_layout))
|
||||
out_linx["type"] = SUPERNODE_LINX_TYPE
|
||||
out_linx["mode"] = "iamccs_multigeneration_sequence_take"
|
||||
out_linx.setdefault("chain", []).append({
|
||||
"role": "multigeneration_sequence_picker",
|
||||
"name": "IAMCCS_MultiTimelineSequentialPicker",
|
||||
"take_index": take_index,
|
||||
"timeline_id": source_take["timeline_id"],
|
||||
"audio_lane": source_take["audio_lane"],
|
||||
"enabled": bool(source_take["sequence_enabled"]),
|
||||
})
|
||||
_outputs(out_linx)["report"] = _json_dump({
|
||||
"node": "IAMCCS_MultiTimelineSequentialPicker",
|
||||
"take_index": take_index,
|
||||
"timeline_id": source_take["timeline_id"],
|
||||
"audio_lane": source_take["audio_lane"],
|
||||
"enabled": bool(source_take["sequence_enabled"]),
|
||||
})
|
||||
_refresh_linx_index(out_linx)
|
||||
outputs.append(out_linx)
|
||||
steps.append({
|
||||
"step_index": take_index,
|
||||
"take_index": take_index,
|
||||
"timeline_id": source_take["timeline_id"],
|
||||
"audio_lane": source_take["audio_lane"],
|
||||
"enabled": bool(source_take["sequence_enabled"]),
|
||||
"output": f"cine_linx_T{take_index}_A{take_index}",
|
||||
"expected_video_slot": f"video_take_{take_index:02d}",
|
||||
})
|
||||
sequence_plan = {
|
||||
"schema": "iamccs.multigeneration.sequence_plan",
|
||||
"schema_version": 2,
|
||||
"source": "IAMCCS_MultiTimelineSequentialPicker",
|
||||
"enabled_takes": max_enabled,
|
||||
"steps": steps,
|
||||
"truth": "Connect each enabled cine_linx_Tn_An output to its own generation branch, then connect generated videos to IAMCCS Shotboard Video Editor in the same order.",
|
||||
}
|
||||
report = _json_dump({
|
||||
"node": "IAMCCS_MultiTimelineSequentialPicker",
|
||||
"enabled_takes": max_enabled,
|
||||
"outputs": [f"T{idx + 1}/A{idx + 1}" for idx in range(5)],
|
||||
"queue_mode": "parallel_branches_in_one_comfy_queue",
|
||||
})
|
||||
return (*outputs, _json_dump(sequence_plan), report)
|
||||
|
||||
|
||||
class IAMCCS_MultiTimelineTakePicker:
|
||||
"""Pick one take from a MultiTimelineBridge index and expose it as active cine_linx audio."""
|
||||
|
||||
@@ -760,6 +963,409 @@ def _concat_audio(audio_items: List[Tuple[Any, int, float]]) -> Dict[str, Any] |
|
||||
return {"waveform": torch.cat(pieces, dim=2), "sample_rate": target_rate}
|
||||
|
||||
|
||||
|
||||
def _parse_take_order(value: Any, max_count: int) -> List[int]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return list(range(1, max_count + 1))
|
||||
out: List[int] = []
|
||||
for part in text.replace(";", ",").split(","):
|
||||
number = _safe_int(part.strip(), 0)
|
||||
if 1 <= number <= max_count and number not in out:
|
||||
out.append(number)
|
||||
return out or list(range(1, max_count + 1))
|
||||
|
||||
|
||||
def _clip_edit_for_take(edits: Any, take_index: int) -> Dict[str, Any]:
|
||||
if not isinstance(edits, dict):
|
||||
return {}
|
||||
clips = edits.get("clips") if isinstance(edits.get("clips"), dict) else edits
|
||||
for key in (str(take_index), f"T{take_index:02d}", f"video_take_{take_index:02d}"):
|
||||
item = clips.get(key) if isinstance(clips, dict) else None
|
||||
if isinstance(item, dict):
|
||||
return item
|
||||
return {}
|
||||
|
||||
|
||||
def _trim_component(comp: Any, fps: float, trim_in_seconds: float, trim_out_seconds: float):
|
||||
frame_count = int(comp.images.shape[0])
|
||||
start = max(0, min(frame_count - 1, int(round(max(0.0, trim_in_seconds) * max(1.0, fps)))))
|
||||
if trim_out_seconds > 0:
|
||||
end = max(start + 1, min(frame_count, int(round(trim_out_seconds * max(1.0, fps)))))
|
||||
else:
|
||||
end = frame_count
|
||||
images = comp.images[start:end]
|
||||
audio = comp.audio
|
||||
if isinstance(audio, dict) and audio.get("waveform") is not None:
|
||||
sample_rate = int(audio.get("sample_rate") or 44100)
|
||||
sample_start = max(0, int(round((start / max(1.0, fps)) * sample_rate)))
|
||||
sample_end = max(sample_start + 1, int(round((end / max(1.0, fps)) * sample_rate)))
|
||||
waveform = audio["waveform"][..., sample_start:sample_end]
|
||||
audio = {"waveform": waveform, "sample_rate": sample_rate}
|
||||
return images, audio, start, end
|
||||
|
||||
|
||||
def _audio_waveform(audio: Any):
|
||||
if not isinstance(audio, dict) or audio.get("waveform") is None:
|
||||
return None, 44100
|
||||
waveform = audio.get("waveform")
|
||||
sample_rate = int(audio.get("sample_rate") or 44100)
|
||||
if waveform is None:
|
||||
return None, sample_rate
|
||||
if waveform.dim() == 1:
|
||||
waveform = waveform.reshape(1, 1, -1)
|
||||
elif waveform.dim() == 2:
|
||||
waveform = waveform.unsqueeze(0)
|
||||
return waveform, sample_rate
|
||||
|
||||
|
||||
def _audio_duration_seconds(audio: Any) -> float:
|
||||
waveform, sample_rate = _audio_waveform(audio)
|
||||
if waveform is None:
|
||||
return 0.0
|
||||
return float(waveform.shape[-1]) / max(1.0, float(sample_rate))
|
||||
|
||||
|
||||
def _mix_editor_audio_tracks(audio_tracks: List[Any]) -> Any:
|
||||
prepared = []
|
||||
target_rate = 0
|
||||
target_channels = 1
|
||||
target_device = None
|
||||
target_dtype = None
|
||||
for audio in audio_tracks:
|
||||
waveform, sample_rate = _audio_waveform(audio)
|
||||
if waveform is None:
|
||||
continue
|
||||
if not target_rate:
|
||||
target_rate = sample_rate
|
||||
target_device = waveform.device
|
||||
target_dtype = waveform.dtype
|
||||
if sample_rate != target_rate:
|
||||
waveform = torchaudio.functional.resample(waveform, sample_rate, target_rate)
|
||||
if target_device is not None and waveform.device != target_device:
|
||||
waveform = waveform.to(target_device)
|
||||
if target_dtype is not None and waveform.dtype != target_dtype:
|
||||
waveform = waveform.to(target_dtype)
|
||||
target_channels = max(target_channels, int(waveform.shape[-2]))
|
||||
prepared.append(waveform)
|
||||
if not prepared:
|
||||
return None
|
||||
max_samples = max(int(w.shape[-1]) for w in prepared)
|
||||
padded = []
|
||||
for waveform in prepared:
|
||||
if int(waveform.shape[-2]) < target_channels:
|
||||
waveform = waveform.repeat_interleave(target_channels, dim=-2)[..., :target_channels, :]
|
||||
if int(waveform.shape[-1]) < max_samples:
|
||||
waveform = torch.nn.functional.pad(waveform, (0, max_samples - int(waveform.shape[-1])))
|
||||
padded.append(waveform)
|
||||
mixed = torch.stack(padded, dim=0).sum(dim=0).clamp(-1.0, 1.0)
|
||||
return {"waveform": mixed, "sample_rate": int(target_rate or 44100)}
|
||||
|
||||
|
||||
class IAMCCS_ShotboardVideoEditor:
|
||||
"""Editorial hard-cut assembler. VIDEO/AUDIO inputs are gathered by CineInfo3 through cine_linx."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"cine_linx": (SUPERNODE_LINX_TYPE,),
|
||||
"editor_mode": (["assemble_sequence", "preview_selected_take"], {"default": "assemble_sequence"}),
|
||||
"selected_take": ("INT", {"default": 1, "min": 1, "max": 8, "step": 1}),
|
||||
"take_order": ("STRING", {"default": "1,2,3", "multiline": False}),
|
||||
"audio_policy": ([
|
||||
"concat_clip_audio",
|
||||
"use_master_audio",
|
||||
"first_selected_audio",
|
||||
"mix_editor_audio_tracks",
|
||||
"concat_editor_audio_tracks",
|
||||
"silent",
|
||||
], {"default": "concat_clip_audio"}),
|
||||
"fps_mode": (["from_first_video", "override_fps"], {"default": "from_first_video"}),
|
||||
"override_fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}),
|
||||
"global_trim_in_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.01}),
|
||||
"global_trim_out_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.01}),
|
||||
},
|
||||
"optional": {
|
||||
"master_audio": ("AUDIO",),
|
||||
"concat_plan_json": ("STRING", {"default": "", "multiline": True}),
|
||||
"clip_edits_json": ("STRING", {"default": "", "multiline": True}),
|
||||
"editor_manifest_json": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO", "IMAGE", "STRING", "STRING", SUPERNODE_LINX_TYPE)
|
||||
RETURN_NAMES = ("video", "frames", "editor_plan_json", "report", "cine_linx")
|
||||
FUNCTION = "edit"
|
||||
CATEGORY = "IAMCCS/Cine/Multigeneration"
|
||||
|
||||
def edit(
|
||||
self,
|
||||
cine_linx,
|
||||
editor_mode,
|
||||
selected_take,
|
||||
take_order,
|
||||
audio_policy,
|
||||
fps_mode,
|
||||
override_fps,
|
||||
global_trim_in_seconds,
|
||||
global_trim_out_seconds,
|
||||
master_audio=None,
|
||||
concat_plan_json="",
|
||||
clip_edits_json="",
|
||||
editor_manifest_json="",
|
||||
):
|
||||
resources = _resources(cine_linx if isinstance(cine_linx, dict) else {})
|
||||
video_inputs = resources.get("cine_info3_video_inputs")
|
||||
audio_inputs = resources.get("cine_info3_audio_inputs")
|
||||
if not isinstance(video_inputs, list):
|
||||
video_inputs = []
|
||||
if not isinstance(audio_inputs, list):
|
||||
audio_inputs = []
|
||||
videos = [item.get("video") for item in video_inputs if isinstance(item, dict) and item.get("video") is not None]
|
||||
if not videos:
|
||||
raise ValueError("IAMCCS Shotboard Video Editor: connect rendered take videos to IAMCCS_CineInfo3, then connect CineInfo3 cine_linx here.")
|
||||
components = [_video_components(video) for video in videos]
|
||||
if str(editor_mode) == "preview_selected_take":
|
||||
order = [max(1, min(len(components), _safe_int(selected_take, 1)))]
|
||||
else:
|
||||
order = _parse_take_order(take_order, len(components))
|
||||
|
||||
first = components[order[0] - 1]
|
||||
first_shape = tuple(first.images.shape[1:3])
|
||||
first_device = first.images.device
|
||||
fps = float(override_fps) if str(fps_mode) == "override_fps" else float(first.frame_rate)
|
||||
edits = _safe_json_loads(clip_edits_json, {})
|
||||
frame_batches = []
|
||||
audio_items = []
|
||||
clip_reports = []
|
||||
timeline_cursor_frames = 0
|
||||
for take_index in order:
|
||||
comp = components[take_index - 1]
|
||||
if tuple(comp.images.shape[1:3]) != first_shape:
|
||||
raise ValueError(
|
||||
"IAMCCS Shotboard Video Editor: all clips must share height and width. "
|
||||
f"video_1={first_shape}, video_{take_index}={tuple(comp.images.shape[1:3])}"
|
||||
)
|
||||
clip_edit = _clip_edit_for_take(edits, take_index)
|
||||
trim_in = _safe_float(clip_edit.get("trim_in_seconds", global_trim_in_seconds), float(global_trim_in_seconds))
|
||||
trim_out = _safe_float(clip_edit.get("trim_out_seconds", global_trim_out_seconds), float(global_trim_out_seconds))
|
||||
images, audio, start, end = _trim_component(comp, fps, trim_in, trim_out)
|
||||
if images.device != first_device:
|
||||
images = images.to(first_device)
|
||||
frame_batches.append(images)
|
||||
audio_items.append((audio, int(images.shape[0]), fps))
|
||||
used_frames = int(images.shape[0])
|
||||
clip_reports.append({
|
||||
"take_index": take_index,
|
||||
"timeline_id": _take_timeline_id(take_index),
|
||||
"audio_lane": _take_audio_lane_name(take_index),
|
||||
"timeline_start_frame": int(timeline_cursor_frames),
|
||||
"timeline_end_frame": int(timeline_cursor_frames + used_frames),
|
||||
"source_frames": int(comp.images.shape[0]),
|
||||
"used_start_frame": int(start),
|
||||
"used_end_frame": int(end),
|
||||
"used_frames": used_frames,
|
||||
"duration_seconds": used_frames / max(1.0, fps),
|
||||
"has_clip_audio": audio is not None,
|
||||
})
|
||||
timeline_cursor_frames += used_frames
|
||||
|
||||
frames = torch.cat(frame_batches, dim=0)
|
||||
frame_rate = Fraction(round(max(1.0, fps) * 1000), 1000)
|
||||
editor_audio_tracks = [item.get("audio") for item in audio_inputs if isinstance(item, dict) and item.get("audio") is not None]
|
||||
audio = None
|
||||
if str(audio_policy) == "use_master_audio":
|
||||
audio = master_audio
|
||||
elif str(audio_policy) == "first_selected_audio":
|
||||
audio = audio_items[0][0]
|
||||
elif str(audio_policy) == "concat_clip_audio":
|
||||
audio = _concat_audio(audio_items)
|
||||
elif str(audio_policy) == "mix_editor_audio_tracks":
|
||||
audio = _mix_editor_audio_tracks(editor_audio_tracks)
|
||||
elif str(audio_policy) == "concat_editor_audio_tracks":
|
||||
audio = _concat_audio([(track, int(round(_audio_duration_seconds(track) * fps)), fps) for track in editor_audio_tracks])
|
||||
|
||||
video = InputImpl.VideoFromComponents(Types.VideoComponents(images=frames, audio=audio, frame_rate=frame_rate))
|
||||
concat_plan = _safe_json_loads(concat_plan_json, {})
|
||||
if not concat_plan and isinstance(resources.get("cine_info3_concat_plan"), dict):
|
||||
concat_plan = resources.get("cine_info3_concat_plan")
|
||||
editor_manifest = _safe_json_loads(editor_manifest_json, {})
|
||||
if not editor_manifest and isinstance(resources.get("cine_info3_video_manifest"), list):
|
||||
editor_manifest = {
|
||||
"video_manifest": resources.get("cine_info3_video_manifest"),
|
||||
"audio_manifest": resources.get("cine_info3_audio_manifest") if isinstance(resources.get("cine_info3_audio_manifest"), list) else [],
|
||||
}
|
||||
out_linx = _clone_linx(cine_linx, "iamccs_video_editor")
|
||||
resources = _resources(out_linx)
|
||||
editor_plan = {
|
||||
"schema": "iamccs.shotboard.video_editor_plan",
|
||||
"schema_version": 3,
|
||||
"source": "IAMCCS_ShotboardVideoEditor",
|
||||
"editor_mode": str(editor_mode),
|
||||
"manual_selected_take": _safe_int(selected_take, 1),
|
||||
"take_order": order,
|
||||
"clip_reports": clip_reports,
|
||||
"concat_plan_takes": len(concat_plan.get("takes", [])) if isinstance(concat_plan, dict) else 0,
|
||||
"external_editor_manifest": editor_manifest if isinstance(editor_manifest, dict) else {},
|
||||
"audio_policy": str(audio_policy),
|
||||
"editor_audio_tracks": len(editor_audio_tracks),
|
||||
"total_frames": int(frames.shape[0]),
|
||||
"fps": float(frame_rate),
|
||||
"duration_seconds": int(frames.shape[0]) / max(1.0, float(frame_rate)),
|
||||
"truth": "CineInfo3 owns all VIDEO/AUDIO inputs; VideoEditor consumes cine_linx only and assembles selected rendered videos.",
|
||||
}
|
||||
resources["cine_video_editor_plan"] = editor_plan
|
||||
resources["cine_video_editor_plan_json"] = _json_dump(editor_plan)
|
||||
resources["cine_video_editor_output"] = {
|
||||
"frames": int(frames.shape[0]),
|
||||
"fps": float(frame_rate),
|
||||
"duration_seconds": int(frames.shape[0]) / max(1.0, float(frame_rate)),
|
||||
"has_audio": audio is not None,
|
||||
"audio_policy": str(audio_policy),
|
||||
}
|
||||
out_linx.setdefault("chain", []).append({
|
||||
"role": "shotboard_video_editor",
|
||||
"name": "IAMCCS_ShotboardVideoEditor",
|
||||
"mode": str(editor_mode),
|
||||
})
|
||||
_refresh_linx_index(out_linx)
|
||||
report = _json_dump({
|
||||
"node": "IAMCCS_ShotboardVideoEditor",
|
||||
"editor_mode": str(editor_mode),
|
||||
"manual_selected_take": _safe_int(selected_take, 1),
|
||||
"take_order": order,
|
||||
"clip_count": len(order),
|
||||
"total_frames": int(frames.shape[0]),
|
||||
"duration_seconds": int(frames.shape[0]) / max(1.0, float(frame_rate)),
|
||||
"fps": float(frame_rate),
|
||||
"audio_policy": str(audio_policy),
|
||||
"editor_audio_tracks": len(editor_audio_tracks),
|
||||
"has_audio": audio is not None,
|
||||
"video_source": "cine_info3_cine_linx",
|
||||
})
|
||||
return video, frames, _json_dump(editor_plan), report, out_linx
|
||||
|
||||
|
||||
class IAMCCS_CineInfo3:
|
||||
"""Collect video/audio edit inputs into cine_linx metadata and transport objects for the Shotboard Video Editor."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"mode": (["collect_video_editor_inputs", "inspect", "publish_editor_manifest"], {"default": "collect_video_editor_inputs"}),
|
||||
"frame_rate": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}),
|
||||
},
|
||||
"optional": {
|
||||
"cine_linx": (SUPERNODE_LINX_TYPE,),
|
||||
"video_1": ("VIDEO",),
|
||||
"video_2": ("VIDEO",),
|
||||
"video_3": ("VIDEO",),
|
||||
"video_4": ("VIDEO",),
|
||||
"video_5": ("VIDEO",),
|
||||
"video_6": ("VIDEO",),
|
||||
"video_7": ("VIDEO",),
|
||||
"video_8": ("VIDEO",),
|
||||
"audio_1": ("AUDIO",),
|
||||
"audio_2": ("AUDIO",),
|
||||
"audio_3": ("AUDIO",),
|
||||
"audio_4": ("AUDIO",),
|
||||
"audio_5": ("AUDIO",),
|
||||
"audio_6": ("AUDIO",),
|
||||
"audio_7": ("AUDIO",),
|
||||
"audio_8": ("AUDIO",),
|
||||
"concat_plan_json": ("STRING", {"default": "", "multiline": True}),
|
||||
"editor_notes": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = ("cine_linx", "video_manifest_json", "audio_manifest_json", "report")
|
||||
FUNCTION = "collect"
|
||||
CATEGORY = "IAMCCS/Cine/Multigeneration"
|
||||
|
||||
def collect(
|
||||
self,
|
||||
mode,
|
||||
frame_rate,
|
||||
cine_linx=None,
|
||||
video_1=None,
|
||||
video_2=None,
|
||||
video_3=None,
|
||||
video_4=None,
|
||||
video_5=None,
|
||||
video_6=None,
|
||||
video_7=None,
|
||||
video_8=None,
|
||||
audio_1=None,
|
||||
audio_2=None,
|
||||
audio_3=None,
|
||||
audio_4=None,
|
||||
audio_5=None,
|
||||
audio_6=None,
|
||||
audio_7=None,
|
||||
audio_8=None,
|
||||
concat_plan_json="",
|
||||
editor_notes="",
|
||||
):
|
||||
out_linx = _clone_linx(cine_linx, "iamccs_cine_info3")
|
||||
video_manifest = []
|
||||
video_inputs = []
|
||||
for index, video in enumerate((video_1, video_2, video_3, video_4, video_5, video_6, video_7, video_8), start=1):
|
||||
if video is None:
|
||||
continue
|
||||
comp = _video_components(video)
|
||||
fps = float(comp.frame_rate or frame_rate)
|
||||
frames = int(comp.images.shape[0])
|
||||
video_inputs.append({"slot": index, "timeline_id": _take_timeline_id(index), "audio_lane": _take_audio_lane_name(index), "video": video})
|
||||
video_manifest.append({
|
||||
"slot": index,
|
||||
"timeline_id": _take_timeline_id(index),
|
||||
"audio_lane": _take_audio_lane_name(index),
|
||||
"frames": frames,
|
||||
"fps": fps,
|
||||
"duration_seconds": frames / max(1.0, fps),
|
||||
"height": int(comp.images.shape[1]),
|
||||
"width": int(comp.images.shape[2]),
|
||||
"has_embedded_audio": comp.audio is not None,
|
||||
})
|
||||
audio_manifest = []
|
||||
audio_inputs = []
|
||||
for index, audio in enumerate((audio_1, audio_2, audio_3, audio_4, audio_5, audio_6, audio_7, audio_8), start=1):
|
||||
waveform, sample_rate = _audio_waveform(audio)
|
||||
if waveform is None:
|
||||
continue
|
||||
audio_inputs.append({"slot": index, "audio_lane": _take_audio_lane_name(index), "audio": audio})
|
||||
audio_manifest.append({
|
||||
"slot": index,
|
||||
"audio_lane": _take_audio_lane_name(index),
|
||||
"sample_rate": int(sample_rate),
|
||||
"samples": int(waveform.shape[-1]),
|
||||
"channels": int(waveform.shape[-2]),
|
||||
"duration_seconds": int(waveform.shape[-1]) / max(1.0, float(sample_rate)),
|
||||
})
|
||||
concat_plan = _safe_json_loads(concat_plan_json, {})
|
||||
resources = _resources(out_linx)
|
||||
resources["cine_info3_video_manifest"] = video_manifest
|
||||
resources["cine_info3_audio_manifest"] = audio_manifest
|
||||
resources["cine_info3_video_inputs"] = video_inputs
|
||||
resources["cine_info3_audio_inputs"] = audio_inputs
|
||||
resources["cine_info3_concat_plan"] = concat_plan if isinstance(concat_plan, dict) else {}
|
||||
resources["cine_info3_editor_notes"] = str(editor_notes or "")
|
||||
resources["cine_info3_mode"] = str(mode)
|
||||
out_linx.setdefault("chain", []).append({"role": "cine_info3", "name": "IAMCCS_CineInfo3", "mode": str(mode)})
|
||||
_refresh_linx_index(out_linx)
|
||||
report = {
|
||||
"node": "IAMCCS_CineInfo3",
|
||||
"mode": str(mode),
|
||||
"videos": len(video_manifest),
|
||||
"audios": len(audio_manifest),
|
||||
"truth": "CineInfo3 owns rendered take VIDEO/AUDIO inputs and transports them through cine_linx for the Video Editor.",
|
||||
}
|
||||
return out_linx, _json_dump(video_manifest), _json_dump(audio_manifest), _json_dump(report)
|
||||
|
||||
|
||||
class IAMCCS_VideoHardConcat:
|
||||
"""Hard-concatenate generated take videos into a final VIDEO object."""
|
||||
|
||||
@@ -860,6 +1466,8 @@ class IAMCCS_VideoHardConcat:
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"IAMCCS_MultiTimelineBridge": IAMCCS_MultiTimelineBridge,
|
||||
"IAMCCS_MultiTimelineTakePicker": IAMCCS_MultiTimelineTakePicker,
|
||||
"IAMCCS_ShotboardVideoEditor": IAMCCS_ShotboardVideoEditor,
|
||||
"IAMCCS_CineInfo3": IAMCCS_CineInfo3,
|
||||
"IAMCCS_VideoHardConcat": IAMCCS_VideoHardConcat,
|
||||
}
|
||||
|
||||
@@ -867,5 +1475,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"IAMCCS_MultiTimelineBridge": "IAMCCS MultiTimeline Bridge",
|
||||
"IAMCCS_MultiTimelineTakePicker": "IAMCCS MultiTimeline Take Picker",
|
||||
"IAMCCS_ShotboardVideoEditor": "IAMCCS Shotboard Video Editor",
|
||||
"IAMCCS_CineInfo3": "IAMCCS CineInfo3",
|
||||
"IAMCCS_VideoHardConcat": "IAMCCS Video Hard Concat",
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+21
-7
@@ -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)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
@@ -11,6 +12,11 @@ from PIL import Image, ImageDraw, ImageFilter, ImageOps
|
||||
import folder_paths
|
||||
import nodes as comfy_nodes
|
||||
|
||||
_GEMMA_ASSIST_DEFAULT_MODEL = "text_encoders\\gemma4_e4b_it_fp8_scaled.safetensors"
|
||||
_GEMMA_ASSIST_CLIP_CACHE: Dict[Tuple[str, str], Any] = {}
|
||||
_GEMMA_ASSIST_RUNNING = False
|
||||
_GEMMA_ASSIST_ABORT_REQUESTED = False
|
||||
|
||||
# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
|
||||
# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
|
||||
# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
|
||||
@@ -110,6 +116,18 @@ def _default_design() -> Dict[str, Any]:
|
||||
"brief": "",
|
||||
"instruction": "Enhance the current Ideogram JSON without changing layout, bbox coordinates, visible text, or panel count."
|
||||
},
|
||||
"gemma_assistant": {
|
||||
"enabled": false,
|
||||
"provider": "local_gemma",
|
||||
"mode": "full_json_enhance",
|
||||
"speed": "fast",
|
||||
"model": "text_encoders\\gemma4_e4b_it_fp8_scaled.safetensors",
|
||||
"selected_id": "",
|
||||
"target_field": "",
|
||||
"current_text": "",
|
||||
"brief": "",
|
||||
"request_ready": false
|
||||
},
|
||||
"mask_paint": {
|
||||
"brush_size": 48,
|
||||
"strokes": []
|
||||
@@ -831,7 +849,7 @@ def _design_with_json_override(design: Dict[str, Any]) -> Tuple[Dict[str, Any],
|
||||
if payload is None:
|
||||
return design, None
|
||||
converted = _from_ideogram_prompt(payload)
|
||||
for key in ("canvas", "i2i", "reference_mode", "workflow_mode", "preset_key", "json_export_mode", "brief_to_json", "json_override", "mask_paint"):
|
||||
for key in ("canvas", "i2i", "reference_mode", "workflow_mode", "preset_key", "json_export_mode", "brief_to_json", "gemma_assistant", "json_override", "mask_paint"):
|
||||
if key in design:
|
||||
converted[key] = copy.deepcopy(design[key])
|
||||
return converted, payload
|
||||
@@ -842,7 +860,7 @@ def _design_from_runtime_source(source_data: Any, fallback_data: Any) -> Dict[st
|
||||
design = _normalize_design(source_data)
|
||||
if _is_ideogram_prompt_json(parsed_source):
|
||||
fallback = _normalize_design(fallback_data)
|
||||
for key in ("canvas", "i2i", "reference_mode", "workflow_mode", "preset_key", "json_export_mode", "brief_to_json", "mask_paint"):
|
||||
for key in ("canvas", "i2i", "reference_mode", "workflow_mode", "preset_key", "json_export_mode", "brief_to_json", "gemma_assistant", "mask_paint"):
|
||||
if key not in parsed_source and key in fallback:
|
||||
design[key] = copy.deepcopy(fallback[key])
|
||||
return design
|
||||
@@ -883,6 +901,7 @@ def _normalize_design(raw: Any) -> Dict[str, Any]:
|
||||
if merged["json_export_mode"] not in {"json_perfect", "standard"}:
|
||||
merged["json_export_mode"] = "json_perfect"
|
||||
merged["brief_to_json"] = _normalize_brief_to_json(data.get("brief_to_json") if isinstance(data.get("brief_to_json"), dict) else base.get("brief_to_json"))
|
||||
merged["gemma_assistant"] = _normalize_gemma_assistant(data.get("gemma_assistant") if isinstance(data.get("gemma_assistant"), dict) else base.get("gemma_assistant"))
|
||||
merged["mask_paint"] = _normalize_mask_paint(
|
||||
data.get("mask_paint") if isinstance(data.get("mask_paint"), dict) else base.get("mask_paint"),
|
||||
base.get("mask_paint"),
|
||||
@@ -1116,27 +1135,539 @@ def _normalize_brief_to_json(raw: Any) -> Dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _normalize_gemma_assistant(raw: Any) -> Dict[str, Any]:
|
||||
data = raw if isinstance(raw, dict) else {}
|
||||
mode = _clean_text(data.get("mode") or "full_json_enhance")
|
||||
if mode not in {"full_json_enhance", "selected_box_enhance", "brief_to_ideoboard", "prompt_critic", "field_enhance"}:
|
||||
mode = "full_json_enhance"
|
||||
speed = _clean_text(data.get("speed") or data.get("detail_mode") or "fast").lower()
|
||||
if speed not in {"fast", "detailed"}:
|
||||
speed = "fast"
|
||||
return {
|
||||
"enabled": bool(data.get("enabled", False)),
|
||||
"provider": "local_gemma",
|
||||
"mode": mode,
|
||||
"speed": speed,
|
||||
"model": _clean_text(data.get("model")) or _GEMMA_ASSIST_DEFAULT_MODEL,
|
||||
"selected_id": _clean_text(data.get("selected_id")),
|
||||
"target_field": _clean_text(data.get("target_field")),
|
||||
"current_text": _clean_text(data.get("current_text")),
|
||||
"brief": _clean_text(data.get("brief")),
|
||||
"request_ready": bool(data.get("request_ready", False)),
|
||||
}
|
||||
|
||||
|
||||
def _gemma_json_request_for_design(design: Dict[str, Any], prompt_json: str) -> str:
|
||||
brief = _normalize_brief_to_json(design.get("brief_to_json"))
|
||||
assistant = _normalize_gemma_assistant(design.get("gemma_assistant"))
|
||||
mode = assistant["mode"] if assistant.get("request_ready") else "full_json_enhance"
|
||||
selected_id = assistant.get("selected_id") or ""
|
||||
direction = assistant.get("brief") or brief["brief"] or brief["instruction"]
|
||||
selected_item = {}
|
||||
for item in design.get("items", []) if isinstance(design.get("items"), list) else []:
|
||||
if _clean_text(item.get("id")) == selected_id:
|
||||
selected_item = item
|
||||
break
|
||||
|
||||
contract = (
|
||||
"OUTPUT CONTRACT\n"
|
||||
"Return one valid JSON object only, no markdown.\n"
|
||||
)
|
||||
if mode == "prompt_critic":
|
||||
contract += (
|
||||
"Return {\"mode\":\"prompt_critic\",\"notes\":\"concise critique\",\"suggestions\":[\"specific improvement\", \"specific improvement\"]}.\n"
|
||||
"Do not rewrite the full prompt in critic mode.\n"
|
||||
)
|
||||
else:
|
||||
contract += (
|
||||
"Return {\"mode\":\"" + mode + "\",\"ideogram_prompt\":{...},\"notes\":\"short reason\"}.\n"
|
||||
"The ideogram_prompt must preserve the exact schema: high_level_description, style_description, compositional_deconstruction.\n"
|
||||
"Preserve every element count, type, visible text, and bbox coordinate exactly unless mode is brief_to_ideoboard and the user asks for a new board.\n"
|
||||
"For selected_box_enhance, improve only the selected element description inside the full returned ideogram_prompt.\n"
|
||||
)
|
||||
|
||||
return (
|
||||
"You are an Ideogram 4 JSON prompt enhancer. Output JSON only, no markdown, no commentary.\n\n"
|
||||
"You are an Ideogram 4 JSON prompt enhancer for IAMCCS FrameDesigner. Output JSON only.\n\n"
|
||||
"TASK\n"
|
||||
"Improve the supplied Ideogram 4 structured JSON for stronger cinematic visual quality, clearer subject descriptions, "
|
||||
"better material detail, lighting, camera language, and storyboard continuity.\n\n"
|
||||
"STRICT RULES\n"
|
||||
"- Preserve the exact JSON schema: high_level_description, style_description, compositional_deconstruction.\n"
|
||||
"- Preserve every element count, type, visible text, and bbox coordinate exactly.\n"
|
||||
"- Use positive concrete visual language.\n"
|
||||
"- Do not add negative prompts, banned lists, or lists of things to avoid.\n"
|
||||
"- Bbox order is [ymin, xmin, ymax, xmax] on a 0-1000 grid. Do not convert to x/y order.\n"
|
||||
"- Do not add negative prompts or lists of things to avoid. Use positive concrete visual language.\n"
|
||||
"- Do not add new characters, new panels, or extra objects unless the user brief explicitly asks for a replacement inside an existing bbox.\n"
|
||||
"- Keep image/reference panels described as references if present.\n"
|
||||
"- Keep color_palette arrays as hex colors when present.\n\n"
|
||||
f"USER BRIEF OR ENHANCEMENT DIRECTION\n{brief['brief'] or brief['instruction']}\n\n"
|
||||
"- Keep color_palette arrays as hex colors when present.\n"
|
||||
"- Keep the result compatible with IAMCCS_IdeoTranslate.\n\n"
|
||||
f"MODE\n{mode}\n\n"
|
||||
f"SELECTED ELEMENT ID\n{selected_id or 'none'}\n\n"
|
||||
"SELECTED ELEMENT JSON\n"
|
||||
f"{json.dumps(selected_item, ensure_ascii=False, indent=2)}\n\n"
|
||||
f"USER BRIEF OR ENHANCEMENT DIRECTION\n{direction}\n\n"
|
||||
f"{contract}\n"
|
||||
"CURRENT IDEOGRAM JSON\n"
|
||||
f"{prompt_json}\n"
|
||||
)
|
||||
|
||||
|
||||
def _gemma_assistant_system_prompt() -> str:
|
||||
return (
|
||||
"You are Gemma running as the IAMCCS FrameDesigner assistant for Ideogram 4 structured prompting. "
|
||||
"Return compact JSON only. No markdown. No commentary outside JSON. "
|
||||
"Use positive, specific visual language. Do not write negative prompts, banned lists, or 'do not' instructions. "
|
||||
"Preserve the Ideogram JSON structure when enhancing. Preserve bbox coordinates unless the user explicitly asks to build a new board. "
|
||||
"Bbox order is [ymin, xmin, ymax, xmax] on a 0-1000 layout grid. "
|
||||
"Prioritize cinematic clarity, subject identity, physical action, material detail, lighting, lens language, and readable composition. "
|
||||
"When the user gives a general natural-language brief, infer whether they want a single image, image refinement, or storyboard grid, then produce a complete FrameDesigner ideoboard JSON. "
|
||||
"When enhancing one field or one selected box, return only the improved text for that target plus brief notes. "
|
||||
"After the closing JSON brace, immediately end the answer with <end_of_turn>."
|
||||
)
|
||||
|
||||
|
||||
def _gemma_assistant_user_prompt(
|
||||
design: Dict[str, Any],
|
||||
mode: str,
|
||||
brief: str,
|
||||
selected_id: str,
|
||||
target_field: str = "",
|
||||
current_text: str = "",
|
||||
) -> str:
|
||||
normalized = _normalize_design(design)
|
||||
prompt_json = json.dumps(_to_ideogram_prompt(normalized), ensure_ascii=False, indent=2)
|
||||
selected_item = None
|
||||
for item in normalized.get("items", []):
|
||||
if _clean_text(item.get("id")) == selected_id:
|
||||
selected_item = item
|
||||
break
|
||||
selected_block = json.dumps(selected_item or {}, ensure_ascii=False, indent=2)
|
||||
field = _clean_text(target_field)
|
||||
text = _clean_text(current_text)
|
||||
if mode == "field_enhance":
|
||||
return (
|
||||
"ASSISTANT MODE\n"
|
||||
"field_enhance\n\n"
|
||||
"TARGET FIELD KEY\n"
|
||||
f"{field}\n\n"
|
||||
"CURRENT FIELD TEXT\n"
|
||||
f"{text}\n\n"
|
||||
"USER DIRECTION\n"
|
||||
f"{_clean_text(brief) or text or 'Improve this field for Ideogram 4.'}\n\n"
|
||||
"CURRENT SELECTED BOX\n"
|
||||
f"{selected_block}\n\n"
|
||||
"CURRENT IDEOGRAM PROMPT JSON FOR CONTEXT\n"
|
||||
f"{prompt_json}\n\n"
|
||||
"OUTPUT CONTRACT\n"
|
||||
"{"
|
||||
"\"mode\":\"field_enhance\","
|
||||
"\"field_key\":\"same target field key\","
|
||||
"\"selected_id\":\"selected box id if any\","
|
||||
"\"text\":\"improved replacement text only\","
|
||||
"\"notes\":\"short reason\""
|
||||
"}\n"
|
||||
"Improve only the target field. Do not rewrite unrelated fields. Use positive concrete visual language and preserve user intent. "
|
||||
"Keep the replacement text concise. End immediately after the JSON object with <end_of_turn>.\n"
|
||||
)
|
||||
return (
|
||||
"ASSISTANT MODE\n"
|
||||
f"{mode}\n\n"
|
||||
"USER DIRECTION\n"
|
||||
f"{_clean_text(brief) or 'Improve the current board for stronger Ideogram 4 results while preserving user intent.'}\n\n"
|
||||
"CURRENT SELECTED BOX\n"
|
||||
f"{selected_block}\n\n"
|
||||
"TARGET FIELD KEY, IF A FIELD BUTTON WAS USED\n"
|
||||
f"{field or 'none'}\n\n"
|
||||
"CURRENT FIELD TEXT, IF A FIELD BUTTON WAS USED\n"
|
||||
f"{text or 'none'}\n\n"
|
||||
"CURRENT FRAMEDESIGNER IDEOBOARD JSON\n"
|
||||
f"{json.dumps(normalized, ensure_ascii=False, indent=2)}\n\n"
|
||||
"CURRENT IDEOGRAM PROMPT JSON\n"
|
||||
f"{prompt_json}\n\n"
|
||||
"OUTPUT CONTRACT\n"
|
||||
"- For mode selected_box_enhance, output: {\"mode\":\"selected_box_enhance\",\"selected_id\":\"...\",\"desc\":\"improved positive visual description\",\"notes\":\"short reason\"}.\n"
|
||||
"- For mode prompt_critic, output: {\"mode\":\"prompt_critic\",\"notes\":\"concise critique\",\"suggestions\":[\"specific improvement\", \"specific improvement\"]}.\n"
|
||||
"- For mode full_json_enhance, output: {\"mode\":\"full_json_enhance\",\"ideogram_prompt\":{...},\"notes\":\"short reason\"}. Preserve all element bbox coordinates and count.\n"
|
||||
"- For mode brief_to_ideoboard, output: {\"mode\":\"brief_to_ideoboard\",\"ideoboard\":{...},\"notes\":\"short reason\"}. The ideoboard must use schema iamccs.ideogram_storyboard_frame_designer, canvas, scene, items, workflow_mode, grid_key, target_resolution_key.\n"
|
||||
"Use compact JSON. Avoid whitespace-heavy formatting. End immediately after the JSON object with <end_of_turn>.\n"
|
||||
)
|
||||
|
||||
|
||||
def _strip_gemma_response_noise(raw: Any) -> str:
|
||||
text = _clean_text(raw)
|
||||
if not text:
|
||||
return ""
|
||||
for marker in ("<end_of_turn>", "<eos>", "</s>"):
|
||||
text = text.replace(marker, "")
|
||||
stripped = text.strip()
|
||||
if stripped.startswith("```"):
|
||||
lines = stripped.splitlines()
|
||||
if lines and lines[0].strip().startswith("```"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip().startswith("```"):
|
||||
lines = lines[:-1]
|
||||
stripped = "\n".join(lines).strip()
|
||||
return stripped
|
||||
|
||||
|
||||
def _extract_balanced_json_object(raw: str) -> Dict[str, Any]:
|
||||
text = _strip_gemma_response_noise(raw)
|
||||
if not text:
|
||||
return {}
|
||||
for start in [idx for idx, char in enumerate(text) if char == "{"]:
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
for pos in range(start, len(text)):
|
||||
char = text[pos]
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif char == "\\":
|
||||
escape = True
|
||||
elif char == '"':
|
||||
in_string = False
|
||||
continue
|
||||
if char == '"':
|
||||
in_string = True
|
||||
elif char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidate = text[start:pos + 1]
|
||||
parsed = _safe_json(candidate, {})
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
break
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_json_object(raw: Any) -> Dict[str, Any]:
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
cleaned = _strip_gemma_response_noise(raw)
|
||||
parsed = _safe_json(cleaned, {})
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
parsed = _extract_balanced_json_object(cleaned)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _apply_gemma_assistant_response(design: Dict[str, Any], response_payload: Dict[str, Any], selected_id: str, mode: str) -> Tuple[Dict[str, Any], str]:
|
||||
current = _normalize_design(design)
|
||||
payload = response_payload if isinstance(response_payload, dict) else {}
|
||||
notes = _clean_text(payload.get("notes") or payload.get("report"))
|
||||
response_mode = _clean_text(payload.get("mode") or mode)
|
||||
|
||||
if response_mode == "prompt_critic":
|
||||
suggestions = payload.get("suggestions")
|
||||
if isinstance(suggestions, list) and suggestions:
|
||||
notes = (notes + " | " if notes else "") + " | ".join(_clean_text(x) for x in suggestions if _clean_text(x))
|
||||
return current, notes or "Gemma critic returned notes only."
|
||||
|
||||
if response_mode == "field_enhance":
|
||||
return current, notes or "Gemma returned a field patch."
|
||||
|
||||
if response_mode == "selected_box_enhance" or payload.get("desc"):
|
||||
target_id = _clean_text(payload.get("selected_id") or selected_id)
|
||||
desc = _clean_text(payload.get("desc") or payload.get("description"))
|
||||
if desc:
|
||||
for item in current.get("items", []):
|
||||
if _clean_text(item.get("id")) == target_id:
|
||||
item["desc"] = desc
|
||||
if item.get("kind") == "text" and payload.get("text") is not None:
|
||||
item["text"] = _clean_text(payload.get("text"))
|
||||
return current, notes or f"Enhanced selected box {target_id}."
|
||||
return current, notes or "Gemma returned no selected-box description to apply."
|
||||
|
||||
ideoboard = payload.get("ideoboard") or payload.get("design_data") or payload.get("board")
|
||||
if isinstance(ideoboard, dict):
|
||||
next_design = _normalize_design(ideoboard)
|
||||
return next_design, notes or "Gemma returned a complete ideoboard."
|
||||
|
||||
ideogram_prompt = payload.get("ideogram_prompt") or payload.get("prompt_json") or payload.get("prompt")
|
||||
if isinstance(ideogram_prompt, dict) and _is_ideogram_prompt_json(ideogram_prompt):
|
||||
converted = _from_ideogram_prompt(ideogram_prompt)
|
||||
for key in ("canvas", "i2i", "reference_mode", "workflow_mode", "preset_key", "json_export_mode", "gemma_assistant", "mask_paint"):
|
||||
if key in current:
|
||||
converted[key] = copy.deepcopy(current[key])
|
||||
return _normalize_design(converted), notes or "Gemma returned enhanced Ideogram prompt JSON."
|
||||
|
||||
if _is_ideogram_prompt_json(payload):
|
||||
converted = _from_ideogram_prompt(payload)
|
||||
for key in ("canvas", "i2i", "reference_mode", "workflow_mode", "preset_key", "json_export_mode", "gemma_assistant", "mask_paint"):
|
||||
if key in current:
|
||||
converted[key] = copy.deepcopy(current[key])
|
||||
return _normalize_design(converted), notes or "Gemma returned raw Ideogram prompt JSON."
|
||||
|
||||
return current, notes or "Gemma response was valid JSON but did not contain an applicable ideoboard, prompt, or box patch."
|
||||
|
||||
|
||||
def _normalize_gemma_model_name(value: Any) -> str:
|
||||
model = _clean_text(value) or _GEMMA_ASSIST_DEFAULT_MODEL
|
||||
return model.replace("/", "\\")
|
||||
|
||||
|
||||
def _gemma_model_candidates(value: Any) -> List[str]:
|
||||
model = _normalize_gemma_model_name(value)
|
||||
candidates = [model]
|
||||
lower = model.lower()
|
||||
if lower.startswith("text_encoders\\"):
|
||||
candidates.append(model.split("\\", 1)[1])
|
||||
else:
|
||||
candidates.append(f"text_encoders\\{model}")
|
||||
out: List[str] = []
|
||||
for candidate in candidates:
|
||||
if candidate and candidate not in out:
|
||||
out.append(candidate)
|
||||
return out
|
||||
|
||||
|
||||
def _list_gemma_assist_models() -> List[str]:
|
||||
try:
|
||||
names = folder_paths.get_filename_list("text_encoders")
|
||||
except Exception:
|
||||
names = []
|
||||
cleaned: List[str] = []
|
||||
for name in names:
|
||||
text = _clean_text(name).replace("/", "\\")
|
||||
if text and text not in cleaned:
|
||||
cleaned.append(text)
|
||||
preferred = [name for name in cleaned if "gemma" in name.lower()]
|
||||
others = [name for name in cleaned if name not in preferred]
|
||||
return preferred + others
|
||||
|
||||
|
||||
def _load_gemma_assist_clip(model: str, device: str = "default") -> Tuple[Any, str]:
|
||||
last_error: Exception | None = None
|
||||
for candidate in _gemma_model_candidates(model):
|
||||
key = (candidate, device)
|
||||
if key in _GEMMA_ASSIST_CLIP_CACHE:
|
||||
return _GEMMA_ASSIST_CLIP_CACHE[key], candidate
|
||||
try:
|
||||
clip = comfy_nodes.CLIPLoader().load_clip(candidate, "ideogram4", device)[0]
|
||||
_GEMMA_ASSIST_CLIP_CACHE[key] = clip
|
||||
return clip, candidate
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
raise RuntimeError(f"Could not load Gemma text encoder '{model}': {last_error}")
|
||||
|
||||
|
||||
def _extract_node_output_text(output: Any) -> str:
|
||||
try:
|
||||
result = getattr(output, "result", None)
|
||||
if isinstance(result, (list, tuple)) and result:
|
||||
return _clean_text(result[0])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return _clean_text(output[0])
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(output, (list, tuple)) and output:
|
||||
return _clean_text(output[0])
|
||||
return _clean_text(output)
|
||||
|
||||
|
||||
def _set_gemma_interrupt(value: bool) -> None:
|
||||
try:
|
||||
import comfy.model_management as model_management
|
||||
model_management.interrupt_current_processing(bool(value))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _gemma_token_budget(mode: str, speed: str, requested: Any = None) -> int:
|
||||
try:
|
||||
explicit = int(requested)
|
||||
except Exception:
|
||||
explicit = 0
|
||||
if explicit > 0:
|
||||
return max(64, min(1400, explicit))
|
||||
speed_key = _clean_text(speed).lower()
|
||||
if speed_key not in {"fast", "detailed"}:
|
||||
speed_key = "fast"
|
||||
budgets = {
|
||||
"field_enhance": {"fast": 128, "detailed": 220},
|
||||
"selected_box_enhance": {"fast": 160, "detailed": 280},
|
||||
"prompt_critic": {"fast": 180, "detailed": 300},
|
||||
"full_json_enhance": {"fast": 420, "detailed": 720},
|
||||
"brief_to_ideoboard": {"fast": 560, "detailed": 900},
|
||||
}
|
||||
return budgets.get(mode, budgets["full_json_enhance"])[speed_key]
|
||||
|
||||
|
||||
def _call_local_gemma_generate(model: str, system: str, prompt: str, max_length: int = 1400) -> Tuple[str, str]:
|
||||
from comfy_extras.nodes_textgen import TextGenerate
|
||||
from server import PromptServer
|
||||
|
||||
if _GEMMA_ASSIST_ABORT_REQUESTED:
|
||||
raise RuntimeError("Gemma assistant was stopped before generation started.")
|
||||
clip, loaded_model = _load_gemma_assist_clip(model)
|
||||
formatted_prompt = (
|
||||
f"<start_of_turn>system\n{system.strip()}<end_of_turn>\n"
|
||||
f"<start_of_turn>user\n{prompt.strip()}<end_of_turn>\n"
|
||||
"<start_of_turn>model\n"
|
||||
)
|
||||
server_instance = getattr(PromptServer, "instance", None)
|
||||
old_prompt_id = getattr(server_instance, "last_prompt_id", None) if server_instance is not None else None
|
||||
old_node_id = getattr(server_instance, "last_node_id", None) if server_instance is not None else None
|
||||
had_prompt_id = hasattr(server_instance, "last_prompt_id") if server_instance is not None else False
|
||||
had_node_id = hasattr(server_instance, "last_node_id") if server_instance is not None else False
|
||||
if server_instance is not None:
|
||||
server_instance.last_prompt_id = "iamccs_gemma_assistant"
|
||||
server_instance.last_node_id = "iamccs_framedesigner_gemma"
|
||||
try:
|
||||
_set_gemma_interrupt(False)
|
||||
output = TextGenerate.execute(
|
||||
clip=clip,
|
||||
prompt=formatted_prompt,
|
||||
max_length=max(64, min(1400, int(max_length or 1400))),
|
||||
sampling_mode={"sampling_mode": "off"},
|
||||
thinking=False,
|
||||
use_default_template=False,
|
||||
)
|
||||
finally:
|
||||
if server_instance is not None:
|
||||
if had_prompt_id:
|
||||
server_instance.last_prompt_id = old_prompt_id
|
||||
else:
|
||||
try:
|
||||
delattr(server_instance, "last_prompt_id")
|
||||
except Exception:
|
||||
pass
|
||||
if had_node_id:
|
||||
server_instance.last_node_id = old_node_id
|
||||
else:
|
||||
try:
|
||||
delattr(server_instance, "last_node_id")
|
||||
except Exception:
|
||||
pass
|
||||
return _extract_node_output_text(output), loaded_model
|
||||
|
||||
|
||||
def _field_patch_from_gemma_response(response_json: Dict[str, Any], raw_text: str, target_field: str, selected_id: str) -> Dict[str, str]:
|
||||
payload = response_json if isinstance(response_json, dict) else {}
|
||||
text = _clean_text(payload.get("text") or payload.get("replacement") or payload.get("desc") or payload.get("description"))
|
||||
if not text:
|
||||
text = _strip_gemma_response_noise(raw_text)
|
||||
if text.startswith("{") and text.endswith("}"):
|
||||
parsed = _extract_json_object(text)
|
||||
text = _clean_text(parsed.get("text") or parsed.get("replacement") or parsed.get("desc") or parsed.get("description"))
|
||||
return {
|
||||
"field_key": _clean_text(payload.get("field_key") or target_field),
|
||||
"selected_id": _clean_text(payload.get("selected_id") or selected_id),
|
||||
"text": text,
|
||||
}
|
||||
|
||||
|
||||
def _register_framedesigner_gemma_route() -> None:
|
||||
try:
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
except Exception:
|
||||
return
|
||||
instance = getattr(PromptServer, "instance", None)
|
||||
routes = getattr(instance, "routes", None)
|
||||
if routes is None or getattr(instance, "_iamccs_framedesigner_gemma_route", False):
|
||||
return
|
||||
|
||||
@routes.get("/iamccs/framedesigner/gemma_models")
|
||||
async def iamccs_framedesigner_gemma_models(request):
|
||||
models = _list_gemma_assist_models()
|
||||
default_model = _GEMMA_ASSIST_DEFAULT_MODEL
|
||||
if default_model not in models:
|
||||
for candidate in _gemma_model_candidates(default_model):
|
||||
if candidate in models:
|
||||
default_model = candidate
|
||||
break
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"models": models,
|
||||
"default": default_model if default_model in models else (models[0] if models else _GEMMA_ASSIST_DEFAULT_MODEL),
|
||||
})
|
||||
|
||||
@routes.post("/iamccs/framedesigner/gemma_abort")
|
||||
async def iamccs_framedesigner_gemma_abort(request):
|
||||
global _GEMMA_ASSIST_ABORT_REQUESTED
|
||||
_GEMMA_ASSIST_ABORT_REQUESTED = True
|
||||
_set_gemma_interrupt(True)
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"running": bool(_GEMMA_ASSIST_RUNNING),
|
||||
"message": "Gemma assistant stop requested.",
|
||||
})
|
||||
|
||||
@routes.post("/iamccs/framedesigner/gemma_assist")
|
||||
async def iamccs_framedesigner_gemma_assist(request):
|
||||
global _GEMMA_ASSIST_ABORT_REQUESTED, _GEMMA_ASSIST_RUNNING
|
||||
try:
|
||||
payload = await request.json()
|
||||
design = payload.get("design_data") if isinstance(payload, dict) else {}
|
||||
mode = _clean_text(payload.get("mode") if isinstance(payload, dict) else "") or "full_json_enhance"
|
||||
if mode not in {"full_json_enhance", "selected_box_enhance", "brief_to_ideoboard", "prompt_critic", "field_enhance"}:
|
||||
mode = "full_json_enhance"
|
||||
brief = _clean_text(payload.get("brief") if isinstance(payload, dict) else "")
|
||||
selected_id = _clean_text(payload.get("selected_id") if isinstance(payload, dict) else "")
|
||||
target_field = _clean_text(payload.get("target_field") if isinstance(payload, dict) else "")
|
||||
current_text = _clean_text(payload.get("current_text") if isinstance(payload, dict) else "")
|
||||
model = _normalize_gemma_model_name(payload.get("model") if isinstance(payload, dict) else "")
|
||||
speed = _clean_text(payload.get("speed") if isinstance(payload, dict) else "") or "fast"
|
||||
max_tokens = _gemma_token_budget(mode, speed, payload.get("max_tokens") if isinstance(payload, dict) else None)
|
||||
normalized = _normalize_design(design)
|
||||
system_prompt = _gemma_assistant_system_prompt()
|
||||
user_prompt = _gemma_assistant_user_prompt(normalized, mode, brief, selected_id, target_field, current_text)
|
||||
_GEMMA_ASSIST_ABORT_REQUESTED = False
|
||||
_GEMMA_ASSIST_RUNNING = True
|
||||
response_text, loaded_model = await asyncio.to_thread(
|
||||
_call_local_gemma_generate,
|
||||
model,
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
max_tokens,
|
||||
)
|
||||
if _GEMMA_ASSIST_ABORT_REQUESTED:
|
||||
return web.json_response({"ok": False, "error": "Gemma assistant was stopped."}, status=409)
|
||||
response_json = _extract_json_object(response_text)
|
||||
if mode == "field_enhance":
|
||||
field_patch = _field_patch_from_gemma_response(response_json, response_text, target_field, selected_id)
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"mode": mode,
|
||||
"selected_id": selected_id,
|
||||
"target_field": target_field,
|
||||
"field_patch": field_patch,
|
||||
"notes": _clean_text(response_json.get("notes")) or "Gemma enhanced the selected field.",
|
||||
"raw_response": response_json or _strip_gemma_response_noise(response_text),
|
||||
"raw_text": _strip_gemma_response_noise(response_text),
|
||||
"model": loaded_model,
|
||||
"speed": speed,
|
||||
"max_tokens": max_tokens,
|
||||
})
|
||||
next_design, notes = _apply_gemma_assistant_response(normalized, response_json, selected_id, mode)
|
||||
prompt_json = json.dumps(_to_ideogram_prompt(next_design), ensure_ascii=False, indent=2)
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"mode": mode,
|
||||
"selected_id": selected_id,
|
||||
"target_field": target_field,
|
||||
"design_data": next_design,
|
||||
"prompt_json": prompt_json,
|
||||
"notes": notes,
|
||||
"raw_response": response_json or _strip_gemma_response_noise(response_text),
|
||||
"raw_text": _strip_gemma_response_noise(response_text),
|
||||
"model": loaded_model,
|
||||
"speed": speed,
|
||||
"max_tokens": max_tokens,
|
||||
})
|
||||
except Exception as exc:
|
||||
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
||||
finally:
|
||||
_GEMMA_ASSIST_RUNNING = False
|
||||
_GEMMA_ASSIST_ABORT_REQUESTED = False
|
||||
_set_gemma_interrupt(False)
|
||||
|
||||
instance._iamccs_framedesigner_gemma_route = True
|
||||
|
||||
|
||||
|
||||
def _resolve_image_path(path: str) -> str:
|
||||
clean = _clean_text(path).replace("\\", "/")
|
||||
if not clean:
|
||||
@@ -1854,3 +2385,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"IAMCCS_IdeoMaskedPixels": "IAMCCS Ideo Masked Pixels",
|
||||
"IAMCCS_IdeogramJSONPreviewPass": "IAMCCS Ideogram JSON Preview / Pass",
|
||||
}
|
||||
|
||||
|
||||
_register_framedesigner_gemma_route()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
const TARGET_CLASS = "IAMCCS_CineMotionSketch";
|
||||
const STATE_KEY = "__iamccs_motion_sketch_ui";
|
||||
const UI_W = 1160;
|
||||
const UI_H = 760;
|
||||
|
||||
function widget(node, name) {
|
||||
return (node?.widgets || []).find((w) => w?.name === name || w?.label === name) || null;
|
||||
}
|
||||
|
||||
function safeParse(value, fallback = {}) {
|
||||
if (value && typeof value === "object") return value;
|
||||
try {
|
||||
const text = String(value || "").trim();
|
||||
return text ? JSON.parse(text) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeWidget(node, name, value) {
|
||||
const w = widget(node, name);
|
||||
if (!w) return;
|
||||
w.value = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
try { w.callback?.(w.value); } catch {}
|
||||
try { app.graph?.setDirtyCanvas?.(true, true); } catch {}
|
||||
}
|
||||
|
||||
function graphNodeById(id) {
|
||||
try {
|
||||
return app.graph?._nodes_by_id?.[id] || app.graph?.getNodeById?.(id) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function linkedSourceNode(node, inputName) {
|
||||
const input = (node.inputs || []).find((inp) => inp?.name === inputName);
|
||||
if (!input || input.link == null) return null;
|
||||
const link = app.graph?.links?.[input.link];
|
||||
if (!link) return null;
|
||||
return graphNodeById(link.origin_id);
|
||||
}
|
||||
|
||||
function normalizeSegment(raw, index) {
|
||||
const start = Math.max(0, Math.round(Number(raw?.start ?? raw?.frame ?? 0) || 0));
|
||||
const length = Math.max(1, Math.round(Number(raw?.length ?? raw?.frames ?? raw?.len ?? 24) || 24));
|
||||
const id = String(raw?.id || raw?.segment_id || raw?.slot_id || `shot_${index + 1}`);
|
||||
const imageFile = String(raw?.imageFile || raw?.image_file || raw?.file || raw?.filename || raw?.path || "");
|
||||
return {
|
||||
id,
|
||||
label: String(raw?.label || raw?.name || raw?.refLabel || raw?.image_label || imageFile || id || `Shot ${index + 1}`),
|
||||
start,
|
||||
length,
|
||||
imageFile,
|
||||
prompt: String(raw?.prompt || raw?.local_prompt || raw?.localPrompt || ""),
|
||||
raw: raw && typeof raw === "object" ? raw : {},
|
||||
};
|
||||
}
|
||||
|
||||
function extractSegmentsFromData(data) {
|
||||
if (!data || typeof data !== "object") return [];
|
||||
const candidates = [
|
||||
data.visual_segments,
|
||||
data.segments,
|
||||
data.timeline_segments,
|
||||
data.rows,
|
||||
data.timeline?.segments,
|
||||
data.payload?.visual_segments,
|
||||
data.resources?.cine_payload?.visual_segments,
|
||||
data.resources?.cine_payload?.segments,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (Array.isArray(candidate) && candidate.length) {
|
||||
return candidate.map(normalizeSegment).sort((a, b) => a.start - b.start);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function collectShotboardCandidates(source) {
|
||||
const out = [];
|
||||
const push = (value) => {
|
||||
if (value == null) return;
|
||||
out.push(value);
|
||||
};
|
||||
for (const name of ["timeline_data", "timeline_json", "shotboard_data", "board_data"]) {
|
||||
push(widget(source, name)?.value);
|
||||
}
|
||||
for (const w of source?.widgets || []) {
|
||||
if (typeof w?.value === "string" && /segments|visual_segments|timeline/i.test(w.value)) push(w.value);
|
||||
}
|
||||
const props = source?.properties || {};
|
||||
for (const key of Object.keys(props)) {
|
||||
if (/timeline|board|segment|payload/i.test(key)) push(props[key]);
|
||||
}
|
||||
push(source?.timeline_data);
|
||||
push(source?.timelineData);
|
||||
push(source?.shotboard_data);
|
||||
push(source?.iamccs_timeline_data);
|
||||
return out;
|
||||
}
|
||||
|
||||
function readLinkedShotboardSegments(node) {
|
||||
const source = linkedSourceNode(node, "cine_linx");
|
||||
if (!source) return { segments: [], source: "not connected" };
|
||||
for (const candidate of collectShotboardCandidates(source)) {
|
||||
const segments = extractSegmentsFromData(safeParse(candidate, null));
|
||||
if (segments.length) return { segments, source: `${source.type || source.comfyClass || "node"}:${source.id}` };
|
||||
}
|
||||
return { segments: [], source: `${source.type || source.comfyClass || "node"}:${source.id} no timeline found` };
|
||||
}
|
||||
|
||||
function imageUrl(file) {
|
||||
if (!file) return "";
|
||||
const raw = String(file).replace(/\\/g, "/").trim();
|
||||
if (/^(https?:|blob:|data:)/i.test(raw)) return raw;
|
||||
if (/^[A-Z]:\//i.test(raw)) {
|
||||
return `/view?filename=${encodeURIComponent(raw.split("/").pop())}&type=input`;
|
||||
}
|
||||
return `/view?filename=${encodeURIComponent(raw)}&type=input`;
|
||||
}
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById("iamccs-motion-sketch-style")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "iamccs-motion-sketch-style";
|
||||
style.textContent = `
|
||||
.iamccs-motion-sketch{font-family:Inter,Arial,sans-serif;color:#eaf7f5;background:#071011;border:1px solid #2e4948;border-radius:8px;padding:12px;box-sizing:border-box;width:${UI_W}px;min-width:${UI_W}px;height:${UI_H}px;min-height:${UI_H}px;overflow:hidden}
|
||||
.iamccs-ms-top{display:grid;grid-template-columns:1fr auto;gap:12px;align-items:center;height:42px;margin-bottom:10px}
|
||||
.iamccs-ms-status{border:1px solid #284241;border-radius:6px;background:#081516;color:#9fe3e8;font:700 11px/1.2 monospace;padding:7px 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.iamccs-ms-actions{display:flex;gap:8px;align-items:center}
|
||||
.iamccs-ms-btn,.iamccs-ms-select{height:30px;border-radius:6px;border:1px solid #3f6c6c;background:#102b2e;color:#e9ffff;font-weight:800;font-size:11px;padding:0 10px}
|
||||
.iamccs-ms-btn.on{background:#dcb66e;color:#111;border-color:#ffe3a0;box-shadow:0 0 0 1px rgba(255,232,170,.35) inset}
|
||||
.iamccs-ms-grid{display:grid;grid-template-columns:1fr 292px;grid-template-rows:174px 1fr;gap:10px;height:682px}
|
||||
.iamccs-ms-timeline{grid-column:1/3;border:1px solid #263d3c;border-radius:7px;background:#071112;overflow:hidden;display:grid;grid-template-columns:128px 1fr}
|
||||
.iamccs-ms-legend{border-right:1px solid #284241;background:#0c191a;color:#ffe2a6;font-size:11px;font-weight:900;padding:10px}
|
||||
.iamccs-ms-lane-label{height:28px;display:flex;align-items:center;border-top:1px solid rgba(255,255,255,.05);color:#a9dadd;cursor:pointer;padding-left:2px;border-left:3px solid transparent;box-sizing:border-box}
|
||||
.iamccs-ms-lane-label.on{color:#fff0bc;border-left-color:#ffd579;background:rgba(255,213,121,.08)}
|
||||
.iamccs-ms-time{position:relative;overflow-x:auto;overflow-y:hidden;background:#050b0c}
|
||||
.iamccs-ms-ruler{position:relative;height:28px;border-bottom:1px solid #263d3c;background:linear-gradient(90deg,rgba(255,255,255,.12) 1px,transparent 1px);background-size:48px 100%}
|
||||
.iamccs-ms-shotrow{position:relative;height:42px;border-bottom:1px solid #1d3030;background:#081112}
|
||||
.iamccs-ms-trackrow{position:relative;height:28px;border-bottom:1px solid #132323;background:#061011;cursor:pointer}
|
||||
.iamccs-ms-trackrow.on{background:linear-gradient(90deg,rgba(255,213,121,.08),rgba(6,16,17,.98))}
|
||||
.iamccs-ms-shotblock{position:absolute;top:4px;height:32px;border:1px solid #486d6a;border-radius:5px;background:#122b2d;color:#f5e6bd;font-size:10px;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:8px 6px;box-sizing:border-box;cursor:pointer}
|
||||
.iamccs-ms-shotblock.on{background:#4a3617;border-color:#ffd579;color:#fff5ce}
|
||||
.iamccs-ms-part{position:absolute;top:5px;height:18px;border-radius:999px;border:1px solid rgba(255,255,255,.35);box-shadow:0 0 10px rgba(0,0,0,.35);cursor:pointer}
|
||||
.iamccs-ms-part.on{outline:2px solid #ffe7a2}
|
||||
.iamccs-ms-playhead{position:absolute;top:0;width:2px;height:154px;background:#ffe89d;box-shadow:0 0 0 1px rgba(0,0,0,.4),0 0 10px rgba(255,232,157,.75);pointer-events:none;z-index:8}
|
||||
.iamccs-ms-playhead::before{content:"";position:absolute;left:-5px;top:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:8px solid #ffe89d}
|
||||
.iamccs-ms-stage{position:relative;background:#020607;border:1px solid #315452;border-radius:7px;overflow:hidden;min-height:498px}
|
||||
.iamccs-ms-canvas,.iamccs-ms-img{position:absolute;inset:0;width:100%;height:100%}
|
||||
.iamccs-ms-img{object-fit:contain;background:radial-gradient(circle at 50% 40%,#152323,#030707 68%)}
|
||||
.iamccs-ms-canvas{touch-action:none;cursor:crosshair}
|
||||
.iamccs-ms-noimg{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#5f8b8d;font-weight:900;font-size:13px;pointer-events:none}
|
||||
.iamccs-ms-side{border:1px solid #263d3c;border-radius:7px;background:#0b1718;padding:10px;overflow:auto;min-height:498px}
|
||||
.iamccs-ms-side h4{font-size:11px;color:#f4deb0;margin:8px 0 6px}
|
||||
.iamccs-ms-field{display:grid;grid-template-columns:92px 1fr;gap:6px;align-items:center;margin:7px 0;font-size:10px;color:#a8d8da}
|
||||
.iamccs-ms-field input,.iamccs-ms-field select{width:100%;height:26px;border-radius:5px;border:1px solid #315452;background:#071011;color:#eff;font-weight:700}
|
||||
.iamccs-ms-list{display:flex;flex-direction:column;gap:5px;margin-top:8px}
|
||||
.iamccs-ms-stroke{border:1px solid #315452;background:#102124;border-radius:5px;padding:7px;font-size:10px;color:#d7ffff;text-align:left}
|
||||
.iamccs-ms-stroke.on{border-color:#ffd579;background:#332914}
|
||||
.iamccs-ms-hint{font-size:10px;color:#8fc9cf;line-height:1.45;margin-top:8px}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function newSketchData(node) {
|
||||
const data = safeParse(widget(node, "motion_sketch_data")?.value, {});
|
||||
if (!Array.isArray(data.strokes)) data.strokes = [];
|
||||
data.schema = data.schema || "iamccs.shotboard_v4.motion_sketch";
|
||||
data.schema_version = data.schema_version || 1;
|
||||
data.view = data.view && typeof data.view === "object" ? data.view : {};
|
||||
return data;
|
||||
}
|
||||
|
||||
function durationFrames(state) {
|
||||
return Math.max(24, ...state.segments.map((s) => s.start + s.length), ...state.strokes.map((s) => (s.start_frame || 0) + (s.length_frames || 1)));
|
||||
}
|
||||
|
||||
function segmentsSignature(segments) {
|
||||
return (segments || []).map((seg) => [
|
||||
seg.id,
|
||||
seg.start,
|
||||
seg.length,
|
||||
seg.imageFile,
|
||||
seg.label,
|
||||
seg.prompt,
|
||||
].join(":")).join("|");
|
||||
}
|
||||
|
||||
function alignStrokesToSegments(state) {
|
||||
const byId = new Map((state.segments || []).map((seg) => [String(seg.id), seg]));
|
||||
for (const stroke of state.strokes || []) {
|
||||
const seg = byId.get(String(stroke.segment_id || ""));
|
||||
if (!seg) continue;
|
||||
stroke.start_frame = seg.start;
|
||||
stroke.length_frames = seg.length;
|
||||
}
|
||||
}
|
||||
|
||||
function selectedSegment(state) {
|
||||
return state.segments.find((s) => s.id === state.selectedSegmentId) || state.segments[0] || null;
|
||||
}
|
||||
|
||||
function persist(node, state) {
|
||||
const data = newSketchData(node);
|
||||
data.frame_rate = Number(widget(node, "frame_rate")?.value || data.frame_rate || 24);
|
||||
data.control_family = String(widget(node, "control_family")?.value || data.control_family || "auto");
|
||||
data.shotboard_segments = state.segments;
|
||||
data.strokes = state.strokes;
|
||||
data.motionParts = buildMotionParts(state);
|
||||
data.view = { ...(data.view || {}), selected_segment_id: state.selectedSegmentId, selected_stroke_id: state.selectedStrokeId };
|
||||
data.duration_frames = durationFrames(state);
|
||||
writeWidget(node, "motion_sketch_data", data);
|
||||
}
|
||||
|
||||
function strokeColor(track) {
|
||||
const map = {
|
||||
camera_path: "#4ed6ff",
|
||||
subject_path: "#78f287",
|
||||
object_path: "#ffd267",
|
||||
background_lock: "#ff6c83",
|
||||
attention_mask: "#b389ff",
|
||||
};
|
||||
return map[track] || "#4ed6ff";
|
||||
}
|
||||
|
||||
function buildMotionParts(state) {
|
||||
return state.strokes.map((s, i) => ({
|
||||
id: s.id || `motion_part_${i + 1}`,
|
||||
type: "motion_control",
|
||||
segment_id: s.segment_id || "",
|
||||
track: s.track || "camera_path",
|
||||
mode: s.mode || "motion_track",
|
||||
scope: s.scope || "slot_only",
|
||||
start: Math.max(0, Math.round(Number(s.start_frame || 0))),
|
||||
length: Math.max(1, Math.round(Number(s.length_frames || 24))),
|
||||
trimStart: Math.max(0, Math.round(Number(s.start_frame || 0))),
|
||||
videoStrength: Number(s.strength ?? 0.75),
|
||||
videoAttentionStrength: Number(s.attention_strength ?? 0.65),
|
||||
resampleMode: "nearest",
|
||||
}));
|
||||
}
|
||||
|
||||
function repaintCanvas(state) {
|
||||
const canvas = state.canvas;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.max(1, Math.round(rect.width * dpr));
|
||||
const h = Math.max(1, Math.round(rect.height * dpr));
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
}
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const stroke of state.strokes) {
|
||||
if (stroke.segment_id && stroke.segment_id !== state.selectedSegmentId) continue;
|
||||
const points = Array.isArray(stroke.points) ? stroke.points : [];
|
||||
if (!points.length) continue;
|
||||
ctx.globalAlpha = stroke.id === state.selectedStrokeId ? 1 : 0.82;
|
||||
ctx.strokeStyle = strokeColor(stroke.track);
|
||||
ctx.lineWidth = Math.max(3, Number(stroke.radius || 20) / 6);
|
||||
ctx.shadowColor = strokeColor(stroke.track);
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.beginPath();
|
||||
points.forEach((p, i) => {
|
||||
const x = Number(p[0] || 0) * rect.width;
|
||||
const y = Number(p[1] || 0) * rect.height;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
const last = points[points.length - 1];
|
||||
if (last) {
|
||||
ctx.fillStyle = strokeColor(stroke.track);
|
||||
ctx.beginPath();
|
||||
ctx.arc(Number(last[0]) * rect.width, Number(last[1]) * rect.height, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function renderStrokeList(state) {
|
||||
state.list.innerHTML = "";
|
||||
const visible = state.strokes.filter((s) => !s.segment_id || s.segment_id === state.selectedSegmentId);
|
||||
for (const stroke of visible) {
|
||||
const item = document.createElement("button");
|
||||
item.className = `iamccs-ms-stroke${stroke.id === state.selectedStrokeId ? " on" : ""}`;
|
||||
item.type = "button";
|
||||
item.textContent = `${stroke.track || "path"} / ${stroke.mode || "motion"} / ${stroke.scope || "slot_only"} / ${stroke.start_frame || 0}f + ${stroke.length_frames || 0}f / ${stroke.points?.length || 0} pts`;
|
||||
item.onclick = () => {
|
||||
state.selectedStrokeId = stroke.id;
|
||||
renderStrokeList(state);
|
||||
renderTimeline(state);
|
||||
repaintCanvas(state);
|
||||
};
|
||||
state.list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLaneHighlights(state) {
|
||||
const active = state.trackSelect?.value || "camera_path";
|
||||
state.root?.querySelectorAll?.(".iamccs-ms-lane-label[data-track]")?.forEach((label) => {
|
||||
label.classList.toggle("on", label.dataset.track === active);
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveTrack(state, track) {
|
||||
if (!track || !state.trackSelect) return;
|
||||
state.trackSelect.value = track;
|
||||
renderLaneHighlights(state);
|
||||
renderTimeline(state);
|
||||
state.status.textContent = `TRACK ${track.replace(/_/g, " ")} / draw on the selected shot`;
|
||||
persist(state.node, state);
|
||||
}
|
||||
|
||||
function frameFromPointer(state, event) {
|
||||
const innerRect = state.timeInner.getBoundingClientRect();
|
||||
const x = Math.max(0, event.clientX - innerRect.left);
|
||||
return Math.max(0, Math.round(x / Math.max(0.001, state.pxPerFrame || 1)));
|
||||
}
|
||||
|
||||
function setPlayheadFrame(state, frame, selectShot = true) {
|
||||
const total = durationFrames(state);
|
||||
const next = Math.max(0, Math.min(total, Math.round(Number(frame) || 0)));
|
||||
state.playheadFrame = next;
|
||||
if (selectShot) {
|
||||
const seg = state.segments.find((item) => next >= item.start && next < item.start + item.length) || state.segments[state.segments.length - 1];
|
||||
if (seg && seg.id !== state.selectedSegmentId) {
|
||||
state.selectedSegmentId = seg.id;
|
||||
state.noImg.style.display = seg?.imageFile ? "none" : "flex";
|
||||
state.currentImage.src = seg?.imageFile ? imageUrl(seg.imageFile) : "";
|
||||
renderStrokeList(state);
|
||||
repaintCanvas(state);
|
||||
}
|
||||
}
|
||||
state.status.textContent = `SCRUB ${next}f / ${total}f / shot ${selectedSegment(state)?.label || "none"}`;
|
||||
renderTimeline(state);
|
||||
persist(state.node, state);
|
||||
}
|
||||
|
||||
function setPlayheadFromEvent(state, event) {
|
||||
setPlayheadFrame(state, frameFromPointer(state, event), true);
|
||||
}
|
||||
|
||||
function setupScrubbing(state) {
|
||||
let scrubbing = false;
|
||||
state.timeScroll.onpointerdown = (event) => {
|
||||
if (event.target?.closest?.(".iamccs-ms-shotblock,.iamccs-ms-part")) return;
|
||||
event.preventDefault();
|
||||
scrubbing = true;
|
||||
state.timeScroll.setPointerCapture?.(event.pointerId);
|
||||
setPlayheadFromEvent(state, event);
|
||||
};
|
||||
state.timeScroll.onpointermove = (event) => {
|
||||
if (!scrubbing) return;
|
||||
event.preventDefault();
|
||||
setPlayheadFromEvent(state, event);
|
||||
};
|
||||
const stop = () => {
|
||||
scrubbing = false;
|
||||
};
|
||||
state.timeScroll.onpointerup = stop;
|
||||
state.timeScroll.onpointercancel = stop;
|
||||
}
|
||||
|
||||
function renderTimeline(state) {
|
||||
const total = durationFrames(state);
|
||||
const pxPerFrame = Math.max(3, Math.min(10, 920 / total));
|
||||
const width = Math.max(960, Math.ceil(total * pxPerFrame) + 20);
|
||||
state.pxPerFrame = pxPerFrame;
|
||||
state.timelineWidth = width;
|
||||
state.timeInner.style.width = `${width}px`;
|
||||
state.timeInner.innerHTML = "";
|
||||
const ruler = document.createElement("div");
|
||||
ruler.className = "iamccs-ms-ruler";
|
||||
ruler.style.width = `${width}px`;
|
||||
const fps = Number(widget(state.node, "frame_rate")?.value || 24);
|
||||
for (let f = 0; f <= total; f += Math.max(1, Math.round(fps))) {
|
||||
const mark = document.createElement("div");
|
||||
mark.style.position = "absolute";
|
||||
mark.style.left = `${f * pxPerFrame}px`;
|
||||
mark.style.top = "7px";
|
||||
mark.style.font = "800 10px monospace";
|
||||
mark.style.color = "#d2f1f3";
|
||||
mark.textContent = `${Math.round(f / fps)}s`;
|
||||
ruler.appendChild(mark);
|
||||
}
|
||||
state.timeInner.appendChild(ruler);
|
||||
|
||||
const shotRow = document.createElement("div");
|
||||
shotRow.className = "iamccs-ms-shotrow";
|
||||
shotRow.style.width = `${width}px`;
|
||||
for (const seg of state.segments) {
|
||||
const block = document.createElement("button");
|
||||
block.type = "button";
|
||||
block.className = `iamccs-ms-shotblock${seg.id === state.selectedSegmentId ? " on" : ""}`;
|
||||
block.style.left = `${seg.start * pxPerFrame}px`;
|
||||
block.style.width = `${Math.max(24, seg.length * pxPerFrame)}px`;
|
||||
block.textContent = seg.label;
|
||||
block.title = `${seg.label} ${seg.start}f + ${seg.length}f`;
|
||||
block.onclick = () => selectSegment(state, seg.id);
|
||||
shotRow.appendChild(block);
|
||||
}
|
||||
state.timeInner.appendChild(shotRow);
|
||||
|
||||
for (const track of state.trackOrder) {
|
||||
const row = document.createElement("div");
|
||||
row.className = `iamccs-ms-trackrow${state.trackSelect?.value === track ? " on" : ""}`;
|
||||
row.dataset.track = track;
|
||||
row.style.width = `${width}px`;
|
||||
row.onclick = (event) => {
|
||||
if (event.target?.closest?.(".iamccs-ms-part")) return;
|
||||
setActiveTrack(state, track);
|
||||
setPlayheadFromEvent(state, event);
|
||||
};
|
||||
for (const stroke of state.strokes.filter((s) => (s.track || "camera_path") === track)) {
|
||||
const part = document.createElement("button");
|
||||
part.type = "button";
|
||||
part.className = `iamccs-ms-part${stroke.id === state.selectedStrokeId ? " on" : ""}`;
|
||||
part.style.left = `${Math.max(0, Number(stroke.start_frame || 0)) * pxPerFrame}px`;
|
||||
part.style.width = `${Math.max(18, Number(stroke.length_frames || 1) * pxPerFrame)}px`;
|
||||
part.style.background = strokeColor(track);
|
||||
part.title = `${track} ${stroke.start_frame || 0}f + ${stroke.length_frames || 0}f`;
|
||||
part.onclick = (event) => {
|
||||
event?.stopPropagation?.();
|
||||
state.selectedStrokeId = stroke.id;
|
||||
if (stroke.segment_id) selectSegment(state, stroke.segment_id, false);
|
||||
renderTimeline(state);
|
||||
renderStrokeList(state);
|
||||
repaintCanvas(state);
|
||||
};
|
||||
row.appendChild(part);
|
||||
}
|
||||
state.timeInner.appendChild(row);
|
||||
}
|
||||
const playhead = document.createElement("div");
|
||||
playhead.className = "iamccs-ms-playhead";
|
||||
playhead.style.left = `${Math.max(0, Math.min(total, state.playheadFrame || 0)) * pxPerFrame}px`;
|
||||
state.timeInner.appendChild(playhead);
|
||||
renderLaneHighlights(state);
|
||||
}
|
||||
|
||||
function selectSegment(state, id, persistNow = true) {
|
||||
state.selectedSegmentId = id;
|
||||
const seg = selectedSegment(state);
|
||||
if (seg) state.playheadFrame = Math.max(0, Math.round(Number(seg.start || 0)));
|
||||
state.noImg.style.display = seg?.imageFile ? "none" : "flex";
|
||||
state.currentImage.src = seg?.imageFile ? imageUrl(seg.imageFile) : "";
|
||||
state.status.textContent = `SYNC ${state.syncSource} / shot ${seg?.label || "none"} / ${seg?.start || 0}f + ${seg?.length || 0}f`;
|
||||
if (persistNow) persist(state.node, state);
|
||||
renderTimeline(state);
|
||||
renderStrokeList(state);
|
||||
repaintCanvas(state);
|
||||
}
|
||||
|
||||
function refreshFromShotboard(state, options = {}) {
|
||||
const linked = readLinkedShotboardSegments(state.node);
|
||||
const data = newSketchData(state.node);
|
||||
const fallback = extractSegmentsFromData(data);
|
||||
state.syncSource = linked.source;
|
||||
const nextSegments = linked.segments.length ? linked.segments : (fallback.length ? fallback : state.segments);
|
||||
const nextSignature = segmentsSignature(nextSegments);
|
||||
if (options.onlyIfChanged && nextSignature === state.lastSegmentsSignature) return false;
|
||||
state.segments = nextSegments;
|
||||
state.lastSegmentsSignature = nextSignature;
|
||||
state.strokes = Array.isArray(data.strokes) ? data.strokes : [];
|
||||
alignStrokesToSegments(state);
|
||||
const saved = data.view?.selected_segment_id;
|
||||
if (saved && state.segments.some((s) => s.id === saved)) state.selectedSegmentId = saved;
|
||||
if (!state.selectedSegmentId || !state.segments.some((s) => s.id === state.selectedSegmentId)) {
|
||||
state.selectedSegmentId = state.segments[0]?.id || "";
|
||||
}
|
||||
selectSegment(state, state.selectedSegmentId || "", false);
|
||||
persist(state.node, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setupDrawing(state) {
|
||||
let active = null;
|
||||
state.canvas.onpointerdown = (event) => {
|
||||
event.preventDefault();
|
||||
state.canvas.setPointerCapture?.(event.pointerId);
|
||||
const seg = selectedSegment(state);
|
||||
if (!seg) return;
|
||||
state.isDrawing = true;
|
||||
const rect = state.canvas.getBoundingClientRect();
|
||||
const stroke = {
|
||||
id: `motion_part_${Date.now().toString(36)}`,
|
||||
segment_id: seg.id,
|
||||
track: state.trackSelect.value,
|
||||
mode: state.modeSelect.value,
|
||||
scope: state.scopeSelect.value,
|
||||
start_frame: seg.start,
|
||||
length_frames: seg.length,
|
||||
strength: Number(state.strength.value || 0.75),
|
||||
attention_strength: Number(state.attention.value || 0.65),
|
||||
radius: Number(state.radius.value || 28),
|
||||
falloff: Number(state.falloff.value || 0.35),
|
||||
easing: "ease_in_out",
|
||||
points: [[(event.clientX - rect.left) / rect.width, (event.clientY - rect.top) / rect.height]],
|
||||
};
|
||||
state.strokes.push(stroke);
|
||||
state.selectedStrokeId = stroke.id;
|
||||
active = stroke;
|
||||
persist(state.node, state);
|
||||
renderTimeline(state);
|
||||
renderStrokeList(state);
|
||||
repaintCanvas(state);
|
||||
};
|
||||
state.canvas.onpointermove = (event) => {
|
||||
if (!active) return;
|
||||
const rect = state.canvas.getBoundingClientRect();
|
||||
active.points.push([
|
||||
Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
|
||||
Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)),
|
||||
]);
|
||||
persist(state.node, state);
|
||||
repaintCanvas(state);
|
||||
};
|
||||
const finish = () => {
|
||||
if (!active) return;
|
||||
active = null;
|
||||
state.isDrawing = false;
|
||||
persist(state.node, state);
|
||||
renderTimeline(state);
|
||||
renderStrokeList(state);
|
||||
repaintCanvas(state);
|
||||
};
|
||||
state.canvas.onpointerup = finish;
|
||||
state.canvas.onpointercancel = finish;
|
||||
}
|
||||
|
||||
function enforceNodeSize(node) {
|
||||
node.size = [Math.max(node.size?.[0] || 0, UI_W + 30), Math.max(node.size?.[1] || 0, UI_H + 92)];
|
||||
node.min_size = [UI_W + 30, UI_H + 92];
|
||||
}
|
||||
|
||||
function createUi(node) {
|
||||
ensureStyle();
|
||||
enforceNodeSize(node);
|
||||
const root = document.createElement("div");
|
||||
root.className = "iamccs-motion-sketch";
|
||||
root.innerHTML = `
|
||||
<div class="iamccs-ms-top">
|
||||
<div class="iamccs-ms-status">Connect Shotboard V3 cine_linx -> Motion Sketch cine_linx</div>
|
||||
<div class="iamccs-ms-actions">
|
||||
<button class="iamccs-ms-btn" data-act="refresh">Sync Shotboard</button>
|
||||
<button class="iamccs-ms-btn" data-act="clear">Clear Shot</button>
|
||||
<button class="iamccs-ms-btn on" data-act="publish">Publish</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="iamccs-ms-grid">
|
||||
<div class="iamccs-ms-timeline">
|
||||
<div class="iamccs-ms-legend">
|
||||
<div style="height:28px">TIME</div>
|
||||
<div class="iamccs-ms-lane-label">SHOT</div>
|
||||
<div class="iamccs-ms-lane-label" data-track="camera_path">CAMERA</div>
|
||||
<div class="iamccs-ms-lane-label" data-track="subject_path">SUBJECT</div>
|
||||
<div class="iamccs-ms-lane-label" data-track="object_path">OBJECT</div>
|
||||
<div class="iamccs-ms-lane-label" data-track="background_lock">LOCK</div>
|
||||
</div>
|
||||
<div class="iamccs-ms-time"><div class="iamccs-ms-time-inner"></div></div>
|
||||
</div>
|
||||
<div class="iamccs-ms-stage">
|
||||
<img class="iamccs-ms-img" alt="">
|
||||
<canvas class="iamccs-ms-canvas"></canvas>
|
||||
<div class="iamccs-ms-noimg">NO SHOT IMAGE</div>
|
||||
</div>
|
||||
<div class="iamccs-ms-side">
|
||||
<h4>MOTION TRACK</h4>
|
||||
<label class="iamccs-ms-field"><span>track</span><select data-field="track"><option value="camera_path">camera path</option><option value="subject_path">subject path</option><option value="object_path">object path</option><option value="background_lock">background lock</option><option value="attention_mask">attention mask</option></select></label>
|
||||
<label class="iamccs-ms-field"><span>mode</span><select data-field="mode"><option value="motion_track">motion track</option><option value="orbit">orbit</option><option value="dolly">dolly</option><option value="pan">pan</option><option value="lock">lock</option></select></label>
|
||||
<label class="iamccs-ms-field"><span>scope</span><select data-field="scope"><option value="slot_only">slot only</option><option value="continue_to_next">continue to next</option><option value="hold_last">hold last</option><option value="cut_reset">cut reset</option></select></label>
|
||||
<label class="iamccs-ms-field"><span>strength</span><input data-field="strength" type="number" min="0" max="1" step="0.01" value="0.75"></label>
|
||||
<label class="iamccs-ms-field"><span>attention</span><input data-field="attention" type="number" min="0" max="1" step="0.01" value="0.65"></label>
|
||||
<label class="iamccs-ms-field"><span>brush</span><input data-field="radius" type="number" min="1" step="1" value="28"></label>
|
||||
<label class="iamccs-ms-field"><span>falloff</span><input data-field="falloff" type="number" min="0" max="1" step="0.01" value="0.35"></label>
|
||||
<h4>MOTION PARTS</h4>
|
||||
<div class="iamccs-ms-list"></div>
|
||||
<div class="iamccs-ms-hint">Disegna sull'immagine selezionata. Ogni tratto diventa una motionPart sincronizzata allo start/length dello shot nella timeline.</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const state = {
|
||||
node,
|
||||
root,
|
||||
status: root.querySelector(".iamccs-ms-status"),
|
||||
timeScroll: root.querySelector(".iamccs-ms-time"),
|
||||
timeInner: root.querySelector(".iamccs-ms-time-inner"),
|
||||
canvas: root.querySelector(".iamccs-ms-canvas"),
|
||||
currentImage: root.querySelector(".iamccs-ms-img"),
|
||||
noImg: root.querySelector(".iamccs-ms-noimg"),
|
||||
list: root.querySelector(".iamccs-ms-list"),
|
||||
trackSelect: root.querySelector("[data-field='track']"),
|
||||
modeSelect: root.querySelector("[data-field='mode']"),
|
||||
scopeSelect: root.querySelector("[data-field='scope']"),
|
||||
strength: root.querySelector("[data-field='strength']"),
|
||||
attention: root.querySelector("[data-field='attention']"),
|
||||
radius: root.querySelector("[data-field='radius']"),
|
||||
falloff: root.querySelector("[data-field='falloff']"),
|
||||
trackOrder: ["camera_path", "subject_path", "object_path", "background_lock"],
|
||||
segments: [],
|
||||
strokes: [],
|
||||
selectedSegmentId: "",
|
||||
selectedStrokeId: "",
|
||||
playheadFrame: 0,
|
||||
lastSegmentsSignature: "",
|
||||
autoSyncTimer: null,
|
||||
syncSource: "not connected",
|
||||
};
|
||||
node[STATE_KEY] = state;
|
||||
|
||||
root.querySelector("[data-act='refresh']").onclick = () => refreshFromShotboard(state);
|
||||
root.querySelector("[data-act='clear']").onclick = () => {
|
||||
state.strokes = state.strokes.filter((s) => s.segment_id !== state.selectedSegmentId);
|
||||
state.selectedStrokeId = "";
|
||||
persist(node, state);
|
||||
renderTimeline(state);
|
||||
renderStrokeList(state);
|
||||
repaintCanvas(state);
|
||||
};
|
||||
root.querySelector("[data-act='publish']").onclick = (event) => {
|
||||
event.currentTarget.classList.toggle("on");
|
||||
persist(node, state);
|
||||
};
|
||||
root.querySelectorAll(".iamccs-ms-lane-label[data-track]").forEach((label) => {
|
||||
label.onclick = () => setActiveTrack(state, label.dataset.track);
|
||||
});
|
||||
state.trackSelect.onchange = () => setActiveTrack(state, state.trackSelect.value);
|
||||
for (const input of [state.strength, state.attention, state.radius, state.falloff, state.modeSelect, state.scopeSelect]) {
|
||||
input.onchange = () => persist(node, state);
|
||||
}
|
||||
setupDrawing(state);
|
||||
setupScrubbing(state);
|
||||
state.currentImage.onload = () => repaintCanvas(state);
|
||||
new ResizeObserver(() => {
|
||||
enforceNodeSize(node);
|
||||
repaintCanvas(state);
|
||||
}).observe(root);
|
||||
|
||||
const domWidget = node.addDOMWidget("CineMotionSketch", "iamccs_cine_motion_sketch", root, { serialize: false });
|
||||
domWidget.computeSize = () => [UI_W, UI_H];
|
||||
const originalOnResize = node.onResize;
|
||||
node.onResize = function (...args) {
|
||||
const out = originalOnResize?.apply(this, args);
|
||||
enforceNodeSize(node);
|
||||
setTimeout(() => repaintCanvas(state), 0);
|
||||
return out;
|
||||
};
|
||||
setTimeout(() => refreshFromShotboard(state), 80);
|
||||
state.autoSyncTimer = setInterval(() => {
|
||||
if (!document.body.contains(root) || node[STATE_KEY] !== state) {
|
||||
clearInterval(state.autoSyncTimer);
|
||||
return;
|
||||
}
|
||||
if (state.isDrawing) return;
|
||||
refreshFromShotboard(state, { onlyIfChanged: true });
|
||||
}, 700);
|
||||
}
|
||||
|
||||
function setupNode(node) {
|
||||
if (!node || (node.comfyClass || node.type) !== TARGET_CLASS || node[STATE_KEY]) return;
|
||||
if (typeof node.addDOMWidget !== "function") {
|
||||
setTimeout(() => setupNode(node), 150);
|
||||
return;
|
||||
}
|
||||
const jsonWidget = widget(node, "motion_sketch_data");
|
||||
if (jsonWidget) {
|
||||
jsonWidget.type = "hidden";
|
||||
jsonWidget.computeSize = () => [0, -4];
|
||||
}
|
||||
createUi(node);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "iamccs.cine.motion.sketch.v4",
|
||||
nodeCreated(node) {
|
||||
setupNode(node);
|
||||
},
|
||||
loadedGraphNode(node) {
|
||||
setupNode(node);
|
||||
},
|
||||
});
|
||||
+374
-146
@@ -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);
|
||||
|
||||
@@ -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 = "<span>" + text + "</span><span>" + extra + "</span>";
|
||||
return label;
|
||||
}
|
||||
function emotionRoutingValue(data) {
|
||||
const value = String(data?.settings?.emotion_routing || "clean_metadata");
|
||||
if (value === "index_tts_text_emotion") return "index_tts_text_emotion";
|
||||
if (value === "index_tts_character_tags") return "index_tts_character_tags";
|
||||
if (value === "chatterbox_v2_tokens") return "chatterbox_v2_tokens";
|
||||
if (value === "step_editx_tags") return "step_editx_tags";
|
||||
return "clean_metadata";
|
||||
}
|
||||
function cineAudioTextModeFromEmotionRouting(data) {
|
||||
const value = emotionRoutingValue(data);
|
||||
if (value === "step_editx_tags") return "tts_audio_suite_tags";
|
||||
if (value === "index_tts_text_emotion") return "index_tts_text_emotion";
|
||||
if (value === "index_tts_character_tags") return "index_tts_character_tags";
|
||||
if (value === "chatterbox_v2_tokens") return "chatterbox_v2_tokens";
|
||||
return "plain_dialogue";
|
||||
}
|
||||
function emotionRoutingLabel(value) {
|
||||
const key = String(value || "");
|
||||
if (key === "index_tts_text_emotion") return "IndexTTS text emotion";
|
||||
if (key === "index_tts_character_tags") return "IndexTTS character tags";
|
||||
if (key === "chatterbox_v2_tokens") return "ChatterBox v2 tokens";
|
||||
if (key === "step_editx_tags") return "Step EditX tags";
|
||||
return "Clean metadata";
|
||||
}
|
||||
function installDialogueLowZoomOverlay(node) {
|
||||
if (node) node._iamccsDialogueLowZoomOverlay = true;
|
||||
return;
|
||||
if (!node || node._iamccsDialogueLowZoomOverlay) return;
|
||||
const previous = node.onDrawForeground;
|
||||
node.onDrawForeground = function(ctx) {
|
||||
if (typeof previous === "function") previous.apply(this, arguments);
|
||||
const scale = Math.max(0.12, Number(app?.canvas?.ds?.scale || 1));
|
||||
if (!ctx || scale >= 0.62) return;
|
||||
let data = {};
|
||||
try { data = parseData(this); } catch {}
|
||||
const lines = Array.isArray(data.lines) ? data.lines : [];
|
||||
const speakers = Array.from(new Set(lines.map((line) => String(line?.speaker || "A").toUpperCase()))).filter(Boolean);
|
||||
const mode = modeFromData(data);
|
||||
const routing = emotionRoutingLabel(data.settings?.emotion_routing || "clean_metadata");
|
||||
const drawLines = [
|
||||
"Dialogue Tag Editor mini view",
|
||||
`${lines.length} lines / ${speakers.length || 1} speaker${speakers.length === 1 ? "" : "s"}`,
|
||||
mode === "tts_master_unico" ? "single master mode" : "A/B speaker stems mode",
|
||||
`emotion routing: ${routing}`,
|
||||
];
|
||||
const nodeW = Math.max(340, Number(this.size?.[0] || 420));
|
||||
const nodeH = Math.max(180, Number(this.size?.[1] || 240));
|
||||
const boost = Math.max(1.2, Math.min(3.4, 0.72 / scale));
|
||||
const pad = 12 * boost;
|
||||
const lineH = 18 * boost;
|
||||
const titleFont = Math.round(13 * boost);
|
||||
const bodyFont = Math.round(11 * boost);
|
||||
const w = Math.max(240, Math.min(nodeW - pad * 2, 700 * boost));
|
||||
const h = 34 * boost + drawLines.length * lineH;
|
||||
const x = pad;
|
||||
const y = Math.min(Math.max(64, 52 * boost), Math.max(46, nodeH - h - pad));
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.96;
|
||||
ctx.fillStyle = "rgba(7,17,18,.93)";
|
||||
ctx.strokeStyle = "rgba(143,208,204,.72)";
|
||||
ctx.lineWidth = Math.max(1.5, 1.2 * boost);
|
||||
if (typeof ctx.roundRect === "function") {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, w, h, 8 * boost);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.fillRect(x, y, w, h);
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
}
|
||||
ctx.fillStyle = "rgba(239,204,139,.95)";
|
||||
ctx.fillRect(x, y, Math.max(4, 3 * boost), h);
|
||||
ctx.fillStyle = "#F4D49E";
|
||||
ctx.font = `900 ${titleFont}px sans-serif`;
|
||||
ctx.fillText(drawLines[0], x + 12 * boost, y + 21 * boost);
|
||||
ctx.fillStyle = "#BFD7D5";
|
||||
ctx.font = `800 ${bodyFont}px sans-serif`;
|
||||
for (let i = 1; i < drawLines.length; i += 1) ctx.fillText(drawLines[i], x + 12 * boost, y + 21 * boost + i * lineH);
|
||||
ctx.restore();
|
||||
};
|
||||
node._iamccsDialogueLowZoomOverlay = true;
|
||||
}
|
||||
function install(node, reason = "install") {
|
||||
if (!isEditor(node) || node._iamccsDialogueTagEditorReady) return;
|
||||
ensureStyle();
|
||||
node._iamccsDialogueTagEditorReady = true;
|
||||
installDialogueLowZoomOverlay(node);
|
||||
["dialogue_data", "frame_rate", "speech_wpm", "min_line_seconds", "default_gap_seconds", "output_mode", "inline_edit_mode"].forEach((name) => hideWidget(widget(node, name)));
|
||||
let data = parseData(node);
|
||||
setTimeout(() => repairExistingDialogueRigLinks(node, reason), 0);
|
||||
let scriptText = linesToText(data);
|
||||
let zoom = Number(data.settings?.font_zoom || 1);
|
||||
let light = String(data.settings?.text_theme || "light_boxes") === "light_boxes";
|
||||
@@ -1124,12 +1456,30 @@ function install(node, reason = "install") {
|
||||
side.append(fieldLabel("Template", "writes example"), templateSelect);
|
||||
// By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
|
||||
side.append(fieldLabel("TTS Mode"));
|
||||
const ttsMode = select([["double_stem_ab", "Double stem A/B"], ["tts_master_unico", "TTS master unico"]], modeFromData(data));
|
||||
const ttsMode = select([["double_stem_ab", "Double stem A/B"], ["tts_master_unico", "TTS single master"]], modeFromData(data));
|
||||
ttsMode.onchange = () => { data.settings.tts_generation_mode = ttsMode.value; data.settings.output_mode = outputMode(ttsMode.value); save(); render(); };
|
||||
side.append(ttsMode, fieldLabel("Tag Mode"));
|
||||
const tagMode = select([["metadata_only", "Metadata only"], ["tts_audio_suite_inline_tags", "Inline tags"]], data.settings.inline_edit_mode || "metadata_only");
|
||||
tagMode.onchange = () => { data.settings.inline_edit_mode = tagMode.value; save(); };
|
||||
side.append(tagMode);
|
||||
side.append(fieldLabel("Emotion Routing", "working paths"));
|
||||
const emotionRoute = select([
|
||||
["clean_metadata", "Clean metadata"],
|
||||
["index_tts_text_emotion", "IndexTTS text emotion"],
|
||||
["index_tts_character_tags", "IndexTTS character tags"],
|
||||
["chatterbox_v2_tokens", "ChatterBox v2 tokens"],
|
||||
["step_editx_tags", "Step EditX tags"],
|
||||
], emotionRoutingValue(data));
|
||||
emotionRoute.title = "Clean metadata strips tags. IndexTTS text emotion sets emotion text on compatible IndexTTS rigs. IndexTTS character tags exports [Speaker:emotion]. ChatterBox v2 tokens are experimental. Step EditX tags are for the Step EditX inline path.";
|
||||
emotionRoute.onchange = () => {
|
||||
data.settings ||= {};
|
||||
data.settings.emotion_routing = emotionRoute.value;
|
||||
if (emotionRoute.value.startsWith("index_tts")) data.settings.tts_export_mode = "index";
|
||||
if (emotionRoute.value === "chatterbox_v2_tokens") data.settings.tts_export_mode = "chatterbox";
|
||||
save();
|
||||
render();
|
||||
};
|
||||
side.append(emotionRoute);
|
||||
const speakersTitle = document.createElement("h4");
|
||||
speakersTitle.textContent = "Speakers";
|
||||
side.append(speakersTitle);
|
||||
@@ -1161,7 +1511,7 @@ function install(node, reason = "install") {
|
||||
const ttsFormatStatus = document.createElement("div");
|
||||
ttsFormatStatus.className = "iamccs-overlap-hint";
|
||||
const refreshTtsFormatStatus = () => {
|
||||
ttsFormatStatus.textContent = "Selected TTS profile: " + activeTtsMode + " | CineAudioInfo export stays plain_dialogue.";
|
||||
ttsFormatStatus.textContent = "Selected TTS profile: " + activeTtsMode + " | Emotion routing: " + emotionRoutingLabel(emotionRoutingValue(data)) + " / " + cineAudioTextModeFromEmotionRouting(data) + ".";
|
||||
};
|
||||
let applyTtsModeToScript = null;
|
||||
const paintConvertButtons = (successMode = "") => {
|
||||
@@ -1251,7 +1601,7 @@ function install(node, reason = "install") {
|
||||
scriptArea.addEventListener("blur", persistScriptHeight);
|
||||
const statusText = () => {
|
||||
const overlapCount = (data.lines || []).filter((line) => Number(line.overlap_after || 0) > 0).length;
|
||||
return data.lines.length + " lines | " + (modeFromData(data) === "tts_master_unico" ? "TTS master unico" : "Double stem A/B") + " | overlap lines " + overlapCount;
|
||||
return data.lines.length + " lines | " + (modeFromData(data) === "tts_master_unico" ? "TTS single master" : "Double stem A/B") + " | overlap lines " + overlapCount;
|
||||
};
|
||||
const overlapValue = document.createElement("input");
|
||||
overlapValue.type = "number";
|
||||
@@ -1303,6 +1653,19 @@ function install(node, reason = "install") {
|
||||
renderCards(cardsGrid);
|
||||
scriptArea.focus();
|
||||
};
|
||||
const insertPerformanceAtCursor = (key, rawValue) => {
|
||||
const currentText = editorPlainText(scriptArea);
|
||||
const offset = caretOffset(scriptArea);
|
||||
const updated = insertPerformanceTagAtTaggedCursor(currentText, offset, key, rawValue, emotionRoutingValue(data));
|
||||
scriptText = updated.text;
|
||||
data = parseScript(scriptText, data);
|
||||
save();
|
||||
status.textContent = statusText();
|
||||
paintScriptEditor(false);
|
||||
setCaretOffset(scriptArea, updated.caret);
|
||||
renderCards(cardsGrid);
|
||||
scriptArea.focus();
|
||||
};
|
||||
scriptSection.append(fieldLabel("Dialogue Script", "editor colorato"), scriptArea);
|
||||
const cardsSection = document.createElement("div");
|
||||
cardsSection.className = "iamccs-dte-section fill";
|
||||
@@ -1341,9 +1704,12 @@ function install(node, reason = "install") {
|
||||
tags.className = "iamccs-dte-tags";
|
||||
// By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
|
||||
tags.append(
|
||||
tagPanel("Emotion", EMOTIONS, (name) => modelAwareTagToken("emotion", name, activeTtsMode), () => scriptArea),
|
||||
tagPanel("Style", STYLES, (name) => modelAwareTagToken("style", name, activeTtsMode), () => scriptArea),
|
||||
tagPanel("Inline", PARAS, (name) => name.startsWith("pause") ? " [" + name + "]" : " <" + name + ">", () => scriptArea)
|
||||
tagPanel("Emotion", EMOTIONS, (name) => modelAwareTagToken("emotion", name, activeTtsMode), () => scriptArea, (name) => insertPerformanceAtCursor("emotion", name)),
|
||||
tagPanel("Style", STYLES, (name) => modelAwareTagToken("style", name, activeTtsMode), () => scriptArea, (name) => insertPerformanceAtCursor("style", name)),
|
||||
tagPanel("Inline", PARAS, (name) => name.startsWith("pause") ? " [" + name + "]" : " <" + name + ">", () => scriptArea, (name) => {
|
||||
if (name.startsWith("pause")) insertAt(scriptArea, " [" + name + "] ");
|
||||
else insertPerformanceAtCursor("para", name);
|
||||
})
|
||||
);
|
||||
main.append(side, center, tags);
|
||||
const foot = document.createElement("div");
|
||||
@@ -1466,7 +1832,7 @@ function toolbarButtons(toolbar, target) {
|
||||
});
|
||||
}
|
||||
// By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com
|
||||
function tagPanel(title, values, makeToken, getFallbackTarget = null) {
|
||||
function tagPanel(title, values, makeToken, getFallbackTarget = null, onPick = null) {
|
||||
const panel = document.createElement("div");
|
||||
const h = document.createElement("h4");
|
||||
h.textContent = title;
|
||||
@@ -1475,6 +1841,10 @@ function tagPanel(title, values, makeToken, getFallbackTarget = null) {
|
||||
values.forEach((value) => {
|
||||
const btn = button(value, "");
|
||||
btn.onclick = () => {
|
||||
if (typeof onPick === "function") {
|
||||
onPick(value);
|
||||
return;
|
||||
}
|
||||
const active = document.activeElement;
|
||||
const focused = active?.tagName === "TEXTAREA" || active?.isContentEditable ? active : null;
|
||||
const fallback = typeof getFallbackTarget === "function" ? getFallbackTarget() : null;
|
||||
@@ -1553,7 +1923,10 @@ app.registerExtension({
|
||||
setup() {
|
||||
[300, 1200, 2500].forEach((delay) => setTimeout(() => {
|
||||
const nodes = Array.isArray(app?.graph?._nodes) ? app.graph._nodes : [];
|
||||
nodes.forEach((node) => install(node, "scan+" + delay));
|
||||
nodes.forEach((node) => {
|
||||
install(node, "scan+" + delay);
|
||||
if (isEditor(node)) repairExistingDialogueRigLinks(node, "scan+" + delay);
|
||||
});
|
||||
}, delay));
|
||||
},
|
||||
nodeCreated(node) { [0, 180, 600].forEach((delay) => setTimeout(() => install(node, "nodeCreated+" + delay), delay)); },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,11 +82,47 @@ function setTake(node, take) {
|
||||
setWidget(node, "active_take", safeTake);
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("iamccs:multigeneration-active-take", {
|
||||
detail: { nodeId: node?.id, activeTake: safeTake },
|
||||
detail: {
|
||||
nodeId: node?.id,
|
||||
activeTake: safeTake,
|
||||
timelineId: `T${String(safeTake).padStart(2, "0")}`,
|
||||
audioLane: `A${safeTake}`,
|
||||
source: "bridge",
|
||||
},
|
||||
}));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function queuePromptOnce() {
|
||||
if (typeof app?.queuePrompt === "function") {
|
||||
return await app.queuePrompt(0, 1);
|
||||
}
|
||||
const api = window?.comfyAPI?.api;
|
||||
if (typeof api?.queuePrompt === "function") {
|
||||
return await api.queuePrompt(0, 1);
|
||||
}
|
||||
throw new Error("ComfyUI queuePrompt API not available");
|
||||
}
|
||||
|
||||
async function queueSingleBackendSequence(node, count, statusEl = null) {
|
||||
const max = Math.max(1, Math.min(maxTakes(node), Math.round(Number(count) || 1)));
|
||||
setWidget(node, "take_source_mode", "auto_detect_multi_lanes");
|
||||
setWidget(node, "take_track_layout", "collapse_to_lane_1");
|
||||
for (let take = 1; take <= max; take += 1) {
|
||||
setTake(node, take);
|
||||
if (statusEl) statusEl.textContent = `Queueing T${String(take).padStart(2, "0")} / A${take} on the single backend (${take}/${max})...`;
|
||||
try { app.graph?.setDirtyCanvas?.(true, true); } catch {}
|
||||
await sleep(120);
|
||||
await queuePromptOnce();
|
||||
await sleep(220);
|
||||
}
|
||||
if (statusEl) statusEl.textContent = `Queued ${max} takes on one backend: ${Array.from({ length: max }, (_, i) => `T${String(i + 1).padStart(2, "0")}/A${i + 1}`).join(" -> ")}.`;
|
||||
}
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
@@ -306,7 +342,7 @@ function installBridgeUI(node, reason = "install") {
|
||||
const card = document.createElement("button");
|
||||
card.type = "button";
|
||||
card.className = `iamccs-mtb-take${take === active ? " is-active" : ""}`;
|
||||
card.innerHTML = `<strong>T${String(take).padStart(2, "0")}</strong><span>${take === active ? "prepared now" : "click to prepare"}</span>`;
|
||||
card.innerHTML = `<strong>T${String(take).padStart(2, "0")} / A${take}</strong><span>${take === active ? "prepared now" : "click to prepare"}</span>`;
|
||||
card.onclick = () => {
|
||||
setTake(node, take);
|
||||
render();
|
||||
@@ -335,11 +371,32 @@ function installBridgeUI(node, reason = "install") {
|
||||
setTake(node, 1);
|
||||
render();
|
||||
};
|
||||
actions.append(prepare, auto);
|
||||
const queueSeq = document.createElement("button");
|
||||
queueSeq.type = "button";
|
||||
queueSeq.textContent = "Queue Sequence / 1 Backend";
|
||||
queueSeq.title = "Queues T1/A1, T2/A2, etc. by changing this bridge active_take on the same single backend workflow.";
|
||||
actions.append(prepare, auto, queueSeq);
|
||||
|
||||
const ledger = document.createElement("div");
|
||||
ledger.className = "iamccs-mtb-ledger";
|
||||
ledger.textContent = `Active T${String(active).padStart(2, "0")} | ${chunkTemplate === "custom" ? customSeconds + "s" : chunkTemplate} | Auto reads BusOut indexed T lanes: T1-A1, T2-A2, T3-A3. Use TakePicker branches to queue multiple generations in one run.`;
|
||||
ledger.textContent = `Active T${String(active).padStart(2, "0")} / A${active} | ${chunkTemplate === "custom" ? customSeconds + "s" : chunkTemplate} | Contract: T1=A1, T2=A2, T3=A3. One backend only: queue sequence changes active take between queued prompts.`;
|
||||
queueSeq.onclick = async () => {
|
||||
queueSeq.disabled = true;
|
||||
queueSeq.classList.add("is-primary");
|
||||
try {
|
||||
const countMode = String(widget(node, "take_count_mode")?.value || "auto_from_audio");
|
||||
const desired = countMode === "fixed_take_count"
|
||||
? Math.max(1, Math.round(num(widget(node, "fixed_take_count")?.value, 3)))
|
||||
: maxTakes(node);
|
||||
await queueSingleBackendSequence(node, desired, ledger);
|
||||
} catch (err) {
|
||||
ledger.textContent = `Queue sequence failed: ${err?.message || err}`;
|
||||
console.warn("[IAMCCS MultiTimelineBridge UI] single-backend queue failed", err);
|
||||
} finally {
|
||||
queueSeq.disabled = false;
|
||||
queueSeq.classList.remove("is-primary");
|
||||
}
|
||||
};
|
||||
root.append(head, grid, takeRow, actions, ledger);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
const STYLE_ID = "iamccs-shotboard-video-editor-style-v3";
|
||||
const FIXED_SIZE = [1260, 940];
|
||||
|
||||
function nodeType(node) {
|
||||
return String(node?.type || node?.comfyClass || node?.constructor?.type || "");
|
||||
}
|
||||
function isEditorNode(node) {
|
||||
return nodeType(node) === "IAMCCS_ShotboardVideoEditor";
|
||||
}
|
||||
function widget(node, name) {
|
||||
return (node?.widgets || []).find((item) => item?.name === name);
|
||||
}
|
||||
function setWidget(node, name, value) {
|
||||
const item = widget(node, name);
|
||||
if (!item) return false;
|
||||
item.value = value;
|
||||
try { item.callback?.(value); } catch {}
|
||||
try { node.setDirtyCanvas?.(true, true); } catch {}
|
||||
try { app.graph?.setDirtyCanvas?.(true, true); } catch {}
|
||||
return true;
|
||||
}
|
||||
function hideWidget(item) {
|
||||
if (!item) return;
|
||||
item.hidden = true;
|
||||
item.type = "hidden";
|
||||
item.computeSize = () => [0, -4];
|
||||
item.draw = () => {};
|
||||
item.options = { ...(item.options || {}), hidden: true };
|
||||
if (item.inputEl) item.inputEl.style.display = "none";
|
||||
}
|
||||
function hideRawWidgets(node) {
|
||||
[
|
||||
"editor_mode", "selected_take", "take_order", "audio_policy", "fps_mode", "override_fps",
|
||||
"global_trim_in_seconds", "global_trim_out_seconds", "concat_plan_json", "clip_edits_json",
|
||||
"editor_manifest_json",
|
||||
].forEach((name) => hideWidget(widget(node, name)));
|
||||
}
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, Number(value) || 0));
|
||||
}
|
||||
function parseOrder(text) {
|
||||
const nums = String(text || "1,2").split(/[,;\s]+/).map((v) => Math.round(Number(v))).filter((v) => v >= 1 && v <= 8);
|
||||
return nums.length ? nums : [1, 2];
|
||||
}
|
||||
function formatTime(seconds, fps = 24) {
|
||||
const safe = Math.max(0, Number(seconds) || 0);
|
||||
const frame = Math.round(safe * fps);
|
||||
const s = Math.floor(safe % 60).toString().padStart(2, "0");
|
||||
const m = Math.floor(safe / 60).toString().padStart(2, "0");
|
||||
return `${m}:${s}.${String(frame % Math.round(fps)).padStart(2, "0")}`;
|
||||
}
|
||||
function flash(el, text) {
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.add("pulse");
|
||||
setTimeout(() => el.classList.remove("pulse"), 220);
|
||||
}
|
||||
function readClipEdits(node) {
|
||||
try { return JSON.parse(String(widget(node, "clip_edits_json")?.value || "{}")); } catch { return {}; }
|
||||
}
|
||||
function writeClipEdits(node, patch) {
|
||||
const current = readClipEdits(node);
|
||||
const next = { ...(current || {}), ...patch, updated_at: Date.now() };
|
||||
setWidget(node, "clip_edits_json", JSON.stringify(next, null, 2));
|
||||
}
|
||||
function ensureStyle() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
.iamccs-sve { box-sizing:border-box; width:100%; height:884px; padding:9px; border:1px solid rgba(244,212,158,.34); border-radius:8px; background:#071012; color:#ebffff; font:11px Inter,Arial,sans-serif; overflow:hidden; pointer-events:auto; }
|
||||
.iamccs-sve * { box-sizing:border-box; }
|
||||
.iamccs-sve button { min-height:28px; border-radius:5px; border:1px solid rgba(126,198,196,.52); background:linear-gradient(180deg,#24484d,#142e32); color:#efffff; cursor:pointer; font-weight:950; font-size:10px; padding:0 9px; }
|
||||
.iamccs-sve button.active,.iamccs-sve button.primary { background:linear-gradient(180deg,#f5d89c,#c5964e); color:#171207; border-color:#ffe3a4; }
|
||||
.iamccs-sve input,.iamccs-sve select { min-width:0; height:28px; border-radius:5px; border:1px solid rgba(94,161,161,.72); background:#061012; color:#efffff; font-size:10px; font-weight:850; padding:0 7px; }
|
||||
.iamccs-sve-top { display:grid; grid-template-columns:220px 1fr; grid-template-rows:42px 42px; gap:7px; height:91px; margin-bottom:7px; }
|
||||
.iamccs-sve-brand,.iamccs-sve-transport,.iamccs-sve-tools { border:1px solid rgba(255,255,255,.09); border-radius:7px; background:linear-gradient(180deg,#132326,#081112); overflow:hidden; }
|
||||
.iamccs-sve-brand { padding:8px 10px; grid-row:1 / 3; }
|
||||
.iamccs-sve-title { color:#fff0b8; font-size:13px; font-weight:950; }
|
||||
.iamccs-sve-sub { color:#8fb4b3; font-size:9px; font-weight:850; margin-top:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.iamccs-sve-transport,.iamccs-sve-tools { display:flex; align-items:center; justify-content:center; gap:6px; padding:6px; min-width:0; }
|
||||
.iamccs-sve-clock { min-width:104px; padding:5px 7px; border-radius:5px; background:#f3ffe9; border:2px solid #a7d59b; color:#0a7d23; font:12px Consolas,monospace; text-align:center; font-weight:950; }
|
||||
.iamccs-sve-sourcebar { height:38px; display:grid; grid-template-columns:70px 1fr 130px; align-items:center; gap:7px; padding:6px 8px; border:1px solid rgba(255,255,255,.09); border-radius:7px; background:#081112; margin-bottom:7px; }
|
||||
.iamccs-sve-sourcebar strong { color:#fff0b8; font-size:10px; }
|
||||
.iamccs-sve-takes { display:flex; gap:5px; overflow:hidden; }
|
||||
.iamccs-sve-take { min-width:50px; height:24px; padding:0 6px; font-size:9px; }
|
||||
.iamccs-sve-monitor-row { display:grid; grid-template-columns:1fr 1fr; gap:8px; height:260px; margin-bottom:7px; }
|
||||
.iamccs-sve.one-monitor .iamccs-sve-monitor-row { grid-template-columns:1fr; }
|
||||
.iamccs-sve.one-monitor .iamccs-sve-monitor.source { display:none; }
|
||||
.iamccs-sve-monitor { border:1px solid rgba(255,255,255,.10); border-radius:7px; background:linear-gradient(180deg,#0d1718,#030607); overflow:hidden; }
|
||||
.iamccs-sve-monitor-head { height:24px; display:flex; align-items:center; justify-content:space-between; padding:0 8px; background:linear-gradient(180deg,#15292b,#0d181a); border-bottom:1px solid rgba(255,255,255,.08); color:#ffe5a8; font-weight:950; font-size:9px; text-transform:uppercase; }
|
||||
.iamccs-sve-screen { height:216px; margin:9px; border-radius:6px; border:1px solid rgba(126,198,196,.28); background:radial-gradient(circle at 52% 45%,rgba(99,160,170,.18),transparent 32%),linear-gradient(135deg,#111b20,#06090b 52%,#141008); position:relative; overflow:hidden; }
|
||||
.iamccs-sve-safe { position:absolute; inset:15px 24px; border:1px dashed rgba(255,255,255,.18); border-radius:4px; }
|
||||
.iamccs-sve-screen-label { position:absolute; left:10px; bottom:8px; color:#eaffff; background:rgba(0,0,0,.50); border:1px solid rgba(255,255,255,.12); border-radius:4px; padding:4px 6px; font:10px Consolas,monospace; }
|
||||
.iamccs-sve-controls { display:grid; grid-template-columns:1fr 80px 1fr 1fr 70px; gap:7px; margin-bottom:7px; border:1px solid rgba(255,255,255,.08); border-radius:7px; padding:7px; background:#080f10; }
|
||||
.iamccs-sve-controls label { display:grid; gap:3px; min-width:0; color:#9db7b8; font-size:8px; text-transform:uppercase; font-weight:950; }
|
||||
.iamccs-sve-timeline { height:344px; border:1px solid rgba(255,255,255,.10); border-radius:8px; overflow:hidden; background:#05090a; }
|
||||
.iamccs-sve-ruler { height:34px; margin-left:116px; position:relative; border-bottom:1px solid rgba(255,255,255,.11); background:linear-gradient(180deg,#122022,#091113); cursor:ew-resize; user-select:none; }
|
||||
.iamccs-sve-ruler-tick { position:absolute; top:0; bottom:0; width:1px; background:rgba(255,255,255,.14); }
|
||||
.iamccs-sve-ruler-tick span { position:absolute; top:5px; left:4px; color:#b7d0cf; font-size:9px; font-weight:900; }
|
||||
.iamccs-sve-tracks { height:310px; overflow:auto; position:relative; }
|
||||
.iamccs-sve-playhead { position:absolute; top:0; bottom:0; width:2px; background:#ffe08d; z-index:20; box-shadow:0 0 10px rgba(255,224,141,.65); pointer-events:none; }
|
||||
.iamccs-sve-track { min-height:74px; display:grid; grid-template-columns:116px 1fr; border-bottom:1px solid rgba(255,255,255,.12); }
|
||||
.iamccs-sve-track.audio { min-height:68px; }
|
||||
.iamccs-sve-label { padding:7px; border-right:1px solid rgba(255,255,255,.10); background:linear-gradient(90deg,rgba(36,68,65,.55),rgba(9,17,18,.92)); }
|
||||
.iamccs-sve-label strong { color:#fff0b8; font-size:12px; display:block; }
|
||||
.iamccs-sve-chiprow { display:flex; gap:4px; margin-top:5px; flex-wrap:wrap; }
|
||||
.iamccs-sve-chip { height:18px; min-width:22px; border-radius:4px; border:1px solid rgba(126,198,196,.45); color:#dfffff; background:#102f34; font-size:8px; font-weight:950; display:flex; align-items:center; justify-content:center; cursor:pointer; }
|
||||
.iamccs-sve-chip.active { background:#f3d08d; color:#171207; border-color:#ffe3a4; }
|
||||
.iamccs-sve-lane { position:relative; min-width:980px; border-left:1px solid rgba(255,224,141,.18); background:repeating-linear-gradient(90deg,rgba(255,255,255,.07) 0 1px,transparent 1px 48px),linear-gradient(180deg,rgba(24,31,29,.50),rgba(8,10,11,.90)); }
|
||||
.iamccs-sve-track:nth-child(even) .iamccs-sve-lane { background:repeating-linear-gradient(90deg,rgba(255,255,255,.06) 0 1px,transparent 1px 48px),linear-gradient(180deg,rgba(30,22,28,.48),rgba(8,10,11,.92)); }
|
||||
.iamccs-sve-clip { position:absolute; top:10px; height:52px; border-radius:5px; border:1px solid rgba(255,224,160,.65); background:linear-gradient(180deg,#31677a,#16343f); box-shadow:inset 0 0 0 1px rgba(255,255,255,.08); overflow:hidden; cursor:grab; touch-action:none; }
|
||||
.iamccs-sve-clip.audio { height:46px; background:linear-gradient(180deg,#386d9b,#173b63); border-color:#b9dcff; }
|
||||
.iamccs-sve-clip.dragging { cursor:grabbing; box-shadow:0 0 0 2px rgba(255,224,141,.55),0 8px 20px rgba(0,0,0,.35); z-index:10; }
|
||||
.iamccs-sve-clip-title { position:absolute; left:11px; top:5px; z-index:2; color:#fff1ba; font-weight:950; font-size:10px; text-shadow:0 1px 2px #000; pointer-events:none; }
|
||||
.iamccs-sve-handle { position:absolute; top:0; bottom:0; width:9px; background:#ffe08d; z-index:3; cursor:ew-resize; }
|
||||
.iamccs-sve-handle.left { left:0; }
|
||||
.iamccs-sve-handle.right { right:0; }
|
||||
.iamccs-sve-wave { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; }
|
||||
.iamccs-sve-clip:not(.audio)::before { content:""; position:absolute; inset:0; background:repeating-linear-gradient(90deg, rgba(255,255,255,.12) 0 2px, transparent 2px 9px, rgba(0,0,0,.25) 9px 34px), linear-gradient(90deg, rgba(93,151,164,.34), rgba(23,54,64,.78), rgba(190,145,78,.28)); opacity:.92; }
|
||||
.iamccs-sve-clip:not(.audio)::after { content:""; position:absolute; left:0; right:0; top:0; height:7px; background:repeating-linear-gradient(90deg,#071012 0 6px,#e8d8a3 6px 9px); opacity:.75; }
|
||||
.iamccs-sve-actions { display:flex; gap:6px; align-items:center; justify-content:flex-end; margin-top:7px; }
|
||||
.iamccs-sve-ledger { margin-top:6px; padding:6px 8px; border:1px solid rgba(255,255,255,.08); border-radius:5px; background:#030708; color:#b8fff1; font:10px Consolas,monospace; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.iamccs-sve-ledger.pulse { color:#171207; background:#f3d08d; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
function drawWave(canvas, seed = 1) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
const w = Math.max(1, Math.round(rect.width * dpr));
|
||||
const h = Math.max(1, Math.round(rect.height * dpr));
|
||||
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const mid = h * 0.56;
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
||||
grad.addColorStop(0, "rgba(235,249,255,.98)");
|
||||
grad.addColorStop(1, "rgba(130,200,232,.82)");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, mid);
|
||||
for (let x = 0; x <= w; x += 2) {
|
||||
const t = (x / Math.max(1, w)) * 22 + seed;
|
||||
const amp = (Math.sin(t * 1.7) * .22 + Math.sin(t * 4.6) * .16 + Math.sin(t * 11.3) * .07 + .45);
|
||||
const env = .28 + .72 * Math.abs(Math.sin(t * .31 + seed));
|
||||
ctx.lineTo(x, mid - amp * env * h * .38);
|
||||
}
|
||||
for (let x = w; x >= 0; x -= 2) {
|
||||
const t = (x / Math.max(1, w)) * 22 + seed;
|
||||
const amp = (Math.sin(t * 1.7) * .22 + Math.sin(t * 4.6) * .16 + Math.sin(t * 11.3) * .07 + .45);
|
||||
const env = .28 + .72 * Math.abs(Math.sin(t * .31 + seed));
|
||||
ctx.lineTo(x, mid + amp * env * h * .34);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "rgba(255,238,181,.72)";
|
||||
ctx.lineWidth = Math.max(1, dpr);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, mid);
|
||||
ctx.lineTo(w, mid);
|
||||
ctx.stroke();
|
||||
}
|
||||
function installFixedNode(node) {
|
||||
node.size = [...FIXED_SIZE];
|
||||
node.resizable = false;
|
||||
const originalOnResize = node.onResize;
|
||||
node.onResize = function () {
|
||||
this.size = [...FIXED_SIZE];
|
||||
try { originalOnResize?.apply(this, arguments); } catch {}
|
||||
};
|
||||
}
|
||||
function installEditorUI(node, reason = "install") {
|
||||
if (!isEditorNode(node) || node._iamccsShotboardVideoEditorReady || typeof node.addDOMWidget !== "function") return;
|
||||
node._iamccsShotboardVideoEditorReady = true;
|
||||
ensureStyle();
|
||||
hideRawWidgets(node);
|
||||
installFixedNode(node);
|
||||
const root = document.createElement("div");
|
||||
root.className = "iamccs-sve";
|
||||
const state = {
|
||||
playhead: 0,
|
||||
duration: 24,
|
||||
oneMonitor: false,
|
||||
clipMap: {},
|
||||
buttons: {},
|
||||
};
|
||||
const clipKey = (type, take, pos) => `${type}_${take}_${pos}`;
|
||||
function initClipState(order, perTake) {
|
||||
order.forEach((take, pos) => {
|
||||
["v", "a"].forEach((type) => {
|
||||
const key = clipKey(type, take, pos);
|
||||
if (!state.clipMap[key]) state.clipMap[key] = { start: pos * perTake, length: perTake };
|
||||
});
|
||||
});
|
||||
}
|
||||
function writeEditorState(node) {
|
||||
writeClipEdits(node, {
|
||||
ui_state: {
|
||||
playhead: state.playhead,
|
||||
oneMonitor: state.oneMonitor,
|
||||
clips: state.clipMap,
|
||||
},
|
||||
});
|
||||
}
|
||||
function render() {
|
||||
hideRawWidgets(node);
|
||||
installFixedNode(node);
|
||||
root.classList.toggle("one-monitor", Boolean(state.oneMonitor));
|
||||
const mode = String(widget(node, "editor_mode")?.value || "assemble_sequence");
|
||||
const selected = clamp(Math.round(Number(widget(node, "selected_take")?.value || 1)), 1, 8);
|
||||
const orderText = String(widget(node, "take_order")?.value || "1,2");
|
||||
const order = parseOrder(orderText);
|
||||
const fps = Number(widget(node, "override_fps")?.value || 24);
|
||||
const perTake = 8;
|
||||
initClipState(order, perTake);
|
||||
state.duration = Math.max(12, ...Object.values(state.clipMap).map((clip) => Number(clip.start || 0) + Number(clip.length || perTake)));
|
||||
state.playhead = clamp(state.playhead, 0, state.duration);
|
||||
root.innerHTML = "";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "iamccs-sve-top";
|
||||
const brand = document.createElement("div");
|
||||
brand.className = "iamccs-sve-brand";
|
||||
brand.innerHTML = `<div class="iamccs-sve-title">Shotboard Video Editor</div><div class="iamccs-sve-sub">timeline editor / monitor / T-A takes</div>`;
|
||||
const transport = document.createElement("div");
|
||||
transport.className = "iamccs-sve-transport";
|
||||
const ledger = document.createElement("div");
|
||||
ledger.className = "iamccs-sve-ledger";
|
||||
const clock = document.createElement("div");
|
||||
clock.className = "iamccs-sve-clock";
|
||||
const updateClock = () => { clock.textContent = formatTime(state.playhead, fps); };
|
||||
updateClock();
|
||||
[
|
||||
["|<", () => { state.playhead = 0; updatePlayhead(); flash(ledger, "Go to start"); }],
|
||||
["<<", () => { state.playhead = Math.max(0, state.playhead - .5); updatePlayhead(); flash(ledger, "Step back"); }],
|
||||
["Play", (btn) => { btn.classList.toggle("active"); flash(ledger, btn.classList.contains("active") ? "Playback armed" : "Playback stopped"); }],
|
||||
[">>", () => { state.playhead = Math.min(state.duration, state.playhead + .5); updatePlayhead(); flash(ledger, "Step forward"); }],
|
||||
[">|", () => { state.playhead = state.duration; updatePlayhead(); flash(ledger, "Go to end"); }],
|
||||
].forEach(([label, cb]) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = label;
|
||||
if (label === "Play") b.className = "primary";
|
||||
b.onclick = () => cb(b);
|
||||
transport.appendChild(b);
|
||||
});
|
||||
transport.appendChild(clock);
|
||||
const tools = document.createElement("div");
|
||||
tools.className = "iamccs-sve-tools";
|
||||
[["Cut", "cut"], ["Trim", "trim"], ["Razor", "razor"], ["Snap", "snap"], ["1 Monitor", "oneMonitor"]].forEach(([label, key]) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = label;
|
||||
b.className = state.buttons[key] ? "active" : "";
|
||||
b.onclick = () => {
|
||||
state.buttons[key] = !state.buttons[key];
|
||||
if (key === "oneMonitor") state.oneMonitor = !state.oneMonitor;
|
||||
b.classList.toggle("active", Boolean(state.buttons[key]));
|
||||
writeEditorState(node);
|
||||
flash(ledger, `${label}: ${state.buttons[key] ? "ON" : "OFF"}`);
|
||||
if (key === "oneMonitor") render();
|
||||
};
|
||||
tools.appendChild(b);
|
||||
});
|
||||
top.append(brand, transport, tools);
|
||||
|
||||
const sourcebar = document.createElement("div");
|
||||
sourcebar.className = "iamccs-sve-sourcebar";
|
||||
sourcebar.innerHTML = `<strong>T/A TAKES</strong>`;
|
||||
const takeRow = document.createElement("div");
|
||||
takeRow.className = "iamccs-sve-takes";
|
||||
for (let take = 1; take <= 8; take += 1) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = `iamccs-sve-take${take === selected ? " active" : ""}`;
|
||||
b.textContent = `T${take}/A${take}`;
|
||||
b.onclick = () => {
|
||||
setWidget(node, "selected_take", take);
|
||||
setWidget(node, "editor_mode", "preview_selected_take");
|
||||
flash(ledger, `Selected T${take}/A${take}`);
|
||||
render();
|
||||
};
|
||||
takeRow.appendChild(b);
|
||||
}
|
||||
const monitorToggle = document.createElement("button");
|
||||
monitorToggle.type = "button";
|
||||
monitorToggle.textContent = state.oneMonitor ? "Two Monitors" : "One Monitor";
|
||||
monitorToggle.onclick = () => { state.oneMonitor = !state.oneMonitor; render(); };
|
||||
sourcebar.append(takeRow, monitorToggle);
|
||||
|
||||
const monitors = document.createElement("div");
|
||||
monitors.className = "iamccs-sve-monitor-row";
|
||||
const makeMonitor = (label, right, cls = "") => {
|
||||
const m = document.createElement("div");
|
||||
m.className = `iamccs-sve-monitor ${cls}`;
|
||||
m.innerHTML = `<div class="iamccs-sve-monitor-head"><span>${label}</span><span>${right}</span></div><div class="iamccs-sve-screen"><div class="iamccs-sve-safe"></div><div class="iamccs-sve-screen-label" data-monitor-label>${label}: ${mode === "preview_selected_take" ? `T${selected}/A${selected}` : order.join(" -> ")}</div></div>`;
|
||||
return m;
|
||||
};
|
||||
monitors.append(makeMonitor("Source Monitor", `T${selected}/A${selected}`, "source"), makeMonitor("Program Monitor", formatTime(state.playhead, fps), "program"));
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "iamccs-sve-controls";
|
||||
const labelWrap = (label, child) => { const w = document.createElement("label"); w.textContent = label; w.appendChild(child); return w; };
|
||||
const modeSelect = document.createElement("select");
|
||||
[["assemble_sequence", "Assemble sequence"], ["preview_selected_take", "Manual selected take"]].forEach(([v, l]) => { const o = document.createElement("option"); o.value = v; o.textContent = l; modeSelect.appendChild(o); });
|
||||
modeSelect.value = mode;
|
||||
modeSelect.onchange = () => { setWidget(node, "editor_mode", modeSelect.value); render(); };
|
||||
const selectedInput = document.createElement("input");
|
||||
selectedInput.type = "number"; selectedInput.min = "1"; selectedInput.max = "8"; selectedInput.value = String(selected);
|
||||
selectedInput.onchange = () => { setWidget(node, "selected_take", clamp(selectedInput.value, 1, 8)); render(); };
|
||||
const orderInput = document.createElement("input");
|
||||
orderInput.value = orderText;
|
||||
orderInput.onchange = () => { setWidget(node, "take_order", orderInput.value || "1,2"); render(); };
|
||||
const audioSelect = document.createElement("select");
|
||||
[["concat_clip_audio", "Concat clip audio"], ["use_master_audio", "Use master audio"], ["first_selected_audio", "Selected clip audio"], ["mix_editor_audio_tracks", "Mix editor audio tracks"], ["concat_editor_audio_tracks", "Concat editor audio tracks"], ["silent", "Silent"]].forEach(([v, l]) => { const o = document.createElement("option"); o.value = v; o.textContent = l; audioSelect.appendChild(o); });
|
||||
audioSelect.value = String(widget(node, "audio_policy")?.value || "concat_clip_audio");
|
||||
audioSelect.onchange = () => { setWidget(node, "audio_policy", audioSelect.value); flash(ledger, `Audio policy: ${audioSelect.value}`); };
|
||||
const fpsInput = document.createElement("input");
|
||||
fpsInput.type = "number"; fpsInput.step = "0.01"; fpsInput.value = String(fps);
|
||||
fpsInput.onchange = () => setWidget(node, "override_fps", Math.max(1, Number(fpsInput.value || 24)));
|
||||
controls.append(labelWrap("Mode", modeSelect), labelWrap("T/A", selectedInput), labelWrap("Order", orderInput), labelWrap("Audio", audioSelect), labelWrap("FPS", fpsInput));
|
||||
|
||||
const timeline = document.createElement("div");
|
||||
timeline.className = "iamccs-sve-timeline";
|
||||
const ruler = document.createElement("div");
|
||||
ruler.className = "iamccs-sve-ruler";
|
||||
for (let sec = 0; sec <= state.duration; sec += 1) {
|
||||
const tick = document.createElement("div");
|
||||
tick.className = "iamccs-sve-ruler-tick";
|
||||
tick.style.left = `${(sec / state.duration) * 100}%`;
|
||||
if (sec % 2 === 0) tick.innerHTML = `<span>${sec}s<br>${Math.round(sec * fps)}f</span>`;
|
||||
ruler.appendChild(tick);
|
||||
}
|
||||
const tracks = document.createElement("div");
|
||||
tracks.className = "iamccs-sve-tracks";
|
||||
const playhead = document.createElement("div");
|
||||
playhead.className = "iamccs-sve-playhead";
|
||||
const updatePlayhead = () => {
|
||||
const width = tracks.querySelector(".iamccs-sve-lane")?.getBoundingClientRect().width || 980;
|
||||
playhead.style.left = `${116 + (state.playhead / state.duration) * width}px`;
|
||||
updateClock();
|
||||
root.querySelectorAll("[data-monitor-label]").forEach((el) => { el.textContent = `${el.textContent.split(":")[0]}: scrub ${formatTime(state.playhead, fps)} / ${mode === "preview_selected_take" ? `T${selected}/A${selected}` : order.join(" -> ")}`; });
|
||||
};
|
||||
const scrub = (ev) => {
|
||||
const box = ruler.getBoundingClientRect();
|
||||
state.playhead = clamp(((ev.clientX - box.left) / Math.max(1, box.width)) * state.duration, 0, state.duration);
|
||||
updatePlayhead();
|
||||
};
|
||||
ruler.onpointerdown = (ev) => {
|
||||
ev.preventDefault();
|
||||
scrub(ev);
|
||||
const move = (e) => scrub(e);
|
||||
const up = () => { window.removeEventListener("pointermove", move, true); window.removeEventListener("pointerup", up, true); writeEditorState(node); };
|
||||
window.addEventListener("pointermove", move, true);
|
||||
window.addEventListener("pointerup", up, true);
|
||||
};
|
||||
tracks.appendChild(playhead);
|
||||
const secondsToPct = (seconds) => `${(seconds / state.duration) * 100}%`;
|
||||
const clipDrag = (clip, key, lane, mode, pointerEvent) => {
|
||||
const startBox = lane.getBoundingClientRect();
|
||||
const startX = pointerEvent.clientX;
|
||||
const original = { ...state.clipMap[key] };
|
||||
clip.classList.add("dragging");
|
||||
const onMove = (ev) => {
|
||||
ev.preventDefault();
|
||||
const delta = ((ev.clientX - startX) / Math.max(1, startBox.width)) * state.duration;
|
||||
if (mode === "left") {
|
||||
const nextStart = clamp(original.start + delta, 0, original.start + original.length - .25);
|
||||
state.clipMap[key].start = nextStart;
|
||||
state.clipMap[key].length = Math.max(.25, original.length + (original.start - nextStart));
|
||||
} else if (mode === "right") {
|
||||
state.clipMap[key].length = Math.max(.25, original.length + delta);
|
||||
} else {
|
||||
state.clipMap[key].start = clamp(original.start + delta, 0, Math.max(0, state.duration - original.length));
|
||||
}
|
||||
clip.style.left = secondsToPct(state.clipMap[key].start);
|
||||
clip.style.width = secondsToPct(state.clipMap[key].length);
|
||||
};
|
||||
const onUp = () => {
|
||||
clip.classList.remove("dragging");
|
||||
window.removeEventListener("pointermove", onMove, true);
|
||||
window.removeEventListener("pointerup", onUp, true);
|
||||
writeEditorState(node);
|
||||
flash(ledger, `${key} updated ${state.clipMap[key].start.toFixed(2)}s + ${state.clipMap[key].length.toFixed(2)}s`);
|
||||
};
|
||||
window.addEventListener("pointermove", onMove, true);
|
||||
window.addEventListener("pointerup", onUp, true);
|
||||
};
|
||||
const makeTrack = (name, type, trackNo) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = `iamccs-sve-track ${type}`;
|
||||
const label = document.createElement("div");
|
||||
label.className = "iamccs-sve-label";
|
||||
label.innerHTML = `<strong>${name}</strong><div class="iamccs-sve-chiprow"></div>`;
|
||||
const chipRow = label.querySelector(".iamccs-sve-chiprow");
|
||||
["M", "S", "L"].forEach((txt) => {
|
||||
const c = document.createElement("span");
|
||||
c.className = "iamccs-sve-chip";
|
||||
c.textContent = txt;
|
||||
c.onclick = () => { c.classList.toggle("active"); flash(ledger, `${name} ${txt}: ${c.classList.contains("active") ? "ON" : "OFF"}`); };
|
||||
chipRow.appendChild(c);
|
||||
});
|
||||
const lane = document.createElement("div");
|
||||
lane.className = "iamccs-sve-lane";
|
||||
order.forEach((take, pos) => {
|
||||
if (type === "video" && trackNo === 2 && pos % 2 === 0) return;
|
||||
if (type === "video" && trackNo === 1 && pos % 2 === 1) return;
|
||||
if (type === "audio" && trackNo > 1 && take !== trackNo) return;
|
||||
const key = clipKey(type === "audio" ? "a" : "v", take, pos);
|
||||
const clipState = state.clipMap[key] || { start: pos * perTake, length: perTake };
|
||||
const clip = document.createElement("div");
|
||||
clip.className = `iamccs-sve-clip${type === "audio" ? " audio" : ""}`;
|
||||
clip.style.left = secondsToPct(clipState.start);
|
||||
clip.style.width = secondsToPct(clipState.length);
|
||||
clip.innerHTML = `<div class="iamccs-sve-handle left"></div><div class="iamccs-sve-handle right"></div>${type === "audio" ? '<canvas class="iamccs-sve-wave"></canvas>' : ""}<div class="iamccs-sve-clip-title">${type === "audio" ? `A${take}` : `T${String(take).padStart(2, "0")}`} ${type === "audio" ? "audio" : "video"}</div>`;
|
||||
clip.onpointerdown = (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const isLeft = ev.target?.classList?.contains("left");
|
||||
const isRight = ev.target?.classList?.contains("right");
|
||||
clipDrag(clip, key, lane, isLeft ? "left" : isRight ? "right" : "move", ev);
|
||||
};
|
||||
lane.appendChild(clip);
|
||||
if (type === "audio") requestAnimationFrame(() => drawWave(clip.querySelector("canvas"), take + pos * 2 + trackNo));
|
||||
});
|
||||
row.append(label, lane);
|
||||
return row;
|
||||
};
|
||||
tracks.append(makeTrack("V1", "video", 1), makeTrack("V2", "video", 2), makeTrack("A1", "audio", 1), makeTrack("A2", "audio", 2), makeTrack("A3", "audio", 3));
|
||||
timeline.append(ruler, tracks);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "iamccs-sve-actions";
|
||||
[["Assemble Hard Cut", () => { setWidget(node, "editor_mode", "assemble_sequence"); flash(ledger, "Assemble mode"); render(); }], [`Manual T${selected}/A${selected}`, () => { setWidget(node, "editor_mode", "preview_selected_take"); flash(ledger, `Manual T${selected}/A${selected}`); render(); }], ["Write Edit Metadata", () => { writeEditorState(node); flash(ledger, "Edit metadata written"); }]].forEach(([label, cb], idx) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = label;
|
||||
if ((idx === 0 && mode === "assemble_sequence") || (idx === 1 && mode === "preview_selected_take")) b.className = "primary";
|
||||
b.onclick = cb;
|
||||
actions.appendChild(b);
|
||||
});
|
||||
ledger.textContent = "Ready. Drag clip center to move; drag yellow handles to trim. Ruler scrubs playhead.";
|
||||
root.append(top, sourcebar, monitors, controls, timeline, actions, ledger);
|
||||
requestAnimationFrame(updatePlayhead);
|
||||
}
|
||||
render();
|
||||
const uiWidget = node.addDOMWidget("Shotboard Video Editor", "iamccs_shotboard_video_editor_ui", root, { serialize: false });
|
||||
uiWidget.computeSize = () => [FIXED_SIZE[0] - 24, 900];
|
||||
console.info("[IAMCCS Shotboard Video Editor UI] interactive NLE installed", { nodeId: node?.id, reason });
|
||||
}
|
||||
app.registerExtension({
|
||||
name: "IAMCCS.ShotboardVideoEditorUI.NLEInteractive",
|
||||
setup() {
|
||||
[700, 1800, 3600].forEach((delay) => setTimeout(() => {
|
||||
const nodes = Array.isArray(app?.graph?._nodes) ? app.graph._nodes : [];
|
||||
nodes.forEach((node) => installEditorUI(node, `scan+${delay}`));
|
||||
}, delay));
|
||||
},
|
||||
nodeCreated(node) { [0, 250, 900].forEach((delay) => setTimeout(() => installEditorUI(node, `nodeCreated+${delay}`), delay)); },
|
||||
loadedGraphNode(node) { [0, 250, 900].forEach((delay) => setTimeout(() => installEditorUI(node, `loadedGraphNode+${delay}`), delay)); },
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== "IAMCCS_ShotboardVideoEditor") return;
|
||||
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
originalOnNodeCreated?.apply(this, arguments);
|
||||
setTimeout(() => installEditorUI(this, "prototype.onNodeCreated"), 0);
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user