From 105532a8ab6ef322f9d141e3769eeebf8382b43c Mon Sep 17 00:00:00 2001 From: IAMCCS Date: Mon, 27 Apr 2026 08:00:07 +0200 Subject: [PATCH] Updated IAMCCS-nodes to version 1.4.2 --- README.md | 6 +- __init__.py | 172 ++ backup/20260420_auimg2vid_goal1/__init__.py | 745 +++++ .../20260420_auimg2vid_goal1/backup_note.txt | 1 + ...260424_073005.settings_preview_sync.bak.js | 560 ++++ ...i.20260422_explicit_segment_presets.bak.js | 577 ++++ ...ec_ui.20260422_load_deserialize_fix.bak.js | 522 ++++ ..._ui.20260422_loop_parity_stage2_fix.bak.js | 609 ++++ ..._ui.20260422_planner_visibility_fix.bak.js | 577 ++++ ...odes_exec_ui.20260422_save_metadata.bak.js | 577 ++++ ...260422_section_button_style_refresh.bak.js | 602 ++++ ...es_exec_ui.20260422_segment_overlay.bak.js | 576 ++++ ...0260424_073005.explicit_preset_sync.bak.js | 640 ++++ ...e_ui.20260420_bank_widget_order_fix.bak.js | 1607 ++++++++++ ..._cleanup.20260418_draft_quota_retry.bak.js | 188 ++ ...st_cleanup.20260419_prototype_guard.bak.js | 366 +++ iamccs_audio_extender.py | 1 + iamccs_auimg2vid_goal1.py | 1647 +++++++++++ iamccs_flux_klein_multigen.py | 25 +- iamccs_lazy_switch.py | 65 + iamccs_ltx2_cinematic_flf.py | 2615 +++++++++++++++++ iamccs_ltx2_extension_module.py | 39 +- iamccs_ltx2_segment_queue.py | 14 +- iamccs_ltx2_tools.py | 180 +- iamccs_supernode_modular.py | 232 ++ iamccs_supernodes_backend_panels.py | 71 + iamccs_supernodes_exec.py | 25 + iamccs_supernodes_linx.py | 136 + iamccs_supernodes_second_stage.py | 214 ++ iamccs_wan_long_length.py | 24 +- iamccs_wan_lora_hooks.py | 734 +++++ iamccs_wan_lora_runtime_bridge.py | 216 ++ iamccs_wan_lora_schedule.py | 267 +- iamccs_wan_lora_stack.py | 46 + iamccs_wan_svipro_motion.py | 76 +- version.json | 2 +- web/iamccs_cinematic_builders_ui.js | 66 + web/iamccs_cinematic_timeline_editor.js | 682 +++++ web/iamccs_ltx2_time_length_sync.js | 272 +- web/iamccs_supernodes_exec_ui.js | 808 +++++ web/iamccs_wan_lora_schedule_ui.js | 436 ++- web/iamccs_workflow_persist_cleanup.js | 249 ++ 42 files changed, 17318 insertions(+), 149 deletions(-) create mode 100644 backup/20260420_auimg2vid_goal1/__init__.py create mode 100644 backup/20260420_auimg2vid_goal1/backup_note.txt create mode 100644 backup/web_disabled/iamccs_ltx2_time_length_sync.20260424_073005.settings_preview_sync.bak.js create mode 100644 backup/web_disabled/iamccs_supernodes_exec_ui.20260422_explicit_segment_presets.bak.js create mode 100644 backup/web_disabled/iamccs_supernodes_exec_ui.20260422_load_deserialize_fix.bak.js create mode 100644 backup/web_disabled/iamccs_supernodes_exec_ui.20260422_loop_parity_stage2_fix.bak.js create mode 100644 backup/web_disabled/iamccs_supernodes_exec_ui.20260422_planner_visibility_fix.bak.js create mode 100644 backup/web_disabled/iamccs_supernodes_exec_ui.20260422_save_metadata.bak.js create mode 100644 backup/web_disabled/iamccs_supernodes_exec_ui.20260422_section_button_style_refresh.bak.js create mode 100644 backup/web_disabled/iamccs_supernodes_exec_ui.20260422_segment_overlay.bak.js create mode 100644 backup/web_disabled/iamccs_supernodes_exec_ui.20260424_073005.explicit_preset_sync.bak.js create mode 100644 backup/web_disabled/iamccs_wan_lora_schedule_ui.20260420_bank_widget_order_fix.bak.js create mode 100644 backup/web_disabled/iamccs_workflow_persist_cleanup.20260418_draft_quota_retry.bak.js create mode 100644 backup/web_disabled/iamccs_workflow_persist_cleanup.20260419_prototype_guard.bak.js create mode 100644 iamccs_auimg2vid_goal1.py create mode 100644 iamccs_lazy_switch.py create mode 100644 iamccs_ltx2_cinematic_flf.py create mode 100644 iamccs_supernode_modular.py create mode 100644 iamccs_supernodes_backend_panels.py create mode 100644 iamccs_supernodes_exec.py create mode 100644 iamccs_supernodes_linx.py create mode 100644 iamccs_supernodes_second_stage.py create mode 100644 iamccs_wan_lora_hooks.py create mode 100644 iamccs_wan_lora_runtime_bridge.py create mode 100644 web/iamccs_cinematic_builders_ui.js create mode 100644 web/iamccs_cinematic_timeline_editor.js create mode 100644 web/iamccs_supernodes_exec_ui.js diff --git a/README.md b/README.md index da8e8b0..f6df270 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,11 @@ ### Category: ComfyUI Custom Nodes ### Main Feature: Fix for LoRA loading in native WANAnimate workflows + general nodes 4 ComfyUI -Versuin: 1.4.0 +Version: 1.4.2 + +## πŸ†• Sdded new LTX-2.3 nodes for v2v, au+img2vid (instructions: patreon.com/IAMCCS) + +Version: 1.4.0 ## πŸ†• Dataset Creation Workflow Companions diff --git a/__init__.py b/__init__.py index c69fed7..925904a 100644 --- a/__init__.py +++ b/__init__.py @@ -22,6 +22,18 @@ from .iamccs_wan_lora_stack_simple import ( from .iamccs_wan_lora_schedule import ( IAMCCS_WanLoRASchedule, ) +from .iamccs_wan_lora_hooks import ( + IAMCCS_WanLoRAHookSchedule, + IAMCCS_ApplyLoRAHooksToConditioning, + IAMCCS_ApplyScheduledWanLoRAFromConditioning, + IAMCCS_BuildScheduledWanModelBank, + IAMCCS_SelectScheduledWanModelFromConditioning, + IAMCCS_SelectScheduledWanModelPairFromConditioning, +) +from .iamccs_wan_lora_runtime_bridge import ( + IAMCCS_WanLoRARuntimeBridge, + IAMCCS_ModelWithLoRA_RuntimeBridge, +) from .iamccs_ltx2_lora_stack import ( IAMCCS_LTX2_LoRAStack, @@ -46,6 +58,8 @@ from .iamccs_ltx2_tools import ( IAMCCS_LTX2_ImageBatchPadReflect, IAMCCS_LTX2_ImageBatchCropByPad, IAMCCS_SegmentPlanner, + IAMCCS_SegmentPlannerSettings, + IAMCCS_SegmentPlannerLinked, IAMCCS_SegmentPlanFromPlanner, IAMCCS_SourceRangeFromSegmentPlan, IAMCCS_TwoSegmentPlanner, @@ -72,6 +86,21 @@ from .iamccs_ltx2_extension_module import ( IAMCCS_LTX2_FirstLastLatentControl_Pro, ) +from .iamccs_ltx2_cinematic_flf import ( + IAMCCS_LTX2_CinematicShotPlanner, + IAMCCS_LTX2_CinematicRefLatentControl, + IAMCCS_LTX2_AudioPromptDirector, + IAMCCS_LTX2_CinematicPromptRelayAdapter, + IAMCCS_LTX2_CinematicPromptComposer, + IAMCCS_LTX2_CinematicShotLineBuilder, + IAMCCS_LTX2_CinematicV2VTimelineLineBuilder, + IAMCCS_LTX2_CinematicLineStacker, + IAMCCS_LTX2_CinematicMultiGenPlanner, + IAMCCS_LTX2_CinematicShotAudioSelector, + IAMCCS_LTX2_CinematicV2VTimelinePlanner, + IAMCCS_LTX2_CinematicV2VAssetSelector, +) + from .iamccs_ltx2_temporal_overlap_samplers import ( IAMCCS_LTX2_ConditionNextLatentWithPrevOverlap, IAMCCS_LTX2_InitLatentSampler, @@ -132,6 +161,10 @@ from .iamccs_multiswitch import ( IAMCCS_MultiSwitch, ) +from .iamccs_lazy_switch import ( + IAMCCS_LazyAnySwitch, +) + from .iamccs_hw_supporter import ( IAMCCS_HwSupporter, IAMCCS_HwSupporterAny, @@ -202,12 +235,57 @@ from .iamccs_multiline_prompt_splitter import ( IAMCCS_MultilinePromptSplitter8, ) +from .iamccs_supernode_modular import ( + IAMCCS_SupernodeBase, + IAMCCS_SupernodeModule, +) + +from .iamccs_auimg2vid_goal1 import ( + IAMCCS_ProjectTimelinePlanner, + IAMCCS_Ltx2HelperModules_ProjectTimelinePlanner, + IAMCCS_Ltx2HelperModules_Planner, + IAMCCS_Ltx2HelperModules_AudioTimeline, + IAMCCS_Ltx2HelperModules_KeyframeTimeline, + IAMCCS_Ltx2HelperModules_RefreshPolicy, + IAMCCS_Ltx2HelperModules_ReanchorLatent, + IAMCCS_Ltx2HelperModules_DiskExtension, + IAMCCS_Ltx2HelperModules_RuntimeBridge, + IAMCCS_Ltx2HelperModules_Continuity, + IAMCCS_Ltx2HelperModules_Finalize, + IAMCCS_AUIMG2VID_ProjectTimelinePlanner, + IAMCCS_AUIMG2VID_Planner, + IAMCCS_AUIMG2VID_AudioTimeline, + IAMCCS_AUIMG2VID_KeyframeTimeline, + IAMCCS_AUIMG2VID_RefreshPolicy, + IAMCCS_AUIMG2VID_ReanchorLatent, + IAMCCS_AUIMG2VID_DiskExtension, + IAMCCS_AUIMG2VID_RuntimeBridge, + IAMCCS_AUIMG2VID_Continuity, + IAMCCS_AUIMG2VID_Finalize, +) + +from .iamccs_supernodes_exec import ( + IAMCCS_SuperNodes_AUIMG2VIDExecutablePlanner, + IAMCCS_SuperNodes_AUIMG2VIDExecutableRender, + IAMCCS_SuperNodes_AUIMG2VIDExecutableVAE, + IAMCCS_SuperNodes_AUIMG2VIDExecutableFinalize, +) +from .iamccs_supernodes_second_stage import IAMCCS_SuperNodes_SecondStage + # Nodi principali NODE_CLASS_MAPPINGS = { "IAMCCS_WanLoRAStack": IAMCCS_WanLoRAStack, "IAMCCS_ModelWithLoRA": IAMCCS_ModelWithLoRA, "IAMCCS_WanLoRAStackModelIO": IAMCCS_WanLoRAStackModelIO, "IAMCCS_WanLoRASchedule": IAMCCS_WanLoRASchedule, + "IAMCCS_WanLoRAHookSchedule": IAMCCS_WanLoRAHookSchedule, + "IAMCCS_ApplyLoRAHooksToConditioning": IAMCCS_ApplyLoRAHooksToConditioning, + "IAMCCS_ApplyScheduledWanLoRAFromConditioning": IAMCCS_ApplyScheduledWanLoRAFromConditioning, + "IAMCCS_BuildScheduledWanModelBank": IAMCCS_BuildScheduledWanModelBank, + "IAMCCS_SelectScheduledWanModelFromConditioning": IAMCCS_SelectScheduledWanModelFromConditioning, + "IAMCCS_SelectScheduledWanModelPairFromConditioning": IAMCCS_SelectScheduledWanModelPairFromConditioning, + "IAMCCS_WanLoRARuntimeBridge": IAMCCS_WanLoRARuntimeBridge, + "IAMCCS_ModelWithLoRA_RuntimeBridge": IAMCCS_ModelWithLoRA_RuntimeBridge, # Backward-compatible key (kept as-is for existing workflows) "iamccs_ltx2_lora_stack": IAMCCS_LTX2_LoRAStack, # Preferred explicit names @@ -228,6 +306,8 @@ NODE_CLASS_MAPPINGS = { "IAMCCS_LTX2_ImageBatchPadReflect": IAMCCS_LTX2_ImageBatchPadReflect, "IAMCCS_LTX2_ImageBatchCropByPad": IAMCCS_LTX2_ImageBatchCropByPad, "IAMCCS_SegmentPlanner": IAMCCS_SegmentPlanner, + "IAMCCS_SegmentPlannerSettings": IAMCCS_SegmentPlannerSettings, + "IAMCCS_SegmentPlannerLinked": IAMCCS_SegmentPlannerLinked, "IAMCCS_SegmentPlanFromPlanner": IAMCCS_SegmentPlanFromPlanner, "IAMCCS_SourceRangeFromSegmentPlan": IAMCCS_SourceRangeFromSegmentPlan, "IAMCCS_TwoSegmentPlanner": IAMCCS_TwoSegmentPlanner, @@ -249,6 +329,18 @@ NODE_CLASS_MAPPINGS = { "IAMCCS_LTX2_MiddleFrames": IAMCCS_LTX2_MiddleFrames, "IAMCCS_LTX2_FirstLastLatentControl": IAMCCS_LTX2_FirstLastLatentControl, "IAMCCS_LTX2_FirstLastLatentControl_Pro": IAMCCS_LTX2_FirstLastLatentControl_Pro, + "IAMCCS_LTX2_CinematicShotPlanner": IAMCCS_LTX2_CinematicShotPlanner, + "IAMCCS_LTX2_CinematicRefLatentControl": IAMCCS_LTX2_CinematicRefLatentControl, + "IAMCCS_LTX2_AudioPromptDirector": IAMCCS_LTX2_AudioPromptDirector, + "IAMCCS_LTX2_CinematicPromptRelayAdapter": IAMCCS_LTX2_CinematicPromptRelayAdapter, + "IAMCCS_LTX2_CinematicPromptComposer": IAMCCS_LTX2_CinematicPromptComposer, + "IAMCCS_LTX2_CinematicShotLineBuilder": IAMCCS_LTX2_CinematicShotLineBuilder, + "IAMCCS_LTX2_CinematicV2VTimelineLineBuilder": IAMCCS_LTX2_CinematicV2VTimelineLineBuilder, + "IAMCCS_LTX2_CinematicLineStacker": IAMCCS_LTX2_CinematicLineStacker, + "IAMCCS_LTX2_CinematicMultiGenPlanner": IAMCCS_LTX2_CinematicMultiGenPlanner, + "IAMCCS_LTX2_CinematicShotAudioSelector": IAMCCS_LTX2_CinematicShotAudioSelector, + "IAMCCS_LTX2_CinematicV2VTimelinePlanner": IAMCCS_LTX2_CinematicV2VTimelinePlanner, + "IAMCCS_LTX2_CinematicV2VAssetSelector": IAMCCS_LTX2_CinematicV2VAssetSelector, "IAMCCS_LTX2_InitLatentSampler": IAMCCS_LTX2_InitLatentSampler, "IAMCCS_LTX2_LoopingSampler": IAMCCS_LTX2_LoopingSampler, "IAMCCS_LTX2_OneShotLowRAMLooper": IAMCCS_LTX2_OneShotLowRAMLooper, @@ -292,6 +384,7 @@ NODE_CLASS_MAPPINGS = { "IAMCCS_bus_group": IAMCCS_bus_group, "IAMCCS_MultiSwitch": IAMCCS_MultiSwitch, + "IAMCCS_LazyAnySwitch": IAMCCS_LazyAnySwitch, "IAMCCS_HwSupporter": IAMCCS_HwSupporter, "IAMCCS_HwSupporterAny": IAMCCS_HwSupporterAny, @@ -328,6 +421,34 @@ NODE_CLASS_MAPPINGS = { "IAMCCS_QwenMultiGen": IAMCCS_QwenMultiGen, "IAMCCS_FluxKleinMultiGen": IAMCCS_FluxKleinMultiGen, "IAMCCS_MultilinePromptSplitter8": IAMCCS_MultilinePromptSplitter8, + "IAMCCS_SupernodeBase": IAMCCS_SupernodeBase, + "IAMCCS_SupernodeModule": IAMCCS_SupernodeModule, + "IAMCCS_ProjectTimelinePlanner": IAMCCS_ProjectTimelinePlanner, + "IAMCCS_Ltx2HelperModules_ProjectTimelinePlanner": IAMCCS_Ltx2HelperModules_ProjectTimelinePlanner, + "IAMCCS_Ltx2HelperModules_Planner": IAMCCS_Ltx2HelperModules_Planner, + "IAMCCS_Ltx2HelperModules_AudioTimeline": IAMCCS_Ltx2HelperModules_AudioTimeline, + "IAMCCS_Ltx2HelperModules_KeyframeTimeline": IAMCCS_Ltx2HelperModules_KeyframeTimeline, + "IAMCCS_Ltx2HelperModules_RefreshPolicy": IAMCCS_Ltx2HelperModules_RefreshPolicy, + "IAMCCS_Ltx2HelperModules_ReanchorLatent": IAMCCS_Ltx2HelperModules_ReanchorLatent, + "IAMCCS_Ltx2HelperModules_DiskExtension": IAMCCS_Ltx2HelperModules_DiskExtension, + "IAMCCS_Ltx2HelperModules_RuntimeBridge": IAMCCS_Ltx2HelperModules_RuntimeBridge, + "IAMCCS_Ltx2HelperModules_Continuity": IAMCCS_Ltx2HelperModules_Continuity, + "IAMCCS_Ltx2HelperModules_Finalize": IAMCCS_Ltx2HelperModules_Finalize, + "IAMCCS_AUIMG2VID_ProjectTimelinePlanner": IAMCCS_AUIMG2VID_ProjectTimelinePlanner, + "IAMCCS_AUIMG2VID_Planner": IAMCCS_AUIMG2VID_Planner, + "IAMCCS_AUIMG2VID_AudioTimeline": IAMCCS_AUIMG2VID_AudioTimeline, + "IAMCCS_AUIMG2VID_KeyframeTimeline": IAMCCS_AUIMG2VID_KeyframeTimeline, + "IAMCCS_AUIMG2VID_RefreshPolicy": IAMCCS_AUIMG2VID_RefreshPolicy, + "IAMCCS_AUIMG2VID_ReanchorLatent": IAMCCS_AUIMG2VID_ReanchorLatent, + "IAMCCS_AUIMG2VID_DiskExtension": IAMCCS_AUIMG2VID_DiskExtension, + "IAMCCS_AUIMG2VID_RuntimeBridge": IAMCCS_AUIMG2VID_RuntimeBridge, + "IAMCCS_AUIMG2VID_Continuity": IAMCCS_AUIMG2VID_Continuity, + "IAMCCS_AUIMG2VID_Finalize": IAMCCS_AUIMG2VID_Finalize, + "IAMCCS-SuperNodes AU+IMG2VID Exec Planner": IAMCCS_SuperNodes_AUIMG2VIDExecutablePlanner, + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": IAMCCS_SuperNodes_AUIMG2VIDExecutableRender, + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": IAMCCS_SuperNodes_AUIMG2VIDExecutableVAE, + "IAMCCS-SuperNodes Second Stage": IAMCCS_SuperNodes_SecondStage, + **({"IAMCCS-SuperNodes AU+IMG2VID Exec Finalize": IAMCCS_SuperNodes_AUIMG2VIDExecutableFinalize} if IAMCCS_SuperNodes_AUIMG2VIDExecutableFinalize is not None else {}), # QwenVL First/Last Frame (registered only if QwenVL is installed) **({"IAMCCS_QWEN_VL_FLF": IAMCCS_QWEN_VL_FLF, @@ -341,6 +462,14 @@ NODE_DISPLAY_NAME_MAPPINGS = { "IAMCCS_ModelWithLoRA": "Apply LoRA to MODEL (Native)", "IAMCCS_WanLoRAStackModelIO": "LoRA Stack (Model Inβ†’Out) WAN", "IAMCCS_WanLoRASchedule": "LoRA Schedule (WAN, ranged)", + "IAMCCS_WanLoRAHookSchedule": "LoRA Schedule (WAN, hooks)", + "IAMCCS_ApplyLoRAHooksToConditioning": "Apply LoRA Hooks to Conditioning", + "IAMCCS_ApplyScheduledWanLoRAFromConditioning": "Apply Scheduled WAN LoRA From Conditioning", + "IAMCCS_BuildScheduledWanModelBank": "Build Scheduled WAN Model Bank", + "IAMCCS_SelectScheduledWanModelFromConditioning": "Select Scheduled WAN Model From Conditioning", + "IAMCCS_SelectScheduledWanModelPairFromConditioning": "Select Scheduled WAN Model Pair From Conditioning", + "IAMCCS_WanLoRARuntimeBridge": "LoRA Runtime Bridge (WAN, loop-safe)", + "IAMCCS_ModelWithLoRA_RuntimeBridge": "Apply LoRA to MODEL (Runtime Bridge)", "iamccs_ltx2_lora_stack": "iamccs_ltx2_lora_stack (3 slots)", "IAMCCS_LTX2_LoRAStack": "LoRA Stack (LTX-2, 3 slots)", "IAMCCS_LTX2_LoRAStackStaged": "LoRA Stack (LTX-2, staged: stage1+stage2) (BETA)", @@ -359,6 +488,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "IAMCCS_LTX2_ImageBatchPadReflect": "LTX-2 Pad Reflect (IMAGE batch)", "IAMCCS_LTX2_ImageBatchCropByPad": "LTX-2 Crop By Pad (IMAGE batch)", "IAMCCS_SegmentPlanner": "Segment Planner (song -> LTX frames)", + "IAMCCS_SegmentPlannerSettings": "Segment Planner Settings (shared)", + "IAMCCS_SegmentPlannerLinked": "Segment Planner Linked (shared inputs)", "IAMCCS_SegmentPlanFromPlanner": "Segment Plan From Planner (per index)", "IAMCCS_SourceRangeFromSegmentPlan": "Source Range From Segment Plan", "IAMCCS_TwoSegmentPlanner": "Two Segment Planner (stable 2SEG)", @@ -380,6 +511,18 @@ NODE_DISPLAY_NAME_MAPPINGS = { "IAMCCS_LTX2_MiddleFrames": "LTX-2 Middle Frames (accumulator) 🧷", "IAMCCS_LTX2_FirstLastLatentControl": "LTX-2 First/Last β†’ Latent (noise_mask) 🎯", "IAMCCS_LTX2_FirstLastLatentControl_Pro": "LTX-2 First/Last β†’ Latent (Pro, slot caps) 🎯", + "IAMCCS_LTX2_CinematicShotPlanner": "LTX-2 Cinematic Shot Planner", + "IAMCCS_LTX2_CinematicRefLatentControl": "LTX-2 Cinematic Ref -> Latent", + "IAMCCS_LTX2_AudioPromptDirector": "LTX-2 Audio Prompt Director", + "IAMCCS_LTX2_CinematicPromptRelayAdapter": "LTX-2 Cinematic PromptRelay Adapter", + "IAMCCS_LTX2_CinematicPromptComposer": "LTX-2 Cinematic Prompt Composer", + "IAMCCS_LTX2_CinematicShotLineBuilder": "LTX-2 Cinematic Shot Line Builder", + "IAMCCS_LTX2_CinematicV2VTimelineLineBuilder": "LTX-2 Cinematic V2V Line Builder", + "IAMCCS_LTX2_CinematicLineStacker": "LTX-2 Cinematic Line Stacker", + "IAMCCS_LTX2_CinematicMultiGenPlanner": "LTX-2 Cinematic MultiGen Planner", + "IAMCCS_LTX2_CinematicShotAudioSelector": "LTX-2 Cinematic Shot Audio Selector", + "IAMCCS_LTX2_CinematicV2VTimelinePlanner": "LTX-2 Cinematic V2V Timeline Planner", + "IAMCCS_LTX2_CinematicV2VAssetSelector": "LTX-2 Cinematic V2V Asset Selector", "IAMCCS_LTX2_InitLatentSampler": "LTX-2 Init Latent Sampler 🧱", "IAMCCS_LTX2_LoopingSampler": "LTX-2 Looping Sampler (temporal overlap) 🧷", "IAMCCS_LTX2_OneShotLowRAMLooper": "LTX-2 One-Shot Low-RAM Looper πŸͺΆ", @@ -417,12 +560,41 @@ NODE_DISPLAY_NAME_MAPPINGS = { "IAMCCS_QwenMultiGen": "IAMCCS Qwen Multi-Gen", "IAMCCS_FluxKleinMultiGen": "Flux Klein Multi-Gen", "IAMCCS_MultilinePromptSplitter8": "Multiline Prompt Splitter (8 outputs)", + "IAMCCS_SupernodeBase": "Supernode Base (contract + linx)", + "IAMCCS_SupernodeModule": "Supernode Module (cascade contract + linx)", + "IAMCCS_ProjectTimelinePlanner": "IAMCCS Project Timeline Planner", + "IAMCCS_Ltx2HelperModules_ProjectTimelinePlanner": "IAMCCS_Ltx2HelperModules Project Timeline Planner", + "IAMCCS_Ltx2HelperModules_Planner": "IAMCCS_Ltx2HelperModules Planner", + "IAMCCS_Ltx2HelperModules_AudioTimeline": "IAMCCS_Ltx2HelperModules Audio Timeline", + "IAMCCS_Ltx2HelperModules_KeyframeTimeline": "IAMCCS_Ltx2HelperModules Keyframe Timeline", + "IAMCCS_Ltx2HelperModules_RefreshPolicy": "IAMCCS_Ltx2HelperModules Refresh Policy", + "IAMCCS_Ltx2HelperModules_ReanchorLatent": "IAMCCS_Ltx2HelperModules Reanchor Latent", + "IAMCCS_Ltx2HelperModules_DiskExtension": "IAMCCS_Ltx2HelperModules Disk Extension", + "IAMCCS_Ltx2HelperModules_RuntimeBridge": "IAMCCS_Ltx2HelperModules Runtime Bridge", + "IAMCCS_Ltx2HelperModules_Continuity": "IAMCCS_Ltx2HelperModules Continuity", + "IAMCCS_Ltx2HelperModules_Finalize": "IAMCCS_Ltx2HelperModules Finalize", + "IAMCCS_AUIMG2VID_ProjectTimelinePlanner": "AU+IMG2VID Project Timeline Planner (legacy alias)", + "IAMCCS_AUIMG2VID_Planner": "AU+IMG2VID Planner (legacy alias)", + "IAMCCS_AUIMG2VID_AudioTimeline": "AU+IMG2VID Audio Timeline (legacy alias)", + "IAMCCS_AUIMG2VID_KeyframeTimeline": "AU+IMG2VID Keyframe Timeline (legacy alias)", + "IAMCCS_AUIMG2VID_RefreshPolicy": "AU+IMG2VID Refresh Policy (legacy alias)", + "IAMCCS_AUIMG2VID_ReanchorLatent": "AU+IMG2VID Reanchor Latent (legacy alias)", + "IAMCCS_AUIMG2VID_DiskExtension": "AU+IMG2VID Disk Extension (legacy alias)", + "IAMCCS_AUIMG2VID_RuntimeBridge": "AU+IMG2VID Runtime Bridge (legacy alias)", + "IAMCCS_AUIMG2VID_Continuity": "AU+IMG2VID Continuity (legacy alias)", + "IAMCCS_AUIMG2VID_Finalize": "AU+IMG2VID Finalize (legacy alias)", + "IAMCCS-SuperNodes AU+IMG2VID Exec Planner": "IAMCCS-SuperNodes AU+IMG2VID Exec Planner", + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": "IAMCCS-SuperNodes AU+IMG2VID Exec Render", + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": "IAMCCS-SuperNodes AU+IMG2VID Exec VAE", + "IAMCCS-SuperNodes Second Stage": "IAMCCS-SuperNodes Second Stage", + "IAMCCS-SuperNodes AU+IMG2VID Exec Finalize": "IAMCCS-SuperNodes AU+IMG2VID Exec Finalize", "IAMCCS_SamplerAdvancedVersion1": "Sampler Advanced v1", "IAMCCS_bus_group": "Bus Group (Mute + Solo) (frontend-only)", "IAMCCS_MultiSwitch": "MultiSwitch (dynamic inputs)", + "IAMCCS_LazyAnySwitch": "Lazy MultiGen Switch (Qwen / Flux)", "IAMCCS_WanSviArgs": "Wan SVI Args", "IAMCCS_WanSviChainRunner": "Wan SVI Chain Runner", "IAMCCS_WanSviSegmentPick": "Wan SVI Segment Pick", diff --git a/backup/20260420_auimg2vid_goal1/__init__.py b/backup/20260420_auimg2vid_goal1/__init__.py new file mode 100644 index 0000000..7a0bdad --- /dev/null +++ b/backup/20260420_auimg2vid_goal1/__init__.py @@ -0,0 +1,745 @@ +# ========================================================== +# __init__.py β€” Registro nodi IAMCCS +# ========================================================== + +import logging +import os + +from .iamccs_comfy_compat import apply_iamccs_comfy_compat_patches + +apply_iamccs_comfy_compat_patches() + +# ComfyUI frontend assets +WEB_DIRECTORY = "web" + +from .iamccs_wan_lora_stack import ( + IAMCCS_WanLoRAStack, + IAMCCS_ModelWithLoRA, +) +from .iamccs_wan_lora_stack_simple import ( + IAMCCS_WanLoRAStackModelIO, +) +from .iamccs_wan_lora_schedule import ( + IAMCCS_WanLoRASchedule, +) +from .iamccs_wan_lora_hooks import ( + IAMCCS_WanLoRAHookSchedule, + IAMCCS_ApplyLoRAHooksToConditioning, + IAMCCS_ApplyScheduledWanLoRAFromConditioning, + IAMCCS_BuildScheduledWanModelBank, + IAMCCS_SelectScheduledWanModelFromConditioning, + IAMCCS_SelectScheduledWanModelPairFromConditioning, +) +from .iamccs_wan_lora_runtime_bridge import ( + IAMCCS_WanLoRARuntimeBridge, + IAMCCS_ModelWithLoRA_RuntimeBridge, +) + +from .iamccs_ltx2_lora_stack import ( + IAMCCS_LTX2_LoRAStack, + IAMCCS_LTX2_LoRAStackStaged, + IAMCCS_ModelWithLoRA_LTX2, + IAMCCS_ModelWithLoRA_LTX2_Staged, + IAMCCS_LTX2_LoRAStackModelIO, +) + +from .iamccs_ltx2_lora_stack_segmented6 import ( + IAMCCS_LTX2_LoRAStackSegmented6, + IAMCCS_LTX2_ModelWithLoRA_Segmented6, +) + +from .iamccs_ltx2_tools import ( + IAMCCS_LTX2_FrameRateSync, + IAMCCS_LTX2_Validator, + IAMCCS_LTX2_TimeFrameCount, + IAMCCS_LTX2_EnsureFrames8nPlus1, + IAMCCS_LTX2_EnsureMinFrames, + IAMCCS_LTX2_ControlPreprocess, + IAMCCS_LTX2_ImageBatchPadReflect, + IAMCCS_LTX2_ImageBatchCropByPad, + IAMCCS_SegmentPlanner, + IAMCCS_SegmentPlanFromPlanner, + IAMCCS_SourceRangeFromSegmentPlan, + IAMCCS_TwoSegmentPlanner, + IAMCCS_ThreeSegmentPlanner, + IAMCCS_SegmentSwitch, +) + +from .iamccs_ltx2_extension_module import ( + IAMCCS_LTX2_ExtensionModule, + IAMCCS_LTX2_ExtensionModule_Disk, + IAMCCS_LoadImagesFromDirLite, + IAMCCS_SourceFramesToDisk, + IAMCCS_StartDirToVideoLatent, + IAMCCS_VideoCombineFromDir, + IAMCCS_LTX2_ExtensionModule_simple, + IAMCCS_LTX2_GetImageFromBatch, + IAMCCS_LTX2_ReferenceImageSwitch, + IAMCCS_LTX2_ReferenceStartFramesInjector, + IAMCCS_LTX2_FrameCountValidator, + IAMCCS_LTX2_FirstLastFramesController, + IAMCCS_LTX2_ContextLatent, + IAMCCS_LTX2_MiddleFrames, + IAMCCS_LTX2_FirstLastLatentControl, + IAMCCS_LTX2_FirstLastLatentControl_Pro, +) + +from .iamccs_ltx2_temporal_overlap_samplers import ( + IAMCCS_LTX2_ConditionNextLatentWithPrevOverlap, + IAMCCS_LTX2_InitLatentSampler, + IAMCCS_LTX2_LoopingSampler, + IAMCCS_LTX2_OneShotLowRAMLooper, + IAMCCS_LTX2_ExtendSampler, +) + +from .iamccs_wan_svipro_motion import ( + IAMCCS_WanImageMotion, + WanImageMotionPro as WanImageMotionProPlus, + IAMCCS_WanImageMotionPro_Simple as IAMCCS_WanImageMotionProPlus_Simple, + IAMCCS_WanImageMotionInductive, + IAMCCS_WanSVIToFLFBridgePro as IAMCCS_WanSVIToFLFBridgeProPlus, + IAMCCS_WanSVIToFLFBridgePro_Simple as IAMCCS_WanSVIToFLFBridgeProPlus_Simple, + WanMotionProTrimmer, + IAMCCS_WanPrevTailPrep, +) + +from .iamccs_wan_long_length import ( + IAMCCS_WanLongPlanner, + IAMCCS_WanContinuityGuide, + IAMCCS_WanPromptPhasePlanner, + IAMCCS_WanPromptLoopInfo, + IAMCCS_WanIndexedPromptEncode, + IAMCCS_WanImageBatchFrameSelect, +) + +from .iamccs_wan_svipro_motion_legacy import ( + WanImageMotionProLegacy, +) + +from .iamccs_autolink import ( + IAMCCS_SetAutoLink, + IAMCCS_GetAutoLink, + IAMCCS_AutoLinkConverter, + IAMCCS_AutoLinkArguments, +) + +from .iamccs_gguf_accelerator import ( + IAMCCS_GGUF_accelerator, +) + +from .iamccs_sampler_advanced_v1 import ( + IAMCCS_SamplerAdvancedVersion1, +) + +from .iamccs_bus_group import ( + IAMCCS_bus_group, +) + +from .iamccs_image_resize import ( + IAMCCS_ImageResizeBatchSafe, + IAMCCS_LoadResizeSegmentFromDir, +) + +from .iamccs_multiswitch import ( + IAMCCS_MultiSwitch, +) + +from .iamccs_hw_supporter import ( + IAMCCS_HwSupporter, + IAMCCS_HwSupporterAny, + IAMCCS_HardMemoryPurge, + IAMCCS_VRAMCleanup, + IAMCCS_VRAMFlushLatent, + IAMCCS_VAEDecodeTiledSafe, + IAMCCS_VAEDecodeToDisk, +) + +from .iamccs_hw_probe_node import ( + IAMCCS_HWProbeRecommendations, +) + +from .iamccs_qwen_vl_flf import ( + IAMCCS_QWEN_VL_FLF, + IAMCCS_QWEN_VL_FLF_Advanced, +) + +from .iamccs_move_ahead import ( + IAMCCS_MoveAhead, + IAMCCS_MoveAheadEnforcer, + IAMCCS_MotionScale, + IAMCCS_MotionScaleAdvanced, +) + +from .iamccs_motion_bridge import ( + IAMCCS_MotionBridgeSave, + IAMCCS_MotionBridgeLoad, + IAMCCS_LatentTailSlice, +) + +from .iamccs_audio_extender import ( + IAMCCS_AudioExtensionMath, + IAMCCS_AudioExtender, + IAMCCS_AudioTimelineAssembler, + IAMCCS_AudioTimelineGate, +) + +from .iamccs_ltx2_segment_queue import ( + IAMCCS_LTX2_BlendLatentBridge, + IAMCCS_LTX2_LastFrameBridgeLoad, + IAMCCS_LTX2_LastFrameBridgeSave, + IAMCCS_LTX2_LoadLatentBridge, + IAMCCS_LTX2_LongVideoWrapperPrep, + IAMCCS_LTX2_LongVideoWrapperPrepDisk, + IAMCCS_LTX2_SaveLatentBridge, + IAMCCS_LTX2_SegmentQueueLoop, +) + +from .iamccs_image_resize import ( + IAMCCS_ImageResizeBatchSafe, +) + +from .iamccs_value_monitor import ( + IAMCCS_IntValueMonitor, +) + +from .iamccs_flux_klein_multigen import ( + IAMCCS_FluxKleinMultiGen, +) + +from .iamccs_qwen_multigen import ( + IAMCCS_QwenMultiGen, +) + +from .iamccs_multiline_prompt_splitter import ( + IAMCCS_MultilinePromptSplitter8, +) + +from .iamccs_supernode_modular import ( + IAMCCS_SupernodeBase, + IAMCCS_SupernodeModule, +) + +# Nodi principali +NODE_CLASS_MAPPINGS = { + "IAMCCS_WanLoRAStack": IAMCCS_WanLoRAStack, + "IAMCCS_ModelWithLoRA": IAMCCS_ModelWithLoRA, + "IAMCCS_WanLoRAStackModelIO": IAMCCS_WanLoRAStackModelIO, + "IAMCCS_WanLoRASchedule": IAMCCS_WanLoRASchedule, + "IAMCCS_WanLoRAHookSchedule": IAMCCS_WanLoRAHookSchedule, + "IAMCCS_ApplyLoRAHooksToConditioning": IAMCCS_ApplyLoRAHooksToConditioning, + "IAMCCS_ApplyScheduledWanLoRAFromConditioning": IAMCCS_ApplyScheduledWanLoRAFromConditioning, + "IAMCCS_BuildScheduledWanModelBank": IAMCCS_BuildScheduledWanModelBank, + "IAMCCS_SelectScheduledWanModelFromConditioning": IAMCCS_SelectScheduledWanModelFromConditioning, + "IAMCCS_SelectScheduledWanModelPairFromConditioning": IAMCCS_SelectScheduledWanModelPairFromConditioning, + "IAMCCS_WanLoRARuntimeBridge": IAMCCS_WanLoRARuntimeBridge, + "IAMCCS_ModelWithLoRA_RuntimeBridge": IAMCCS_ModelWithLoRA_RuntimeBridge, + # Backward-compatible key (kept as-is for existing workflows) + "iamccs_ltx2_lora_stack": IAMCCS_LTX2_LoRAStack, + # Preferred explicit names + "IAMCCS_LTX2_LoRAStack": IAMCCS_LTX2_LoRAStack, + "IAMCCS_LTX2_LoRAStackStaged": IAMCCS_LTX2_LoRAStackStaged, + "IAMCCS_ModelWithLoRA_LTX2": IAMCCS_ModelWithLoRA_LTX2, + "IAMCCS_ModelWithLoRA_LTX2_Staged": IAMCCS_ModelWithLoRA_LTX2_Staged, + "IAMCCS_LTX2_LoRAStackModelIO": IAMCCS_LTX2_LoRAStackModelIO, + "IAMCCS_LTX2_LoRAStackSegmented6": IAMCCS_LTX2_LoRAStackSegmented6, + "IAMCCS_LTX2_ModelWithLoRA_Segmented6": IAMCCS_LTX2_ModelWithLoRA_Segmented6, + + "IAMCCS_LTX2_FrameRateSync": IAMCCS_LTX2_FrameRateSync, + "IAMCCS_LTX2_Validator": IAMCCS_LTX2_Validator, + "IAMCCS_LTX2_TimeFrameCount": IAMCCS_LTX2_TimeFrameCount, + "IAMCCS_LTX2_EnsureFrames8nPlus1": IAMCCS_LTX2_EnsureFrames8nPlus1, + "IAMCCS_LTX2_EnsureMinFrames": IAMCCS_LTX2_EnsureMinFrames, + "IAMCCS_LTX2_ControlPreprocess": IAMCCS_LTX2_ControlPreprocess, + "IAMCCS_LTX2_ImageBatchPadReflect": IAMCCS_LTX2_ImageBatchPadReflect, + "IAMCCS_LTX2_ImageBatchCropByPad": IAMCCS_LTX2_ImageBatchCropByPad, + "IAMCCS_SegmentPlanner": IAMCCS_SegmentPlanner, + "IAMCCS_SegmentPlanFromPlanner": IAMCCS_SegmentPlanFromPlanner, + "IAMCCS_SourceRangeFromSegmentPlan": IAMCCS_SourceRangeFromSegmentPlan, + "IAMCCS_TwoSegmentPlanner": IAMCCS_TwoSegmentPlanner, + "IAMCCS_ThreeSegmentPlanner": IAMCCS_ThreeSegmentPlanner, + "IAMCCS_SegmentSwitch": IAMCCS_SegmentSwitch, + "IAMCCS_LTX2_ExtensionModule": IAMCCS_LTX2_ExtensionModule, + "IAMCCS_LTX2_ExtensionModule_Disk": IAMCCS_LTX2_ExtensionModule_Disk, + "IAMCCS_LoadImagesFromDirLite": IAMCCS_LoadImagesFromDirLite, + "IAMCCS_SourceFramesToDisk": IAMCCS_SourceFramesToDisk, + "IAMCCS_StartDirToVideoLatent": IAMCCS_StartDirToVideoLatent, + "IAMCCS_VideoCombineFromDir": IAMCCS_VideoCombineFromDir, + "IAMCCS_LTX2_ExtensionModule_simple": IAMCCS_LTX2_ExtensionModule_simple, + "IAMCCS_LTX2_GetImageFromBatch": IAMCCS_LTX2_GetImageFromBatch, + "IAMCCS_LTX2_ReferenceImageSwitch": IAMCCS_LTX2_ReferenceImageSwitch, + "IAMCCS_LTX2_ReferenceStartFramesInjector": IAMCCS_LTX2_ReferenceStartFramesInjector, + "IAMCCS_LTX2_FrameCountValidator": IAMCCS_LTX2_FrameCountValidator, + "IAMCCS_LTX2_FirstLastFramesController": IAMCCS_LTX2_FirstLastFramesController, + "IAMCCS_LTX2_ContextLatent": IAMCCS_LTX2_ContextLatent, + "IAMCCS_LTX2_MiddleFrames": IAMCCS_LTX2_MiddleFrames, + "IAMCCS_LTX2_FirstLastLatentControl": IAMCCS_LTX2_FirstLastLatentControl, + "IAMCCS_LTX2_FirstLastLatentControl_Pro": IAMCCS_LTX2_FirstLastLatentControl_Pro, + "IAMCCS_LTX2_InitLatentSampler": IAMCCS_LTX2_InitLatentSampler, + "IAMCCS_LTX2_LoopingSampler": IAMCCS_LTX2_LoopingSampler, + "IAMCCS_LTX2_OneShotLowRAMLooper": IAMCCS_LTX2_OneShotLowRAMLooper, + "IAMCCS_LTX2_ExtendSampler": IAMCCS_LTX2_ExtendSampler, + "IAMCCS_LTX2_ConditionNextLatentWithPrevOverlap": IAMCCS_LTX2_ConditionNextLatentWithPrevOverlap, + "IAMCCS_WanImageMotion": IAMCCS_WanImageMotion, + # Backward-compat alias: workflow JSONs saved with the _AdaIN name still load. + "IAMCCS_WanImageMotion_AdaIN": IAMCCS_WanImageMotion, + "WanImageMotionPro": WanImageMotionProPlus, + "IAMCCS_WanImageMotionPro_AdaIN": WanImageMotionProPlus, + # Keep the historic key on the current implementation so existing workflows + # load the new Plus node with continuity profiles and presets. + "IAMCCS_WanImageMotionPro": WanImageMotionProPlus, + # Explicit legacy entrypoint for older raw-only behavior. + "WanImageMotionProLegacy": WanImageMotionProLegacy, + "IAMCCS_WanImageMotionProLegacy": WanImageMotionProLegacy, + "WanImageMotionProPlus": WanImageMotionProPlus, + "IAMCCS_WanImageMotionProPlus": WanImageMotionProPlus, + "IAMCCS_WanImageMotionProPlus_Simple": IAMCCS_WanImageMotionProPlus_Simple, + "IAMCCS_WanImageMotionInductive": IAMCCS_WanImageMotionInductive, + "IAMCCS_WanSVIToFLFBridgeProPlus": IAMCCS_WanSVIToFLFBridgeProPlus, + "IAMCCS_WanSVIToFLFBridgeProPlus_Simple": IAMCCS_WanSVIToFLFBridgeProPlus_Simple, + "WanMotionProTrimmer": WanMotionProTrimmer, + "IAMCCS_WanPrevTailPrep": IAMCCS_WanPrevTailPrep, + "IAMCCS_WanLongPlanner": IAMCCS_WanLongPlanner, + "IAMCCS_WanContinuityGuide": IAMCCS_WanContinuityGuide, + "IAMCCS_WanPromptPhasePlanner": IAMCCS_WanPromptPhasePlanner, + "IAMCCS_WanPromptLoopInfo": IAMCCS_WanPromptLoopInfo, + "IAMCCS_WanIndexedPromptEncode": IAMCCS_WanIndexedPromptEncode, + "IAMCCS_WanImageBatchFrameSelect": IAMCCS_WanImageBatchFrameSelect, + + "IAMCCS_SetAutoLink": IAMCCS_SetAutoLink, + "IAMCCS_GetAutoLink": IAMCCS_GetAutoLink, + "IAMCCS_AutoLinkConverter": IAMCCS_AutoLinkConverter, + "IAMCCS_AutoLinkArguments": IAMCCS_AutoLinkArguments, + + "IAMCCS_GGUF_accelerator": IAMCCS_GGUF_accelerator, + + "IAMCCS_SamplerAdvancedVersion1": IAMCCS_SamplerAdvancedVersion1, + + "IAMCCS_bus_group": IAMCCS_bus_group, + + "IAMCCS_MultiSwitch": IAMCCS_MultiSwitch, + + "IAMCCS_HwSupporter": IAMCCS_HwSupporter, + "IAMCCS_HwSupporterAny": IAMCCS_HwSupporterAny, + "IAMCCS_HardMemoryPurge": IAMCCS_HardMemoryPurge, + "IAMCCS_VRAMCleanup": IAMCCS_VRAMCleanup, + "IAMCCS_VRAMFlushLatent": IAMCCS_VRAMFlushLatent, + "IAMCCS_VAEDecodeTiledSafe": IAMCCS_VAEDecodeTiledSafe, + "IAMCCS_VAEDecodeToDisk": IAMCCS_VAEDecodeToDisk, + "IAMCCS_HWProbeRecommendations": IAMCCS_HWProbeRecommendations, + + "IAMCCS_MoveAhead": IAMCCS_MoveAhead, + "IAMCCS_MoveAheadEnforcer": IAMCCS_MoveAheadEnforcer, + "IAMCCS_MotionScale": IAMCCS_MotionScale, + "IAMCCS_MotionScaleAdvanced": IAMCCS_MotionScaleAdvanced, + + "IAMCCS_MotionBridgeSave": IAMCCS_MotionBridgeSave, + "IAMCCS_MotionBridgeLoad": IAMCCS_MotionBridgeLoad, + "IAMCCS_LatentTailSlice": IAMCCS_LatentTailSlice, + "IAMCCS_AudioExtensionMath": IAMCCS_AudioExtensionMath, + "IAMCCS_AudioExtender": IAMCCS_AudioExtender, + "IAMCCS_AudioTimelineAssembler": IAMCCS_AudioTimelineAssembler, + "IAMCCS_AudioTimelineGate": IAMCCS_AudioTimelineGate, + "IAMCCS_LTX2_LastFrameBridgeSave": IAMCCS_LTX2_LastFrameBridgeSave, + "IAMCCS_LTX2_BlendLatentBridge": IAMCCS_LTX2_BlendLatentBridge, + "IAMCCS_LTX2_LastFrameBridgeLoad": IAMCCS_LTX2_LastFrameBridgeLoad, + "IAMCCS_LTX2_LoadLatentBridge": IAMCCS_LTX2_LoadLatentBridge, + "IAMCCS_LTX2_LongVideoWrapperPrep": IAMCCS_LTX2_LongVideoWrapperPrep, + "IAMCCS_LTX2_LongVideoWrapperPrepDisk": IAMCCS_LTX2_LongVideoWrapperPrepDisk, + "IAMCCS_LTX2_SaveLatentBridge": IAMCCS_LTX2_SaveLatentBridge, + "IAMCCS_LTX2_SegmentQueueLoop": IAMCCS_LTX2_SegmentQueueLoop, + "IAMCCS_ImageResizeBatchSafe": IAMCCS_ImageResizeBatchSafe, + "IAMCCS_LoadResizeSegmentFromDir": IAMCCS_LoadResizeSegmentFromDir, + "IAMCCS_IntValueMonitor": IAMCCS_IntValueMonitor, + "IAMCCS_QwenMultiGen": IAMCCS_QwenMultiGen, + "IAMCCS_FluxKleinMultiGen": IAMCCS_FluxKleinMultiGen, + "IAMCCS_MultilinePromptSplitter8": IAMCCS_MultilinePromptSplitter8, + "IAMCCS_SupernodeBase": IAMCCS_SupernodeBase, + "IAMCCS_SupernodeModule": IAMCCS_SupernodeModule, + + # 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, + } if IAMCCS_QWEN_VL_FLF is not None else {}), + +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "IAMCCS_WanLoRAStack": "LoRA Stack (WAN-style remap)", + "IAMCCS_ModelWithLoRA": "Apply LoRA to MODEL (Native)", + "IAMCCS_WanLoRAStackModelIO": "LoRA Stack (Model Inβ†’Out) WAN", + "IAMCCS_WanLoRASchedule": "LoRA Schedule (WAN, ranged)", + "IAMCCS_WanLoRAHookSchedule": "LoRA Schedule (WAN, hooks)", + "IAMCCS_ApplyLoRAHooksToConditioning": "Apply LoRA Hooks to Conditioning", + "IAMCCS_ApplyScheduledWanLoRAFromConditioning": "Apply Scheduled WAN LoRA From Conditioning", + "IAMCCS_BuildScheduledWanModelBank": "Build Scheduled WAN Model Bank", + "IAMCCS_SelectScheduledWanModelFromConditioning": "Select Scheduled WAN Model From Conditioning", + "IAMCCS_SelectScheduledWanModelPairFromConditioning": "Select Scheduled WAN Model Pair From Conditioning", + "IAMCCS_WanLoRARuntimeBridge": "LoRA Runtime Bridge (WAN, loop-safe)", + "IAMCCS_ModelWithLoRA_RuntimeBridge": "Apply LoRA to MODEL (Runtime Bridge)", + "iamccs_ltx2_lora_stack": "iamccs_ltx2_lora_stack (3 slots)", + "IAMCCS_LTX2_LoRAStack": "LoRA Stack (LTX-2, 3 slots)", + "IAMCCS_LTX2_LoRAStackStaged": "LoRA Stack (LTX-2, staged: stage1+stage2) (BETA)", + "IAMCCS_ModelWithLoRA_LTX2": "Apply LoRA to MODEL (LTX-2, quiet logs)", + "IAMCCS_ModelWithLoRA_LTX2_Staged": "Apply LoRA to MODEL (LTX-2, staged) (BETA)", + "IAMCCS_LTX2_LoRAStackModelIO": "LoRA Stack (Model Inβ†’Out) LTX-2", + "IAMCCS_LTX2_LoRAStackSegmented6": "LoRA Stack (LTX-2, segmented: 3 seg Γ— 2 stages)", + "IAMCCS_LTX2_ModelWithLoRA_Segmented6": "Apply LoRA to MODEL (LTX-2, segmented: 3 seg Γ— 2 stages)", + + "IAMCCS_LTX2_FrameRateSync": "LTX-2 FrameRate Sync (int+float)", + "IAMCCS_LTX2_Validator": "LTX-2 Validator", + "IAMCCS_LTX2_TimeFrameCount": "LTX-2 TimeFrameCount", + "IAMCCS_LTX2_EnsureFrames8nPlus1": "LTX-2 Ensure Frames (8n + 1)", + "IAMCCS_LTX2_EnsureMinFrames": "LTX-2 Ensure Minimum Frames", + "IAMCCS_LTX2_ControlPreprocess": "LTX-2 Control Preprocess (aux)", + "IAMCCS_LTX2_ImageBatchPadReflect": "LTX-2 Pad Reflect (IMAGE batch)", + "IAMCCS_LTX2_ImageBatchCropByPad": "LTX-2 Crop By Pad (IMAGE batch)", + "IAMCCS_SegmentPlanner": "Segment Planner (song -> LTX frames)", + "IAMCCS_SegmentPlanFromPlanner": "Segment Plan From Planner (per index)", + "IAMCCS_SourceRangeFromSegmentPlan": "Source Range From Segment Plan", + "IAMCCS_TwoSegmentPlanner": "Two Segment Planner (stable 2SEG)", + "IAMCCS_ThreeSegmentPlanner": "Three Segment Planner (stable 3SEG)", + "IAMCCS_SegmentSwitch": "Segment Switch (by segment_index)", + "IAMCCS_LTX2_ExtensionModule": "LTX-2 Extension Module 🎬", + "IAMCCS_LTX2_ExtensionModule_Disk": "LTX-2 Extension Module (Disk / Low RAM) πŸ’Ύ", + "IAMCCS_LoadImagesFromDirLite": "Load Images From Dir (Lite) πŸ“", + "IAMCCS_SourceFramesToDisk": "Source Frames To Disk πŸ“ΌπŸ’Ύ", + "IAMCCS_StartDirToVideoLatent": "Start Dir To Video Latent πŸš€", + "IAMCCS_VideoCombineFromDir": "Video Combine From Dir 🎞️", + "IAMCCS_LTX2_ExtensionModule_simple": "LTX-2 Extension Module (simple) 🎬", + "IAMCCS_LTX2_GetImageFromBatch": "LTX-2 Get Images From Batch 🎞️", + "IAMCCS_LTX2_ReferenceImageSwitch": "LTX-2 Reference Image Switch 🧷", + "IAMCCS_LTX2_ReferenceStartFramesInjector": "LTX-2 Inject Reference Into Start Frames 🧬", + "IAMCCS_LTX2_FrameCountValidator": "LTX-2 Frame Count Validator βœ… (8n+1)", + "IAMCCS_LTX2_FirstLastFramesController": "LTX-2 First/Last Frames Controller 🧲", + "IAMCCS_LTX2_ContextLatent": "LTX-2 Context β†’ Latent (continue) 🧩", + "IAMCCS_LTX2_MiddleFrames": "LTX-2 Middle Frames (accumulator) 🧷", + "IAMCCS_LTX2_FirstLastLatentControl": "LTX-2 First/Last β†’ Latent (noise_mask) 🎯", + "IAMCCS_LTX2_FirstLastLatentControl_Pro": "LTX-2 First/Last β†’ Latent (Pro, slot caps) 🎯", + "IAMCCS_LTX2_InitLatentSampler": "LTX-2 Init Latent Sampler 🧱", + "IAMCCS_LTX2_LoopingSampler": "LTX-2 Looping Sampler (temporal overlap) 🧷", + "IAMCCS_LTX2_OneShotLowRAMLooper": "LTX-2 One-Shot Low-RAM Looper πŸͺΆ", + "IAMCCS_LTX2_ExtendSampler": "LTX-2 Extend Sampler (temporal overlap) 🧷", + "IAMCCS_LTX2_ConditionNextLatentWithPrevOverlap": "LTX-2 Condition Next Latent (prev overlap) 🧷", + "IAMCCS_WanImageMotion": "WanImageMotion", + "IAMCCS_WanImageMotion_AdaIN": "WanImageMotion", + "WanImageMotionPro": "WanImageMotionPro Plus", + "IAMCCS_WanImageMotionPro_AdaIN": "WanImageMotionPro Plus", + "WanImageMotionProLegacy": "WanImageMotionPro Legacy", + "IAMCCS_WanImageMotionProLegacy": "WanImageMotionPro Legacy", + "WanImageMotionProPlus": "WanImageMotionPro Plus", + "IAMCCS_WanImageMotionProPlus": "WanImageMotionPro Plus", + "IAMCCS_WanImageMotionProPlus_Simple": "WanImageMotionPro Plus Simple", + "IAMCCS_WanImageMotionInductive": "WanImageMotion Inductive", + "IAMCCS_WanSVIToFLFBridgeProPlus": "Wan SVIβ†’FLF Bridge Pro Plus", + "IAMCCS_WanSVIToFLFBridgeProPlus_Simple": "Wan SVIβ†’FLF Bridge Pro Plus", + "WanMotionProTrimmer": "WanMotionProTrimmer (trim overshoot tail)", + "IAMCCS_WanPrevTailPrep": "Wan Prev Tail Prep", + "IAMCCS_WanLongPlanner": "Wan Long Planner", + "IAMCCS_WanContinuityGuide": "Wan Continuity Guide", + "IAMCCS_WanPromptPhasePlanner": "Wan Prompt Phase Planner", + "IAMCCS_WanPromptLoopInfo": "Wan Prompt Loop Info", + "IAMCCS_WanIndexedPromptEncode": "Wan Indexed Prompt Encode", + "IAMCCS_WanImageBatchFrameSelect": "Wan Image Batch Frame Select", + + "IAMCCS_SetAutoLink": "Set AutoLink", + "IAMCCS_GetAutoLink": "Get AutoLink", + "IAMCCS_AutoLinkConverter": "AutoLink Converter", + "IAMCCS_AutoLinkArguments": "AutoLink Arguments", + + "IAMCCS_GGUF_accelerator": "GGUF Accelerator (patch_on_device)", + + "IAMCCS_IntValueMonitor": "INT Value Monitor", + "IAMCCS_QwenMultiGen": "IAMCCS Qwen Multi-Gen", + "IAMCCS_FluxKleinMultiGen": "Flux Klein Multi-Gen", + "IAMCCS_MultilinePromptSplitter8": "Multiline Prompt Splitter (8 outputs)", + "IAMCCS_SupernodeBase": "Supernode Base (contract + linx)", + "IAMCCS_SupernodeModule": "Supernode Module (cascade contract + linx)", + + "IAMCCS_SamplerAdvancedVersion1": "Sampler Advanced v1", + + "IAMCCS_bus_group": "Bus Group (Mute + Solo) (frontend-only)", + + "IAMCCS_MultiSwitch": "MultiSwitch (dynamic inputs)", + "IAMCCS_WanSviArgs": "Wan SVI Args", + "IAMCCS_WanSviChainRunner": "Wan SVI Chain Runner", + "IAMCCS_WanSviSegmentPick": "Wan SVI Segment Pick", + + "IAMCCS_HwSupporter": "HW Supporter (auto VRAM/attention/torch knobs)", + "IAMCCS_HwSupporterAny": "HW Supporter (ANY passthrough)", + "IAMCCS_HardMemoryPurge": "Hard RAM/VRAM Purge (trim working set)", + "IAMCCS_VRAMCleanup": "VRAM Cleanup (unload + empty cache)", + "IAMCCS_VRAMFlushLatent": "VRAM Flush β†’ Latent passthrough (empty cache)", + "IAMCCS_VAEDecodeTiledSafe": "VAE Decode Tiled (safe, optional cleanup)", + "IAMCCS_VAEDecodeToDisk": "VAE Decode β†’ Disk (frames, low RAM)", + "IAMCCS_HWProbeRecommendations": "HW Probe Recommendations (JSON)", + + "IAMCCS_MoveAhead": "MoveAhead (FreeLong spectral blend) 🎬", + "IAMCCS_MoveAheadEnforcer": "MoveAhead Enforcer (3-tier motion lock) 🎬", + "IAMCCS_MotionScale": "MotionScale (temporal RoPE scale) ⚑", + "IAMCCS_MotionScaleAdvanced": "MotionScale Advanced (RoPE + theta) ⚑", + + "IAMCCS_MotionBridgeSave": "Motion Bridge Save πŸŽ¬πŸ’Ύ", + "IAMCCS_MotionBridgeLoad": "Motion Bridge Load πŸŽ¬πŸ“‚", + "IAMCCS_LatentTailSlice": "Latent Tail Slice βœ‚οΈ", + "IAMCCS_AudioExtensionMath": "Audio Extension Math (timeline sync)", + "IAMCCS_AudioExtender": "Audio Extender (segment + overlap)", + "IAMCCS_AudioTimelineAssembler": "Audio Timeline Assembler (full track)", + "IAMCCS_AudioTimelineGate": "Audio Timeline Gate (continue/stop)", + "IAMCCS_LTX2_LastFrameBridgeSave": "LTX-2 Last Frame Bridge Save πŸ–ΌοΈπŸ’Ύ", + "IAMCCS_LTX2_BlendLatentBridge": "LTX-2 Blend Latent Bridge 🎚️", + "IAMCCS_LTX2_LastFrameBridgeLoad": "LTX-2 Last Frame Bridge Load πŸ–ΌοΈ", + "IAMCCS_LTX2_LoadLatentBridge": "LTX-2 Load Latent Bridge 🧬", + "IAMCCS_LTX2_LongVideoWrapperPrep": "LTX-2 Long Video Wrapper Prep 🧰", + "IAMCCS_LTX2_LongVideoWrapperPrepDisk": "LTX-2 Long Video Wrapper Prep (Disk) πŸ’ΎπŸ§°", + "IAMCCS_LTX2_SaveLatentBridge": "LTX-2 Save Latent Bridge πŸ’Ύ", + "IAMCCS_LTX2_SegmentQueueLoop": "LTX-2 Segment Queue Loop πŸ”", + "IAMCCS_ImageResizeBatchSafe": "Image Resize Batch Safe (IAMCCS)", + "IAMCCS_LoadResizeSegmentFromDir": "Load + Resize Segment From Dir 🧰", + + # QwenVL FLF + **({"IAMCCS_QWEN_VL_FLF": "QwenVL FLF β€” First/Last Frame Prompt 🎬", + "IAMCCS_QWEN_VL_FLF_Advanced": "QwenVL FLF β€” First/Last Frame Prompt (Advanced) 🎬", + } if IAMCCS_QWEN_VL_FLF is not None else {}), + +} + +WEB_DIRECTORY = "./web" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] + + +def _print_startup_banner() -> None: + # Print once per process. + if getattr(_print_startup_banner, "_done", False): + return + _print_startup_banner._done = True # type: ignore[attr-defined] + + banner = r""" + ___ _ __ __ ____ ____ ____ ____ _ + |_ _| / \ | \/ |/ ___/ ___|/ ___| | _ \ ___ ___| | _____ + | | / _ \ | |\/| | | | | \___ \ | |_) / _ \ / __| |/ / __| + | | / ___ \| | | | |__| |___ ___) || __/ (_) | (__| <\__ \ + |___/_/ \_\_| |_|\____\____||____/ |_| \___/ \___|_|\_\___/ + +""" + log = logging.getLogger("IAMCCS") + log.info("%s", banner) + log.info("by IAMCCS (follow me on patreon.com/IAMCCS or carminecristalloscalzi.com)") + + try: + keys = sorted(list(NODE_CLASS_MAPPINGS.keys())) + log.info("IAMCCS nodes loaded: %d", len(keys)) + # Keep log readable: print in chunks. + chunk = [] + for k in keys: + chunk.append(k) + if len(chunk) >= 10: + log.info("- %s", ", ".join(chunk)) + chunk = [] + if chunk: + log.info("- %s", ", ".join(chunk)) + except Exception: + pass + + +def setup_api_routes() -> None: + """IAMCCS API routes used by frontend widgets.""" + + try: + from server import PromptServer + from aiohttp import web + + from .iamccs_hw_probe import recommend_settings + + routes = PromptServer.instance.routes + + @routes.get("/api/iamccs/hw_probe") + async def iamccs_hw_probe_endpoint(request): + try: + q = request.rel_url.query + def _to_int(x): + try: + return int(float(x)) + except Exception: + return None + def _to_float(x): + try: + return float(x) + except Exception: + return None + + width = _to_int(q.get("width")) + height = _to_int(q.get("height")) + frames = _to_int(q.get("frames")) + fps = _to_float(q.get("fps")) + + data = recommend_settings(width=width, height=height, frames=frames, fps=fps) + logging.getLogger("IAMCCS.API").info( + "[iamccs/hw_probe] cuda=%s vram_gb=%s ram_gb=%s profile=%s vae_tile=%s frames=%s fps=%s", + data.get("hardware", {}).get("cuda_available"), + data.get("hardware", {}).get("cuda_total_vram_gb"), + data.get("hardware", {}).get("system_ram_gb"), + data.get("recommendations", {}).get("hw_supporter", {}).get("profile"), + data.get("recommendations", {}).get("vae_decode", {}).get("tile_size"), + frames, + fps, + ) + return web.json_response(data) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + except Exception as e: + # Never hard-fail ComfyUI startup due to optional API endpoints. + logging.getLogger("IAMCCS.API").warning("Could not setup IAMCCS API routes: %r", e) + + +# Setup API routes when extension loads +setup_api_routes() + +# Print banner after we are fully imported and mappings exist. +_print_startup_banner() + + +def _iamccs_install_ltx2_vae_encode_autofix() -> None: + """Prevents hard-crash when LTX-2 VAE receives invalid frame counts. + + Lightricks video VAE encode requires a frame count of the form 1 + 8*x. + Some workflows can produce off-by-a-few batches (e.g. 240 instead of 241), + which otherwise raises ValueError and stops execution. + + This patch pads by repeating the last frame up to the next valid count. + Opt-in via IAMCCS_LTX2_VAE_ENCODE_AUTOFIX=1. + """ + + # Default OFF: user requested workflow-level fixes without monkeypatching VAE. + if str(os.getenv("IAMCCS_LTX2_VAE_ENCODE_AUTOFIX", "0")).strip().lower() in {"0", "false", "no", "off"}: + return + + log = logging.getLogger("IAMCCS.LTX2.VAE") + + try: + import torch + except Exception: + return + + try: + from comfy.ldm.lightricks.vae import causal_video_autoencoder as _cvae + except Exception: + # ComfyUI / LTXVideo not installed or import path changed. + return + + cls = getattr(_cvae, "CausalVideoAutoencoder", None) + if cls is None: + return + + orig_encode = getattr(cls, "encode", None) + if orig_encode is None: + return + + if getattr(orig_encode, "__iamccs_ltx2_autofix__", False): + return + + def _round_up_8n1(frames: int) -> int: + frames = int(frames) + if frames <= 1: + return 1 + rem = (frames - 1) % 8 + if rem == 0: + return frames + return frames + (8 - rem) + + def _is_valid_8n1(frames: int) -> bool: + frames = int(frames) + return frames >= 1 and (frames - 1) % 8 == 0 + + def _pad_repeat_last(x: "torch.Tensor", dim: int, pad: int) -> "torch.Tensor": + # Take last slice along `dim` (keeps dimension) and repeat it `pad` times. + slc = [slice(None)] * x.ndim + slc[dim] = slice(-1, None) + last = x[tuple(slc)] + reps = [1] * x.ndim + reps[dim] = int(pad) + last_rep = last.repeat(*reps) + return torch.cat([x, last_rep], dim=dim) + + def _candidate_frame_dims(x: "torch.Tensor") -> list[int]: + # Most common layouts: + # - (B, C, T, H, W) -> frames dim = 2 + # - (T, H, W, C) -> frames dim = 0 (ComfyUI IMAGE batches) + # We only try dims that are >1 and *not obviously channels*. + dims: list[int] = [] + if x.ndim == 5: + # Prefer T, then fallbacks + dims = [2, 0, 1] + elif x.ndim == 4: + dims = [0] + else: + dims = [0] + + out: list[int] = [] + for d in dims: + try: + size = int(x.shape[d]) + except Exception: + continue + if size <= 1: + continue + # Heuristic: channels are usually small (1..4). Don't treat that as frames. + if size in (1, 2, 3, 4) and x.ndim >= 4 and d in (1, 3): + continue + out.append(d) + # Ensure uniqueness, preserve order + seen = set() + unique: list[int] = [] + for d in out: + if d in seen: + continue + seen.add(d) + unique.append(d) + return unique + + def encode_patched(self, pixels_in: "torch.Tensor"): + try: + return orig_encode(self, pixels_in) + except ValueError as e: + msg = str(e) + if "Invalid number of frames" not in msg: + raise + + if not isinstance(pixels_in, torch.Tensor) or pixels_in.ndim < 4: + raise + + # Try padding along the most likely frame dimension(s). + last_err: Exception | None = e + for dim in _candidate_frame_dims(pixels_in): + frames_in = int(pixels_in.shape[dim]) + if _is_valid_8n1(frames_in): + continue + + frames_fixed = _round_up_8n1(frames_in) + pad = frames_fixed - frames_in + if pad <= 0: + continue + + try: + pixels_fixed = _pad_repeat_last(pixels_in, dim=dim, pad=pad) + log.warning( + "[LTX2 VAE encode autofix] Padded frames dim=%d %d -> %d (pad=%d) to satisfy 1+8*x rule", + dim, + frames_in, + frames_fixed, + pad, + ) + return orig_encode(self, pixels_fixed) + except Exception as ee: + last_err = ee + continue + + # If all attempts failed, re-raise the original ValueError. + raise e + + encode_patched.__iamccs_ltx2_autofix__ = True + setattr(cls, "encode", encode_patched) + + +_iamccs_install_ltx2_vae_encode_autofix() diff --git a/backup/20260420_auimg2vid_goal1/backup_note.txt b/backup/20260420_auimg2vid_goal1/backup_note.txt new file mode 100644 index 0000000..40c2bca --- /dev/null +++ b/backup/20260420_auimg2vid_goal1/backup_note.txt @@ -0,0 +1 @@ +Goal 1 AU+IMG2VID modular nodes bootstrap: planner, audio timeline, continuity, finalize contracts. diff --git a/backup/web_disabled/iamccs_ltx2_time_length_sync.20260424_073005.settings_preview_sync.bak.js b/backup/web_disabled/iamccs_ltx2_time_length_sync.20260424_073005.settings_preview_sync.bak.js new file mode 100644 index 0000000..2510814 --- /dev/null +++ b/backup/web_disabled/iamccs_ltx2_time_length_sync.20260424_073005.settings_preview_sync.bak.js @@ -0,0 +1,560 @@ +// IAMCCS LTX-2 seconds <-> length sync +// Frontend-only helper for: +// - IAMCCS_LTX2_TimeFrameCount +// - IAMCCS_LTX2_Validator +// Uses FPS to convert: +// length(frames) = 1 + seconds * fps +// seconds = (length-1) / fps + +import { app } from "../../scripts/app.js"; + +const TIMEFRAME_TYPE = "IAMCCS_LTX2_TimeFrameCount"; +const VALIDATOR_TYPE = "IAMCCS_LTX2_Validator"; +const FRAMERATE_SYNC_TYPE = "IAMCCS_LTX2_FrameRateSync"; +const SEGMENT_PLANNER_TYPE = "IAMCCS_SegmentPlanner"; +const SEGMENT_PLANNER_LINKED_TYPE = "IAMCCS_SegmentPlannerLinked"; +const SEGMENT_PLANNER_SETTINGS_TYPE = "IAMCCS_SegmentPlannerSettings"; +const SEGMENT_PLANNER_PREVIEW_WIDGET = "iamccs_segmentplanner_live_preview"; +const SEGMENT_PLANNER_DETAILS_WIDGET = "iamccs_segmentplanner_live_details"; +const SEGMENT_PLANNER_COPY_BUTTON = "iamccs_segmentplanner_copy_button"; +const SEGMENT_PLANNER_SETTINGS_REPORT_WIDGET = "iamccs_segmentplanner_settings_live_report"; + +console.log("[IAMCCS LTX2] Loading seconds/length sync..."); + +function getWidget(node, name) { + if (!node?.widgets?.length) return null; + return node.widgets.find(w => w?.name === name || w?.label === name) || null; +} + +function getInput(node, name) { + if (!node?.inputs?.length) return null; + return node.inputs.find(i => i?.name === name) || null; +} + +function getNodeById(nodeId) { + if (nodeId == null) return null; + return app?.graph?._nodes_by_id?.[nodeId] || app?.graph?._nodes?.find(n => n?.id === nodeId) || null; +} + +function getLinkedSource(node, inputName) { + const input = getInput(node, inputName); + const linkId = input?.link; + if (linkId == null) return null; + + const link = app?.graph?.links?.[linkId]; + if (!link) return null; + + const originId = Array.isArray(link) ? link[1] : link.origin_id; + const originSlot = Array.isArray(link) ? link[2] : link.origin_slot; + const sourceNode = getNodeById(originId); + if (!sourceNode) return null; + + // Defensive: outputs must exist and be an array, originSlot must be valid + let sourceOutput = null; + if (Array.isArray(sourceNode.outputs) && originSlot != null && originSlot >= 0 && originSlot < sourceNode.outputs.length) { + sourceOutput = sourceNode.outputs[originSlot]; + } + + return { + sourceNode, + sourceOutput, + }; +} + +function getLinkedPrimitiveValue(node, inputName) { + const source = getLinkedSource(node, inputName); + if (!source?.sourceNode) return null; + + const sourceNode = source.sourceNode; + const outputName = String(source.sourceOutput?.name || ""); + const candidateWidgetNames = [outputName, inputName, "value", "float", "int", "string", "text"]; + + // Defensive: try to get value from output if present + if (source.sourceOutput && typeof source.sourceOutput.value !== "undefined") { + return source.sourceOutput.value; + } + + for (const widgetName of candidateWidgetNames) { + const widget = getWidget(sourceNode, widgetName); + if (widget?.value != null) return widget.value; + } + + if (Array.isArray(sourceNode.widgets) && sourceNode.widgets.length > 0) { + const widgetValue = sourceNode.widgets[0]?.value; + if (widgetValue != null) return widgetValue; + } + + return null; +} + +function clampNumber(v, min, max) { + const n = Number(v); + if (!Number.isFinite(n)) return min; + return Math.max(min, Math.min(max, n)); +} + +function getGraphFpsOrDefault(defaultFps = 25) { + try { + const nodes = app?.graph?._nodes || []; + const fr = nodes.find(n => n?.type === FRAMERATE_SYNC_TYPE); + if (!fr) return defaultFps; + + const wFps = getWidget(fr, "fps"); + const wMode = getWidget(fr, "int_mode"); + const fpsIn = clampNumber(wFps?.value ?? defaultFps, 1.0, 240.0); + const mode = String(wMode?.value || "round"); + + if (mode === "floor") return Math.max(1, Math.floor(fpsIn)); + if (mode === "ceil") return Math.max(1, Math.ceil(fpsIn)); + if (mode === "fixed") return Math.max(1, Math.round(fpsIn)); + // round + return Math.max(1, Math.round(fpsIn)); + } catch (e) { + return defaultFps; + } +} + +function snapLengthToLtx2RuleUp(length) { + // LTX-2 constraint: 1 + 8*x frames + const n = Math.max(1, Math.round(Number(length) || 1)); + const rem = (n - 1) % 8; + if (rem === 0) return n; + return n + (8 - rem); +} + +function snapLengthToLtx2Rule(length, mode = "up") { + const n = Math.max(1, Math.round(Number(length) || 1)); + const rem = (n - 1) % 8; + if (rem === 0) return n; + const down = Math.max(1, n - rem); + const up = n + (8 - rem); + if (mode === "down") return down; + if (mode === "nearest") return (up - n) <= (n - down) ? up : down; + return up; +} + +function ensurePlannerPreviewWidget(node) { + let widget = getWidget(node, SEGMENT_PLANNER_PREVIEW_WIDGET); + if (widget) return widget; + + widget = node.addWidget("text", "Live Preview", "", () => {}, { multiline: true }); + widget.name = SEGMENT_PLANNER_PREVIEW_WIDGET; + widget.serialize = false; + try { + widget.inputEl?.setAttribute?.("readonly", true); + } catch { + // ignore + } + return widget; +} + +function ensurePlannerDetailsWidget(node) { + let widget = getWidget(node, SEGMENT_PLANNER_DETAILS_WIDGET); + if (widget) return widget; + + widget = node.addWidget("text", "Plan Details", "", () => {}, { multiline: true }); + widget.name = SEGMENT_PLANNER_DETAILS_WIDGET; + widget.serialize = false; + try { + widget.inputEl?.setAttribute?.("readonly", true); + } catch { + // ignore + } + return widget; +} + +function ensurePlannerCopyButton(node) { + let widget = getWidget(node, SEGMENT_PLANNER_COPY_BUTTON); + if (widget) return widget; + + widget = node.addWidget("button", "Copy Planner Report", null, async () => { + try { + const summary = String(node.properties?.iamccs_segmentplanner_live_preview || ""); + const details = String(node.properties?.iamccs_segmentplanner_live_details || ""); + const text = [summary, details].filter(Boolean).join("\n\n"); + await navigator.clipboard.writeText(text); + } catch { + // ignore + } + }); + widget.name = SEGMENT_PLANNER_COPY_BUTTON; + widget.serialize = false; + return widget; +} + +function ensurePlannerSettingsReportWidget(node) { + let widget = getWidget(node, SEGMENT_PLANNER_SETTINGS_REPORT_WIDGET); + if (widget) return widget; + + widget = node.addWidget("text", "Live Settings", "", () => {}, { multiline: true }); + widget.name = SEGMENT_PLANNER_SETTINGS_REPORT_WIDGET; + widget.serialize = false; + try { + widget.inputEl?.setAttribute?.("readonly", true); + } catch { + // ignore + } + return widget; +} + +function ensurePlannerNodeSize(node) { + try { + const width = Math.max(460, Number(node.size?.[0] || 0)); + const height = Math.max(node?.type === SEGMENT_PLANNER_SETTINGS_TYPE ? 300 : 520, Number(node.size?.[1] || 0)); + node.size = [width, height]; + } catch { + // ignore + } +} + +function refreshLinkedPlannerPreviews() { + try { + const nodes = app?.graph?._nodes || []; + for (const node of nodes) { + if (node?.type === SEGMENT_PLANNER_LINKED_TYPE) { + updateSegmentPlannerPreview(node); + } + } + } catch { + // ignore + } +} + +function updateSegmentPlannerSettingsReport(node) { + const wSeg = getWidget(node, "segment_duration_s"); + const wPlanning = getWidget(node, "planning_mode"); + const wProfile = getWidget(node, "segment_preset") || getWidget(node, "content_profile"); + const wOverlap = getWidget(node, "overlap_frames"); + const wAutoSync = getWidget(node, "auto_sync_overlap"); + if (!wSeg || !wPlanning || !wProfile || !wOverlap || !wAutoSync) return; + + const segmentDuration = clampNumber(wSeg.value, 0.01, 3600.0); + const planningMode = String(wPlanning.value || "manual_segment_seconds"); + const segmentPreset = String(wProfile.value || "10sec"); + const autoSyncOverlap = Boolean(wAutoSync.value); + + const rec = getPlannerProfile(segmentPreset); + const explicitPresetMode = planningMode === "explicit_preset_seconds" || planningMode === "auto_profile"; + const autoDurationProfile = !explicitPresetMode ? getAutoOverlapProfileForDuration(segmentDuration) : null; + const effectiveSegmentDuration = explicitPresetMode ? rec.segmentSeconds : segmentDuration; + const effectiveOverlapFrames = explicitPresetMode + ? rec.overlap + : (autoSyncOverlap ? (autoDurationProfile?.overlap ?? clampNumber(wOverlap.value, 0, 4096)) : clampNumber(wOverlap.value, 0, 4096)); + + if (autoSyncOverlap && Number(wOverlap.value) !== effectiveOverlapFrames) { + wOverlap.value = effectiveOverlapFrames; + } + + const report = [ + `segment_duration_s = ${effectiveSegmentDuration.toFixed(3)}`, + `planning_mode = ${planningMode}`, + `segment_preset = ${segmentPreset}`, + `overlap_frames = ${effectiveOverlapFrames}`, + `auto_sync_overlap = ${autoSyncOverlap}`, + `auto_duration_profile = ${autoDurationProfile?.profileLabel || "none"}`, + `recommended_extension_preset = ${rec.preset}`, + ].join("\n"); + + const widget = ensurePlannerSettingsReportWidget(node); + widget.value = report; + node.properties = node.properties || {}; + node.properties.iamccs_segmentplanner_settings_live_report = report; + ensurePlannerNodeSize(node); + try { + node.setDirtyCanvas(true, true); + } catch { + // ignore + } +} + +function getPlannerProfile(presetOrLegacyProfile) { + const value = String(presetOrLegacyProfile || "15sec"); + if (value === "20sec") { + return { segmentSeconds: 20.0, overlap: 17, leftContext: 1.0, preset: "monologue_audio_24fps", profileLabel: "20sec" }; + } + if (value === "15sec" || value === "monologue") { + return { segmentSeconds: 15.0, overlap: 13, leftContext: 0.75, preset: "monologue_audio_24fps", profileLabel: "15sec" }; + } + if (value === "5sec") { + return { segmentSeconds: 5.0, overlap: 5, leftContext: 0.25, preset: "videoclip_audio_24fps", profileLabel: "5sec" }; + } + return { segmentSeconds: 10.0, overlap: 9, leftContext: 0.5, preset: "videoclip_audio_24fps", profileLabel: "10sec" }; +} + +function getAutoOverlapProfileForDuration(segmentDuration) { + const duration = Number(segmentDuration); + if (!Number.isFinite(duration)) return null; + const profiles = [ + getPlannerProfile("5sec"), + getPlannerProfile("10sec"), + getPlannerProfile("15sec"), + getPlannerProfile("20sec"), + ]; + return profiles.find(profile => Math.abs(duration - profile.segmentSeconds) <= 0.1) || null; +} + +function updateSegmentPlannerPreview(node) { + const wSong = getWidget(node, "song_duration_s"); + const wFps = getWidget(node, "fps"); + const wSeg = getWidget(node, "segment_duration_s"); + const wPlanning = getWidget(node, "planning_mode") || getWidget(node, "planning_mode_in"); + const wProfile = getWidget(node, "segment_preset") || getWidget(node, "segment_preset_in") || getWidget(node, "content_profile"); + const wOverlap = getWidget(node, "overlap_frames") || getWidget(node, "overlap_frames_in"); + const wRound = getWidget(node, "ltx_round_mode"); + const wIndex = getWidget(node, "segment_index"); + if (!wSong || !wFps || !wSeg || !wPlanning || !wProfile || !wOverlap || !wRound || !wIndex) return; + + const songDuration = clampNumber(wSong.value, 0.01, 36000); + const fps = clampNumber(wFps.value, 0.001, 240.0); + const linkedSegmentDuration = node?.type === SEGMENT_PLANNER_LINKED_TYPE ? getLinkedPrimitiveValue(node, "segment_duration_s") : null; + const linkedPlanningMode = node?.type === SEGMENT_PLANNER_LINKED_TYPE ? getLinkedPrimitiveValue(node, "planning_mode_in") : null; + const linkedSegmentPreset = node?.type === SEGMENT_PLANNER_LINKED_TYPE ? getLinkedPrimitiveValue(node, "segment_preset_in") : null; + const linkedOverlapFrames = node?.type === SEGMENT_PLANNER_LINKED_TYPE ? getLinkedPrimitiveValue(node, "overlap_frames_in") : null; + + const segmentDuration = clampNumber(linkedSegmentDuration ?? wSeg.value, 0.01, 3600.0); + const planningMode = String(linkedPlanningMode ?? wPlanning.value || "manual_segment_seconds"); + const segmentPreset = String(linkedSegmentPreset ?? wProfile.value || "15sec"); + const inputOverlapFrames = clampNumber(linkedOverlapFrames ?? wOverlap.value, 0, 4096); + const roundMode = String(wRound.value || "up"); + const segmentIndex = Math.max(0, Math.trunc(Number(wIndex.value || 0))); + + const rec = getPlannerProfile(segmentPreset); + const explicitPresetMode = planningMode === "explicit_preset_seconds" || planningMode === "auto_profile"; + const autoDurationProfile = !explicitPresetMode ? getAutoOverlapProfileForDuration(segmentDuration) : null; + const effectiveSegmentDuration = explicitPresetMode ? rec.segmentSeconds : segmentDuration; + const effectiveOverlapFrames = explicitPresetMode + ? rec.overlap + : (autoDurationProfile?.overlap ?? inputOverlapFrames); + + if (Number(wOverlap.value) !== effectiveOverlapFrames) { + wOverlap.value = effectiveOverlapFrames; + } + const overlapFrames = clampNumber(wOverlap.value, 0, 4096); + + const totalFrames = Math.max(1, Math.round(songDuration * fps)); + const uniqueFrames = Math.max(1, Math.round(effectiveSegmentDuration * fps)); + const firstRaw = snapLengthToLtx2Rule(uniqueFrames, roundMode); + const nextRaw = snapLengthToLtx2Rule(uniqueFrames + effectiveOverlapFrames, roundMode); + const segments = Math.max(1, Math.ceil(totalFrames / uniqueFrames)); + const loops = Math.max(0, segments - 1); + const usedBeforeLast = uniqueFrames * Math.max(0, segments - 1); + const lastUnique = Math.max(1, totalFrames - usedBeforeLast); + const clampedIndex = Math.min(segmentIndex, Math.max(0, segments - 1)); + const currentStart = uniqueFrames * clampedIndex; + const currentUnique = Math.max(1, Math.min(uniqueFrames, totalFrames - currentStart)); + const currentEnd = Math.min(totalFrames, currentStart + currentUnique); + const currentRemaining = Math.max(0, totalFrames - currentEnd); + const currentRaw = clampedIndex === 0 ? firstRaw : nextRaw; + const currentStartS = currentStart / fps; + const currentEndS = currentEnd / fps; + + const summary = [ + `total=${totalFrames}f`, + `unique=${uniqueFrames}f`, + `mode=${planningMode}`, + `profile=${segmentPreset}`, + `first_raw=${firstRaw}f`, + `next_raw=${nextRaw}f`, + `segments=${segments}`, + `loops=${loops}`, + `last_unique=${lastUnique}f`, + `seg=${clampedIndex}`, + `cur_raw=${currentRaw}f`, + `cur_unique=${currentUnique}f`, + `cur=[${currentStart}..${currentEnd})`, + `remain=${currentRemaining}f`, + ].join(" | "); + + const details = [ + `song_duration_s = ${songDuration.toFixed(3)}`, + `fps = ${fps.toFixed(3)}`, + `segment_duration_s = ${segmentDuration.toFixed(3)}`, + `effective_segment_duration_s = ${effectiveSegmentDuration.toFixed(3)}`, + `planning_mode = ${planningMode}`, + `segment_preset = ${segmentPreset}`, + `overlap_frames = ${overlapFrames}`, + `effective_overlap_frames = ${effectiveOverlapFrames}`, + `ltx_round_mode = ${roundMode}`, + `segment_index = ${clampedIndex}`, + `total_frames = ${totalFrames}`, + `unique_segment_frames = ${uniqueFrames}`, + `first_segment_raw_frames = ${firstRaw}`, + `continuation_raw_frames = ${nextRaw}`, + `estimated_segments = ${segments}`, + `continuation_loops = ${loops}`, + `last_segment_unique_frames = ${lastUnique}`, + `current_segment_raw_frames = ${currentRaw}`, + `current_segment_unique_frames = ${currentUnique}`, + `current_segment_start_frames = ${currentStart}`, + `current_segment_end_frames = ${currentEnd}`, + `current_remaining_frames_after = ${currentRemaining}`, + `current_segment_start_s = ${currentStartS.toFixed(3)}`, + `current_segment_end_s = ${currentEndS.toFixed(3)}`, + `recommended_audio_left_context_s = ${rec.leftContext.toFixed(2)}`, + `recommended_extension_preset = ${rec.preset}`, + `auto_duration_profile = ${autoDurationProfile?.profileLabel || "none"}`, + ].join("\n"); + + const widget = ensurePlannerPreviewWidget(node); + widget.value = summary; + const detailsWidget = ensurePlannerDetailsWidget(node); + detailsWidget.value = details; + ensurePlannerCopyButton(node); + ensurePlannerNodeSize(node); + node.properties = node.properties || {}; + node.properties.iamccs_segmentplanner_live_preview = summary; + node.properties.iamccs_segmentplanner_live_details = details; + try { + node.setDirtyCanvas(true, true); + } catch { + // ignore + } +} + +function installSegmentPlannerSync(node) { + const widgets = [ + getWidget(node, "song_duration_s"), + getWidget(node, "fps"), + getWidget(node, "segment_duration_s"), + getWidget(node, "planning_mode") || getWidget(node, "planning_mode_in"), + getWidget(node, "segment_preset") || getWidget(node, "segment_preset_in") || getWidget(node, "content_profile"), + getWidget(node, "overlap_frames") || getWidget(node, "overlap_frames_in"), + getWidget(node, "ltx_round_mode"), + getWidget(node, "segment_index"), + ].filter(Boolean); + + if (!widgets.length || node._iamccsSegmentPlannerSyncInstalled) { + updateSegmentPlannerPreview(node); + return; + } + + node._iamccsSegmentPlannerSyncInstalled = true; + ensurePlannerPreviewWidget(node); + ensurePlannerDetailsWidget(node); + ensurePlannerCopyButton(node); + ensurePlannerNodeSize(node); + + for (const widget of widgets) { + const prev = widget.callback; + widget.callback = function () { + const r = prev?.apply(this, arguments); + updateSegmentPlannerPreview(node); + return r; + }; + } + + updateSegmentPlannerPreview(node); +} + +function installSegmentPlannerSettingsSync(node) { + const widgets = [ + getWidget(node, "segment_duration_s"), + getWidget(node, "planning_mode"), + getWidget(node, "segment_preset"), + getWidget(node, "overlap_frames"), + getWidget(node, "auto_sync_overlap"), + ].filter(Boolean); + + if (!widgets.length || node._iamccsSegmentPlannerSettingsSyncInstalled) { + updateSegmentPlannerSettingsReport(node); + return; + } + + node._iamccsSegmentPlannerSettingsSyncInstalled = true; + ensurePlannerSettingsReportWidget(node); + ensurePlannerNodeSize(node); + + for (const widget of widgets) { + const prev = widget.callback; + widget.callback = function () { + const r = prev?.apply(this, arguments); + updateSegmentPlannerSettingsReport(node); + refreshLinkedPlannerPreviews(); + return r; + }; + } + + updateSegmentPlannerSettingsReport(node); + refreshLinkedPlannerPreviews(); +} + +function installSecondsLengthSync(node, { snapRule = false } = {}) { + const wSeconds = getWidget(node, "seconds"); + const wLength = getWidget(node, "length"); + if (!wSeconds || !wLength) return; + + let updating = false; + + const updateLengthFromSeconds = () => { + const fps = getGraphFpsOrDefault(25); + const seconds = clampNumber(wSeconds.value, 0.01, 3600); + let length = clampNumber(1 + Math.round(seconds * fps), 1, 16385); + if (snapRule) length = snapLengthToLtx2RuleUp(length); + wLength.value = length; + }; + + const updateSecondsFromLength = () => { + const fps = getGraphFpsOrDefault(25); + const length = clampNumber(wLength.value, 1, 16385); + const seconds = (length - 1) / fps; + // keep precision consistent with widget step 0.01 + wSeconds.value = Math.round(clampNumber(seconds, 0.01, 3600) * 100) / 100; + }; + + const hookWidget = (widget, fn) => { + const prev = widget.callback; + widget.callback = function () { + const r = prev?.apply(this, arguments); + if (updating) return r; + updating = true; + try { + fn(); + node.setDirtyCanvas(true, true); + } finally { + updating = false; + } + return r; + }; + }; + + hookWidget(wSeconds, updateLengthFromSeconds); + hookWidget(wLength, updateSecondsFromLength); +} + +app.registerExtension({ + name: "iamccs.ltx2.time_length_sync", + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (!nodeData?.name) return; + const name = nodeData.name; + + if (name !== TIMEFRAME_TYPE && name !== VALIDATOR_TYPE && name !== SEGMENT_PLANNER_TYPE && name !== SEGMENT_PLANNER_LINKED_TYPE && name !== SEGMENT_PLANNER_SETTINGS_TYPE) return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = onNodeCreated?.apply(this, arguments); + if (name === SEGMENT_PLANNER_TYPE || name === SEGMENT_PLANNER_LINKED_TYPE) { + installSegmentPlannerSync(this); + } else if (name === SEGMENT_PLANNER_SETTINGS_TYPE) { + installSegmentPlannerSettingsSync(this); + } else { + // Snap to 8n+1 on both nodes, since LTX-2 VAE encode requires it. + installSecondsLengthSync(this, { snapRule: true }); + } + return r; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const r = onConfigure?.apply(this, arguments); + if (name === SEGMENT_PLANNER_TYPE || name === SEGMENT_PLANNER_LINKED_TYPE) { + installSegmentPlannerSync(this); + updateSegmentPlannerPreview(this); + } else if (name === SEGMENT_PLANNER_SETTINGS_TYPE) { + installSegmentPlannerSettingsSync(this); + updateSegmentPlannerSettingsReport(this); + } + return r; + }; + }, +}); diff --git a/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_explicit_segment_presets.bak.js b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_explicit_segment_presets.bak.js new file mode 100644 index 0000000..96d96ef --- /dev/null +++ b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_explicit_segment_presets.bak.js @@ -0,0 +1,577 @@ +import { app } from "../../../scripts/app.js"; + +const PRESET_CONFIGS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { modular_decode: "low_ram", steps: 16, image_compression: 40, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + balanced: { modular_decode: "normal", steps: 20, image_compression: 33, continuity_anchor_mode: "off", anchor_refresh_interval: 3, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + high_quality: { modular_decode: "high", steps: 24, image_compression: 28, continuity_anchor_mode: "off", anchor_refresh_interval: 1, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + fast_preview: { modular_decode: "low_ram", steps: 12, image_compression: 45, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + }, + visibility: { + low_ram_safe: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + balanced: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + high_quality: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + fast_preview: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: false }, + }, + }, + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { decode_mode: "low_ram", jpg_quality: 92, crf: 21, tiled_tile_size: 512, tiled_overlap: 64 }, + balanced: { decode_mode: "normal", jpg_quality: 95, crf: 19, tiled_tile_size: 512, tiled_overlap: 64 }, + high_quality: { decode_mode: "high", jpg_quality: 100, crf: 16, tiled_tile_size: 768, tiled_overlap: 96 }, + fast_preview: { decode_mode: "low_ram", jpg_quality: 88, crf: 24, tiled_tile_size: 384, tiled_overlap: 48 }, + }, + visibility: { + low_ram_safe: { frames_subdir: true, image_format: true, jpg_quality: true }, + balanced: { frames_subdir: true, image_format: true, jpg_quality: true }, + high_quality: { frames_subdir: true, image_format: true, jpg_quality: true }, + fast_preview: { frames_subdir: true, image_format: true, jpg_quality: true }, + }, + }, +}; + +const NODE_GROUPS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Planner": [ + { key: "timeline", label: "Timeline", color: "#2d6f63", widgets: ["fps", "segment_seconds"] }, + { key: "planning", label: "Planning", color: "#6d5d2a", widgets: ["planning_mode", "content_profile", "overlap_frames", "ltx_round_mode"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": [ + { key: "preset", label: "Preset", color: "#445c75", widgets: ["ui_preset"] }, + { key: "prompts", label: "Prompting", color: "#6b4f2a", widgets: ["positive_text", "negative_text"] }, + { key: "video", label: "Video", color: "#2f4d7a", widgets: ["width", "height"] }, + { key: "sampling", label: "Sampling", color: "#5b3a73", widgets: ["steps", "cfg", "sampler_name", "seed", "max_shift", "base_shift", "sigma_terminal", "manual_sigmas", "image_strength", "image_compression"] }, + { key: "audio", label: "Audio", color: "#2f6a47", widgets: ["audio_context_mode", "audio_left_context_s", "audio_right_context_s"] }, + { key: "stitch", label: "Stitch", color: "#7a4f2f", widgets: ["stitch_preset", "overlap_side", "overlap_mode", "start_frames_rule"] }, + { key: "anchor", label: "Anchor", color: "#8a4d4d", widgets: ["continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength"] }, + { key: "modular", label: "Modular", color: "#35566e", widgets: ["modular_decode", "downstream_stage_mode", "output_root"] }, + { key: "debug", label: "Debug", color: "#5b5b2f", widgets: ["segment_overlay_mode", "segment_overlay_text"] }, + ], + "IAMCCS-SuperNodes Second Stage": [ + { key: "stage2", label: "Second Stage", color: "#6f4a7f", widgets: ["second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas"] }, + ], + IAMCCS_GC_AudioConcatSupernode: [ + { key: "concat", label: "Audio Concat", color: "#3e6f55", widgets: ["concat_mode", "clip_durations_seconds", "gap_seconds", "intro_seconds", "outro_seconds"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": [ + { key: "preset", label: "Preset", color: "#445c75", widgets: ["ui_preset"] }, + { key: "decode", label: "Decode", color: "#3e5f82", widgets: ["frame_rate", "decode_mode", "output_root", "frames_subdir", "image_format", "jpg_quality", "tiled_tile_size", "tiled_overlap"] }, + { key: "final", label: "Finalize", color: "#6c5330", widgets: ["filename_prefix", "crf", "pix_fmt", "trim_to_audio", "save_metadata"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Finalize": [ + { key: "output", label: "Output", color: "#556677", widgets: ["frame_rate", "filename_prefix", "crf", "pix_fmt", "trim_to_audio"] }, + ], +}; + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget?.name === name); +} + +function setWidgetVisibility(widget, visible) { + if (!widget || widget.type === "converted-widget") { + return; + } + + widget.hidden = !visible; + widget.disabled = !visible; + + if (widget.element) { + widget.element.style.display = visible ? "" : "none"; + } + if (widget.inputEl) { + widget.inputEl.style.display = visible ? "" : "none"; + } + + if (visible) { + if (Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.computeSize = widget.__iamccsOrigComputeSize; + } else { + delete widget.computeSize; + } + } else { + if (!Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.__iamccsOrigComputeSize = widget.computeSize; + } + widget.computeSize = () => [0, -4]; + widget.y = undefined; + widget.last_y = undefined; + } +} + +function fitNodeToWidgets(node) { + const size = node.computeSize?.() || node.size; + node.setSize([ + Math.max(node.size?.[0] || 0, size[0]), + Math.max(size[1], size[1] + 8), + ]); +} + +function insertWidget(node, widget, index) { + const widgets = node.widgets || []; + const currentIndex = widgets.indexOf(widget); + if (currentIndex >= 0) { + widgets.splice(currentIndex, 1); + } + widgets.splice(index, 0, widget); +} + +function setWidgetValue(node, widgetName, value) { + const widget = findWidget(node, widgetName); + if (!widget || widget.value === value) { + return; + } + widget.value = value; + widget.callback?.(value); +} + +function getLinxTargets(node) { + const results = []; + for (const output of node.outputs || []) { + if (output?.type !== "IAMCCS_SUPERNODE_LINX") { + continue; + } + for (const linkId of output.links || []) { + const link = app.graph.links?.[linkId]; + if (!link) { + continue; + } + const targetNode = app.graph.getNodeById?.(link.target_id); + if (targetNode) { + results.push(targetNode); + } + } + } + return results; +} + +function applyVaeDecodeModeVisibility(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + return; + } + const decodeMode = String(findWidget(node, "decode_mode")?.value || "low_ram"); + const isLowRam = decodeMode === "low_ram"; + const isNormal = decodeMode === "normal"; + setWidgetVisibility(findWidget(node, "frames_subdir"), isLowRam); + setWidgetVisibility(findWidget(node, "image_format"), isLowRam); + setWidgetVisibility(findWidget(node, "jpg_quality"), isLowRam); + setWidgetVisibility(findWidget(node, "tiled_tile_size"), isNormal); + setWidgetVisibility(findWidget(node, "tiled_overlap"), isNormal); + fitNodeToWidgets(node); +} + +function syncDownstreamVaeDecodeModes(renderNode) { + const renderDecode = String(findWidget(renderNode, "modular_decode")?.value || "low_ram"); + const visited = new Set(); + const queue = [...getLinxTargets(renderNode)]; + while (queue.length > 0) { + const node = queue.shift(); + if (!node || visited.has(node.id)) { + continue; + } + visited.add(node.id); + if (node.comfyClass === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + setWidgetValue(node, "decode_mode", renderDecode); + setWidgetValue(node, "ui_preset", "custom"); + applyVaeDecodeModeVisibility(node); + app.graph.setDirtyCanvas(true, true); + continue; + } + if (node.comfyClass === "IAMCCS-SuperNodes Second Stage") { + queue.push(...getLinxTargets(node)); + } + } +} + +function applyPresetConfig(node, nodeName) { + const config = PRESET_CONFIGS[nodeName]; + if (!config) { + return; + } + const presetWidget = findWidget(node, config.presetWidget); + if (!presetWidget) { + return; + } + const presetName = String(presetWidget.value || config.defaultPreset || "custom"); + if (presetName !== "custom") { + const values = config.values?.[presetName] || {}; + Object.entries(values).forEach(([widgetName, widgetValue]) => setWidgetValue(node, widgetName, widgetValue)); + } + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + fitNodeToWidgets(node); +} + +function getSerializableWidgets(node) { + return (node.widgets || []).filter((widget) => widget && widget.serialize !== false); +} + +function rehydrateSerializedWidgets(node, serializedValues) { + if (!Array.isArray(serializedValues) || !node.widgets?.length) { + return; + } + + const widgets = getSerializableWidgets(node); + const count = Math.min(widgets.length, serializedValues.length); + for (let index = 0; index < count; index += 1) { + const widget = widgets[index]; + if (!widget) { + continue; + } + widget.value = serializedValues[index]; + } +} + +function addSectionButton(node, group, index) { + // Duplicate guard: if a button for this section key is already in the widget list, + // return it immediately. This prevents double-insertion if onNodeCreated or + // enhanceNodeLayout is called more than once on the same node instance. + const existing = node.widgets?.find((w) => w._iamccsSectionKey === group.key); + if (existing) { + return existing; + } + + node.properties = node.properties || {}; + const propKey = `iamccs_section_${group.key}`; + if (node.properties[propKey] === undefined) { + node.properties[propKey] = true; + } + + const sectionButton = node.addWidget("button", "", "", () => { + node.properties[propKey] = !node.properties[propKey]; + applyGroupVisibility(node, group, propKey, sectionButton); + }); + // IMPORTANT: do NOT set serialize = false. + // Section buttons must occupy a slot in widgets_values so that ComfyUI's + // index-based configure() assignment keeps real widget values aligned. + // Their value (empty string / null) is harmless. + sectionButton._iamccsSectionKey = group.key; + sectionButton.computeSize = (width) => [width || 280, 28]; + sectionButton.label = group.label; + sectionButton.options = { bgcolor: group.color || "#405060", color: "#f4f7fa" }; + + applyGroupVisibility(node, group, propKey, sectionButton); + insertWidget(node, sectionButton, index); + return sectionButton; +} + +function applyGroupVisibility(node, group, propKey, button) { + const isExpanded = !!node.properties[propKey]; + button.name = `${isExpanded ? "β–Ό" : "β–Ά"} ${group.label}`; + for (const widgetName of group.widgets) { + setWidgetVisibility(findWidget(node, widgetName), isExpanded); + } + fitNodeToWidgets(node); + app.graph.setDirtyCanvas(true, true); +} + +function refreshNodeLayoutState(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + for (const group of groups) { + const button = node.widgets?.find((widget) => widget?._iamccsSectionKey === group.key); + if (!button) { + continue; + } + applyGroupVisibility(node, group, `iamccs_section_${group.key}`, button); + } + + const config = PRESET_CONFIGS[nodeName]; + if (config) { + const presetWidget = findWidget(node, config.presetWidget); + const presetName = String(presetWidget?.value || config.defaultPreset || "custom"); + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } else { + fitNodeToWidgets(node); + } +} + +function addPlannerChip(node) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === "planner_chip_preview")) { + return; + } + const chipWidget = { + type: "custom", + name: "planner_chip_preview", + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 30]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.iamccsPlannerChip || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 22; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = "#18312b"; + ctx.strokeStyle = "#6ea88d"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 9); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#d8efe2"; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(chipWidget); +} + +function addStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 42]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 34; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function addMultiLineStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 78]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const lines = String(text).split("\n").filter(Boolean).slice(0, 4); + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 70; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "top"; + lines.forEach((line, index) => { + ctx.fillText(line, x + 10, top + 8 + index * 15); + }); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function enhanceNodeLayout(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + if (!groups.length || !node.widgets?.length) { + return; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + addPlannerChip(node); + addMultiLineStatusBox(node, "iamccsPlannerDetails", "planner_details_preview", "#24362d", "#72ab8e", "#e4f2e8"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + addStatusBox(node, "iamccsRenderStatus", "render_status_preview", "#1e2f3f", "#6f93ba", "#e4edf7"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + + for (let index = groups.length - 1; index >= 0; index -= 1) { + const group = groups[index]; + const firstWidgetIndex = node.widgets.findIndex((widget) => group.widgets.includes(widget?.name)); + if (firstWidgetIndex >= 0) { + addSectionButton(node, group, firstWidgetIndex); + } + } + + applyPresetConfig(node, nodeName); + fitNodeToWidgets(node); +} + +app.registerExtension({ + name: "IAMCCS.SuperNodesExecUI", + + async beforeRegisterNodeDef(nodeType, nodeData) { + const nodeName = nodeData?.name; + if (!NODE_GROUPS[nodeName]) { + return; + } + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + // MUST run synchronously, BEFORE configure() assigns widgets_values by index. + // Section buttons inserted here occupy their index slots so that the null + // placeholders in widgets_values land on buttons, not on real widgets. + // (Using setTimeout here was the root cause of NaN on load / after undo.) + enhanceNodeLayout(this, nodeName); + const config = PRESET_CONFIGS[nodeName]; + const presetWidget = config ? findWidget(this, config.presetWidget) : null; + if (presetWidget) { + const originalCallback = presetWidget.callback; + presetWidget.callback = (...args) => { + originalCallback?.apply(presetWidget, args); + applyPresetConfig(this, nodeName); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + syncDownstreamVaeDecodeModes(this); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(this); + } + app.graph.setDirtyCanvas(true, true); + }; + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const modularDecodeWidget = findWidget(this, "modular_decode"); + if (modularDecodeWidget) { + const originalCallback = modularDecodeWidget.callback; + modularDecodeWidget.callback = (...args) => { + originalCallback?.apply(modularDecodeWidget, args); + syncDownstreamVaeDecodeModes(this); + app.graph.setDirtyCanvas(true, true); + }; + syncDownstreamVaeDecodeModes(this); + } + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + const decodeModeWidget = findWidget(this, "decode_mode"); + if (decodeModeWidget) { + const originalCallback = decodeModeWidget.callback; + decodeModeWidget.callback = (...args) => { + originalCallback?.apply(decodeModeWidget, args); + applyVaeDecodeModeVisibility(this); + app.graph.setDirtyCanvas(true, true); + }; + } + } + return result; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + const result = onConfigure?.apply(this, arguments); + enhanceNodeLayout(this, nodeName); + rehydrateSerializedWidgets(this, info?.widgets_values); + refreshNodeLayoutState(this, nodeName); + return result; + }; + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + this.properties = this.properties || {}; + if (Array.isArray(message?.planner_chip) && message.planner_chip.length > 0) { + this.properties.iamccsPlannerChip = String(message.planner_chip[0] || ""); + } + const details = []; + const duration = Array.isArray(message?.planned_duration_seconds) && message.planned_duration_seconds.length > 0 + ? message.planned_duration_seconds[0] + : Array.isArray(message?.duration_seconds) && message.duration_seconds.length > 0 + ? message.duration_seconds[0] + : null; + const totalFrames = Array.isArray(message?.planned_total_frames) && message.planned_total_frames.length > 0 + ? message.planned_total_frames[0] + : Array.isArray(message?.total_frames) && message.total_frames.length > 0 + ? message.total_frames[0] + : null; + const segments = Array.isArray(message?.planned_segment_count) && message.planned_segment_count.length > 0 + ? message.planned_segment_count[0] + : Array.isArray(message?.segment_count) && message.segment_count.length > 0 + ? message.segment_count[0] + : null; + if (duration !== null) { + details.push(`duration ${Number(duration || 0).toFixed(2)}s`); + } + if (totalFrames !== null) { + details.push(`total ${totalFrames}f`); + } + if (segments !== null) { + details.push(`segments ${segments}`); + } + if (Array.isArray(message?.recommended_overlap_frames) && message.recommended_overlap_frames.length > 0) { + details.push(`overlap ${message.recommended_overlap_frames[0]}f`); + } + if (Array.isArray(message?.recommended_audio_left_context_s) && message.recommended_audio_left_context_s.length > 0) { + details.push(`left ctx ${Number(message.recommended_audio_left_context_s[0] || 0).toFixed(2)}s`); + } + if (Array.isArray(message?.planning_report) && message.planning_report.length > 0) { + details.push(String(message.planning_report[0] || "")); + } + if (details.length > 0) { + this.properties.iamccsPlannerDetails = details.join("\n"); + app.graph.setDirtyCanvas(true, true); + } + }; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + if (Array.isArray(message?.report) && message.report.length > 0) { + this.properties = this.properties || {}; + this.properties.iamccsRenderStatus = String(message.report[0] || "").split("\n")[0]; + app.graph.setDirtyCanvas(true, true); + } + }; + } + }, +}); diff --git a/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_load_deserialize_fix.bak.js b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_load_deserialize_fix.bak.js new file mode 100644 index 0000000..b236cf8 --- /dev/null +++ b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_load_deserialize_fix.bak.js @@ -0,0 +1,522 @@ +import { app } from "../../../scripts/app.js"; + +const PRESET_CONFIGS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { modular_decode: "low_ram", steps: 16, image_compression: 40, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + balanced: { modular_decode: "normal", steps: 20, image_compression: 33, continuity_anchor_mode: "off", anchor_refresh_interval: 3, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + high_quality: { modular_decode: "high", steps: 24, image_compression: 28, continuity_anchor_mode: "off", anchor_refresh_interval: 1, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + fast_preview: { modular_decode: "low_ram", steps: 12, image_compression: 45, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + }, + visibility: { + low_ram_safe: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + balanced: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + high_quality: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + fast_preview: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: false }, + }, + }, + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { decode_mode: "low_ram", jpg_quality: 92, crf: 21, tiled_tile_size: 512, tiled_overlap: 64 }, + balanced: { decode_mode: "normal", jpg_quality: 95, crf: 19, tiled_tile_size: 512, tiled_overlap: 64 }, + high_quality: { decode_mode: "high", jpg_quality: 100, crf: 16, tiled_tile_size: 768, tiled_overlap: 96 }, + fast_preview: { decode_mode: "low_ram", jpg_quality: 88, crf: 24, tiled_tile_size: 384, tiled_overlap: 48 }, + }, + visibility: { + low_ram_safe: { frames_subdir: true, image_format: true, jpg_quality: true }, + balanced: { frames_subdir: true, image_format: true, jpg_quality: true }, + high_quality: { frames_subdir: true, image_format: true, jpg_quality: true }, + fast_preview: { frames_subdir: true, image_format: true, jpg_quality: true }, + }, + }, +}; + +const NODE_GROUPS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Planner": [ + { key: "timeline", label: "Timeline", color: "#2d6f63", widgets: ["fps", "segment_seconds"] }, + { key: "planning", label: "Planning", color: "#6d5d2a", widgets: ["planning_mode", "content_profile", "overlap_frames", "ltx_round_mode"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": [ + { key: "preset", label: "Preset", color: "#445c75", widgets: ["ui_preset"] }, + { key: "prompts", label: "Prompting", color: "#6b4f2a", widgets: ["positive_text", "negative_text"] }, + { key: "video", label: "Video", color: "#2f4d7a", widgets: ["width", "height"] }, + { key: "sampling", label: "Sampling", color: "#5b3a73", widgets: ["steps", "cfg", "sampler_name", "seed", "max_shift", "base_shift", "sigma_terminal", "manual_sigmas", "image_strength", "image_compression"] }, + { key: "audio", label: "Audio", color: "#2f6a47", widgets: ["audio_context_mode", "audio_left_context_s", "audio_right_context_s"] }, + { key: "stitch", label: "Stitch", color: "#7a4f2f", widgets: ["stitch_preset", "overlap_side", "overlap_mode", "start_frames_rule"] }, + { key: "anchor", label: "Anchor", color: "#8a4d4d", widgets: ["continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength"] }, + { key: "modular", label: "Modular", color: "#35566e", widgets: ["modular_decode", "downstream_stage_mode", "output_root"] }, + ], + "IAMCCS-SuperNodes Second Stage": [ + { key: "stage2", label: "Second Stage", color: "#6f4a7f", widgets: ["second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas"] }, + ], + IAMCCS_GC_AudioConcatSupernode: [ + { key: "concat", label: "Audio Concat", color: "#3e6f55", widgets: ["concat_mode", "clip_durations_seconds", "gap_seconds", "intro_seconds", "outro_seconds"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": [ + { key: "preset", label: "Preset", color: "#445c75", widgets: ["ui_preset"] }, + { key: "decode", label: "Decode", color: "#3e5f82", widgets: ["frame_rate", "decode_mode", "output_root", "frames_subdir", "image_format", "jpg_quality", "tiled_tile_size", "tiled_overlap"] }, + { key: "final", label: "Finalize", color: "#6c5330", widgets: ["filename_prefix", "crf", "pix_fmt", "trim_to_audio"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Finalize": [ + { key: "output", label: "Output", color: "#556677", widgets: ["frame_rate", "filename_prefix", "crf", "pix_fmt", "trim_to_audio"] }, + ], +}; + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget?.name === name); +} + +function setWidgetVisibility(widget, visible) { + if (!widget || widget.type === "converted-widget") { + return; + } + + widget.hidden = !visible; + widget.disabled = !visible; + + if (widget.element) { + widget.element.style.display = visible ? "" : "none"; + } + if (widget.inputEl) { + widget.inputEl.style.display = visible ? "" : "none"; + } + + if (visible) { + if (Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.computeSize = widget.__iamccsOrigComputeSize; + } else { + delete widget.computeSize; + } + } else { + if (!Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.__iamccsOrigComputeSize = widget.computeSize; + } + widget.computeSize = () => [0, -4]; + widget.y = undefined; + widget.last_y = undefined; + } +} + +function fitNodeToWidgets(node) { + const size = node.computeSize?.() || node.size; + node.setSize([ + Math.max(node.size?.[0] || 0, size[0]), + Math.max(size[1], size[1] + 8), + ]); +} + +function insertWidget(node, widget, index) { + const widgets = node.widgets || []; + const currentIndex = widgets.indexOf(widget); + if (currentIndex >= 0) { + widgets.splice(currentIndex, 1); + } + widgets.splice(index, 0, widget); +} + +function setWidgetValue(node, widgetName, value) { + const widget = findWidget(node, widgetName); + if (!widget || widget.value === value) { + return; + } + widget.value = value; + widget.callback?.(value); +} + +function getLinxTargets(node) { + const results = []; + for (const output of node.outputs || []) { + if (output?.type !== "IAMCCS_SUPERNODE_LINX") { + continue; + } + for (const linkId of output.links || []) { + const link = app.graph.links?.[linkId]; + if (!link) { + continue; + } + const targetNode = app.graph.getNodeById?.(link.target_id); + if (targetNode) { + results.push(targetNode); + } + } + } + return results; +} + +function applyVaeDecodeModeVisibility(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + return; + } + const decodeMode = String(findWidget(node, "decode_mode")?.value || "low_ram"); + const isLowRam = decodeMode === "low_ram"; + const isNormal = decodeMode === "normal"; + setWidgetVisibility(findWidget(node, "frames_subdir"), isLowRam); + setWidgetVisibility(findWidget(node, "image_format"), isLowRam); + setWidgetVisibility(findWidget(node, "jpg_quality"), isLowRam); + setWidgetVisibility(findWidget(node, "tiled_tile_size"), isNormal); + setWidgetVisibility(findWidget(node, "tiled_overlap"), isNormal); + fitNodeToWidgets(node); +} + +function syncDownstreamVaeDecodeModes(renderNode) { + const renderDecode = String(findWidget(renderNode, "modular_decode")?.value || "low_ram"); + const visited = new Set(); + const queue = [...getLinxTargets(renderNode)]; + while (queue.length > 0) { + const node = queue.shift(); + if (!node || visited.has(node.id)) { + continue; + } + visited.add(node.id); + if (node.comfyClass === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + setWidgetValue(node, "decode_mode", renderDecode); + setWidgetValue(node, "ui_preset", "custom"); + applyVaeDecodeModeVisibility(node); + app.graph.setDirtyCanvas(true, true); + continue; + } + if (node.comfyClass === "IAMCCS-SuperNodes Second Stage") { + queue.push(...getLinxTargets(node)); + } + } +} + +function applyPresetConfig(node, nodeName) { + const config = PRESET_CONFIGS[nodeName]; + if (!config) { + return; + } + const presetWidget = findWidget(node, config.presetWidget); + if (!presetWidget) { + return; + } + const presetName = String(presetWidget.value || config.defaultPreset || "custom"); + if (presetName !== "custom") { + const values = config.values?.[presetName] || {}; + Object.entries(values).forEach(([widgetName, widgetValue]) => setWidgetValue(node, widgetName, widgetValue)); + } + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + fitNodeToWidgets(node); +} + +function addSectionButton(node, group, index) { + // Duplicate guard: if a button for this section key is already in the widget list, + // return it immediately. This prevents double-insertion if onNodeCreated or + // enhanceNodeLayout is called more than once on the same node instance. + const existing = node.widgets?.find((w) => w._iamccsSectionKey === group.key); + if (existing) { + return existing; + } + + node.properties = node.properties || {}; + const propKey = `iamccs_section_${group.key}`; + if (node.properties[propKey] === undefined) { + node.properties[propKey] = true; + } + + const sectionButton = node.addWidget("button", "", "", () => { + node.properties[propKey] = !node.properties[propKey]; + applyGroupVisibility(node, group, propKey, sectionButton); + }); + // IMPORTANT: do NOT set serialize = false. + // Section buttons must occupy a slot in widgets_values so that ComfyUI's + // index-based configure() assignment keeps real widget values aligned. + // Their value (empty string / null) is harmless. + sectionButton._iamccsSectionKey = group.key; + sectionButton.computeSize = (width) => [width || 280, 28]; + sectionButton.label = group.label; + sectionButton.options = { bgcolor: group.color || "#405060", color: "#f4f7fa" }; + + applyGroupVisibility(node, group, propKey, sectionButton); + insertWidget(node, sectionButton, index); + return sectionButton; +} + +function applyGroupVisibility(node, group, propKey, button) { + const isExpanded = !!node.properties[propKey]; + button.name = `${isExpanded ? "β–Ό" : "β–Ά"} ${group.label}`; + for (const widgetName of group.widgets) { + setWidgetVisibility(findWidget(node, widgetName), isExpanded); + } + fitNodeToWidgets(node); + app.graph.setDirtyCanvas(true, true); +} + +function addPlannerChip(node) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === "planner_chip_preview")) { + return; + } + const chipWidget = { + type: "custom", + name: "planner_chip_preview", + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 30]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.iamccsPlannerChip || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 22; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = "#18312b"; + ctx.strokeStyle = "#6ea88d"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 9); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#d8efe2"; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(chipWidget); +} + +function addStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 42]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 34; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function addMultiLineStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 78]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const lines = String(text).split("\n").filter(Boolean).slice(0, 4); + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 70; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "top"; + lines.forEach((line, index) => { + ctx.fillText(line, x + 10, top + 8 + index * 15); + }); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function enhanceNodeLayout(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + if (!groups.length || !node.widgets?.length) { + return; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + addPlannerChip(node); + addMultiLineStatusBox(node, "iamccsPlannerDetails", "planner_details_preview", "#24362d", "#72ab8e", "#e4f2e8"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + addStatusBox(node, "iamccsRenderStatus", "render_status_preview", "#1e2f3f", "#6f93ba", "#e4edf7"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + + for (let index = groups.length - 1; index >= 0; index -= 1) { + const group = groups[index]; + const firstWidgetIndex = node.widgets.findIndex((widget) => group.widgets.includes(widget?.name)); + if (firstWidgetIndex >= 0) { + addSectionButton(node, group, firstWidgetIndex); + } + } + + applyPresetConfig(node, nodeName); + fitNodeToWidgets(node); +} + +app.registerExtension({ + name: "IAMCCS.SuperNodesExecUI", + + async beforeRegisterNodeDef(nodeType, nodeData) { + const nodeName = nodeData?.name; + if (!NODE_GROUPS[nodeName]) { + return; + } + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + // MUST run synchronously, BEFORE configure() assigns widgets_values by index. + // Section buttons inserted here occupy their index slots so that the null + // placeholders in widgets_values land on buttons, not on real widgets. + // (Using setTimeout here was the root cause of NaN on load / after undo.) + enhanceNodeLayout(this, nodeName); + const config = PRESET_CONFIGS[nodeName]; + const presetWidget = config ? findWidget(this, config.presetWidget) : null; + if (presetWidget) { + const originalCallback = presetWidget.callback; + presetWidget.callback = (...args) => { + originalCallback?.apply(presetWidget, args); + applyPresetConfig(this, nodeName); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + syncDownstreamVaeDecodeModes(this); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(this); + } + app.graph.setDirtyCanvas(true, true); + }; + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const modularDecodeWidget = findWidget(this, "modular_decode"); + if (modularDecodeWidget) { + const originalCallback = modularDecodeWidget.callback; + modularDecodeWidget.callback = (...args) => { + originalCallback?.apply(modularDecodeWidget, args); + syncDownstreamVaeDecodeModes(this); + app.graph.setDirtyCanvas(true, true); + }; + syncDownstreamVaeDecodeModes(this); + } + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + const decodeModeWidget = findWidget(this, "decode_mode"); + if (decodeModeWidget) { + const originalCallback = decodeModeWidget.callback; + decodeModeWidget.callback = (...args) => { + originalCallback?.apply(decodeModeWidget, args); + applyVaeDecodeModeVisibility(this); + app.graph.setDirtyCanvas(true, true); + }; + } + } + return result; + }; + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + this.properties = this.properties || {}; + if (Array.isArray(message?.planner_chip) && message.planner_chip.length > 0) { + this.properties.iamccsPlannerChip = String(message.planner_chip[0] || ""); + } + const details = []; + const duration = Array.isArray(message?.planned_duration_seconds) && message.planned_duration_seconds.length > 0 + ? message.planned_duration_seconds[0] + : Array.isArray(message?.duration_seconds) && message.duration_seconds.length > 0 + ? message.duration_seconds[0] + : null; + const totalFrames = Array.isArray(message?.planned_total_frames) && message.planned_total_frames.length > 0 + ? message.planned_total_frames[0] + : Array.isArray(message?.total_frames) && message.total_frames.length > 0 + ? message.total_frames[0] + : null; + const segments = Array.isArray(message?.planned_segment_count) && message.planned_segment_count.length > 0 + ? message.planned_segment_count[0] + : Array.isArray(message?.segment_count) && message.segment_count.length > 0 + ? message.segment_count[0] + : null; + if (duration !== null) { + details.push(`duration ${Number(duration || 0).toFixed(2)}s`); + } + if (totalFrames !== null) { + details.push(`total ${totalFrames}f`); + } + if (segments !== null) { + details.push(`segments ${segments}`); + } + if (Array.isArray(message?.recommended_overlap_frames) && message.recommended_overlap_frames.length > 0) { + details.push(`overlap ${message.recommended_overlap_frames[0]}f`); + } + if (Array.isArray(message?.recommended_audio_left_context_s) && message.recommended_audio_left_context_s.length > 0) { + details.push(`left ctx ${Number(message.recommended_audio_left_context_s[0] || 0).toFixed(2)}s`); + } + if (Array.isArray(message?.planning_report) && message.planning_report.length > 0) { + details.push(String(message.planning_report[0] || "")); + } + if (details.length > 0) { + this.properties.iamccsPlannerDetails = details.join("\n"); + app.graph.setDirtyCanvas(true, true); + } + }; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + if (Array.isArray(message?.report) && message.report.length > 0) { + this.properties = this.properties || {}; + this.properties.iamccsRenderStatus = String(message.report[0] || "").split("\n")[0]; + app.graph.setDirtyCanvas(true, true); + } + }; + } + }, +}); diff --git a/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_loop_parity_stage2_fix.bak.js b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_loop_parity_stage2_fix.bak.js new file mode 100644 index 0000000..b1288c0 --- /dev/null +++ b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_loop_parity_stage2_fix.bak.js @@ -0,0 +1,609 @@ +import { app } from "../../../scripts/app.js"; + +const PRESET_CONFIGS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { modular_decode: "low_ram", steps: 16, image_compression: 40, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + balanced: { modular_decode: "normal", steps: 20, image_compression: 33, continuity_anchor_mode: "off", anchor_refresh_interval: 3, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + high_quality: { modular_decode: "high", steps: 24, image_compression: 28, continuity_anchor_mode: "off", anchor_refresh_interval: 1, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + fast_preview: { modular_decode: "low_ram", steps: 12, image_compression: 45, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + }, + visibility: { + low_ram_safe: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + balanced: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + high_quality: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + fast_preview: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: false }, + }, + }, + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { decode_mode: "low_ram", jpg_quality: 92, crf: 21, tiled_tile_size: 512, tiled_overlap: 64 }, + balanced: { decode_mode: "normal", jpg_quality: 95, crf: 19, tiled_tile_size: 512, tiled_overlap: 64 }, + high_quality: { decode_mode: "high", jpg_quality: 100, crf: 16, tiled_tile_size: 768, tiled_overlap: 96 }, + fast_preview: { decode_mode: "low_ram", jpg_quality: 88, crf: 24, tiled_tile_size: 384, tiled_overlap: 48 }, + }, + visibility: { + low_ram_safe: { frames_subdir: true, image_format: true, jpg_quality: true }, + balanced: { frames_subdir: true, image_format: true, jpg_quality: true }, + high_quality: { frames_subdir: true, image_format: true, jpg_quality: true }, + fast_preview: { frames_subdir: true, image_format: true, jpg_quality: true }, + }, + }, +}; + +const NODE_GROUPS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Planner": [ + { key: "timeline", label: "Timeline", color: "#2f8f80", widgets: ["fps", "segment_seconds"] }, + { key: "planning", label: "Planning", color: "#a07b2c", widgets: ["planning_mode", "segment_preset", "overlap_frames", "ltx_round_mode"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": [ + { key: "preset", label: "Preset", color: "#557ea6", widgets: ["ui_preset"] }, + { key: "prompts", label: "Prompting", color: "#9a6b2f", widgets: ["positive_text", "negative_text"] }, + { key: "video", label: "Video", color: "#3f6fb0", widgets: ["width", "height"] }, + { key: "sampling", label: "Sampling", color: "#8352a6", widgets: ["steps", "cfg", "sampler_name", "seed", "max_shift", "base_shift", "sigma_terminal", "manual_sigmas", "image_strength", "image_compression"] }, + { key: "audio", label: "Audio", color: "#3d8a5c", widgets: ["audio_context_mode", "audio_left_context_s", "audio_right_context_s"] }, + { key: "stitch", label: "Stitch", color: "#a4673d", widgets: ["stitch_preset", "overlap_side", "overlap_mode", "start_frames_rule"] }, + { key: "anchor", label: "Anchor", color: "#b35c5c", widgets: ["continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength"] }, + { key: "modular", label: "Modular", color: "#46769a", widgets: ["modular_decode", "downstream_stage_mode", "output_root"] }, + { key: "debug", label: "Debug", color: "#8a8a36", widgets: ["segment_overlay_mode", "segment_overlay_text"] }, + ], + "IAMCCS-SuperNodes Second Stage": [ + { key: "stage2", label: "Second Stage", color: "#8a5ca0", widgets: ["second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas"] }, + ], + IAMCCS_GC_AudioConcatSupernode: [ + { key: "concat", label: "Audio Concat", color: "#4e8f6b", widgets: ["concat_mode", "clip_durations_seconds", "gap_seconds", "intro_seconds", "outro_seconds"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": [ + { key: "preset", label: "Preset", color: "#557ea6", widgets: ["ui_preset"] }, + { key: "decode", label: "Decode", color: "#4b79a6", widgets: ["frame_rate", "decode_mode", "output_root", "frames_subdir", "image_format", "jpg_quality", "tiled_tile_size", "tiled_overlap"] }, + { key: "final", label: "Finalize", color: "#9b7441", widgets: ["filename_prefix", "crf", "pix_fmt", "trim_to_audio", "save_metadata"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Finalize": [ + { key: "output", label: "Output", color: "#6a85a0", widgets: ["frame_rate", "filename_prefix", "crf", "pix_fmt", "trim_to_audio"] }, + ], +}; + +const SECTION_BUTTON_COLLAPSED_BG = "#22303f"; +const SECTION_BUTTON_TEXT = "#f8fbff"; + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget?.name === name); +} + +function setWidgetVisibility(widget, visible) { + if (!widget || widget.type === "converted-widget") { + return; + } + + widget.hidden = !visible; + widget.disabled = !visible; + + if (widget.element) { + widget.element.style.display = visible ? "" : "none"; + } + if (widget.inputEl) { + widget.inputEl.style.display = visible ? "" : "none"; + } + + if (visible) { + if (Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.computeSize = widget.__iamccsOrigComputeSize; + } else { + delete widget.computeSize; + } + } else { + if (!Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.__iamccsOrigComputeSize = widget.computeSize; + } + widget.computeSize = () => [0, -4]; + widget.y = undefined; + widget.last_y = undefined; + } +} + +function fitNodeToWidgets(node) { + const size = node.computeSize?.() || node.size; + node.setSize([ + Math.max(node.size?.[0] || 0, size[0]), + Math.max(size[1], size[1] + 8), + ]); +} + +function insertWidget(node, widget, index) { + const widgets = node.widgets || []; + const currentIndex = widgets.indexOf(widget); + if (currentIndex >= 0) { + widgets.splice(currentIndex, 1); + } + widgets.splice(index, 0, widget); +} + +function setWidgetValue(node, widgetName, value) { + const widget = findWidget(node, widgetName); + if (!widget || widget.value === value) { + return; + } + widget.value = value; + widget.callback?.(value); +} + +function getLinxTargets(node) { + const results = []; + for (const output of node.outputs || []) { + if (output?.type !== "IAMCCS_SUPERNODE_LINX") { + continue; + } + for (const linkId of output.links || []) { + const link = app.graph.links?.[linkId]; + if (!link) { + continue; + } + const targetNode = app.graph.getNodeById?.(link.target_id); + if (targetNode) { + results.push(targetNode); + } + } + } + return results; +} + +function applyVaeDecodeModeVisibility(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + return; + } + const decodeMode = String(findWidget(node, "decode_mode")?.value || "low_ram"); + const isLowRam = decodeMode === "low_ram"; + const isNormal = decodeMode === "normal"; + setWidgetVisibility(findWidget(node, "frames_subdir"), isLowRam); + setWidgetVisibility(findWidget(node, "image_format"), isLowRam); + setWidgetVisibility(findWidget(node, "jpg_quality"), isLowRam); + setWidgetVisibility(findWidget(node, "tiled_tile_size"), isNormal); + setWidgetVisibility(findWidget(node, "tiled_overlap"), isNormal); + fitNodeToWidgets(node); +} + +function syncDownstreamVaeDecodeModes(renderNode) { + const renderDecode = String(findWidget(renderNode, "modular_decode")?.value || "low_ram"); + const visited = new Set(); + const queue = [...getLinxTargets(renderNode)]; + while (queue.length > 0) { + const node = queue.shift(); + if (!node || visited.has(node.id)) { + continue; + } + visited.add(node.id); + if (node.comfyClass === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + setWidgetValue(node, "decode_mode", renderDecode); + setWidgetValue(node, "ui_preset", "custom"); + applyVaeDecodeModeVisibility(node); + app.graph.setDirtyCanvas(true, true); + continue; + } + if (node.comfyClass === "IAMCCS-SuperNodes Second Stage") { + queue.push(...getLinxTargets(node)); + } + } +} + +function applyPresetConfig(node, nodeName) { + const config = PRESET_CONFIGS[nodeName]; + if (!config) { + return; + } + const presetWidget = findWidget(node, config.presetWidget); + if (!presetWidget) { + return; + } + const presetName = String(presetWidget.value || config.defaultPreset || "custom"); + if (presetName !== "custom") { + const values = config.values?.[presetName] || {}; + Object.entries(values).forEach(([widgetName, widgetValue]) => setWidgetValue(node, widgetName, widgetValue)); + } + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + fitNodeToWidgets(node); +} + +function getSerializableWidgets(node) { + return (node.widgets || []).filter((widget) => widget && widget.serialize !== false); +} + +function rehydrateSerializedWidgets(node, serializedValues) { + if (!Array.isArray(serializedValues) || !node.widgets?.length) { + return; + } + + const widgets = getSerializableWidgets(node); + const count = Math.min(widgets.length, serializedValues.length); + for (let index = 0; index < count; index += 1) { + const widget = widgets[index]; + if (!widget) { + continue; + } + widget.value = serializedValues[index]; + } +} + +function addSectionButton(node, group, index) { + // Duplicate guard: if a button for this section key is already in the widget list, + // return it immediately. This prevents double-insertion if onNodeCreated or + // enhanceNodeLayout is called more than once on the same node instance. + const existing = node.widgets?.find((w) => w._iamccsSectionKey === group.key); + if (existing) { + return existing; + } + + node.properties = node.properties || {}; + const propKey = `iamccs_section_${group.key}`; + if (node.properties[propKey] === undefined) { + node.properties[propKey] = true; + } + + const sectionButton = node.addWidget("button", "", "", () => { + node.properties[propKey] = !node.properties[propKey]; + applyGroupVisibility(node, group, propKey, sectionButton); + }); + // IMPORTANT: do NOT set serialize = false. + // Section buttons must occupy a slot in widgets_values so that ComfyUI's + // index-based configure() assignment keeps real widget values aligned. + // Their value (empty string / null) is harmless. + sectionButton._iamccsSectionKey = group.key; + sectionButton.computeSize = (width) => [width || 280, 34]; + sectionButton.label = group.label; + sectionButton.options = { bgcolor: group.color || "#4f6f8f", color: SECTION_BUTTON_TEXT }; + + applyGroupVisibility(node, group, propKey, sectionButton); + insertWidget(node, sectionButton, index); + return sectionButton; +} + +function applyGroupVisibility(node, group, propKey, button) { + const isExpanded = !!node.properties[propKey]; + button.name = `${isExpanded ? "β–Ό" : "β–Ά"} ${group.label} Section`; + button.options = { + bgcolor: isExpanded ? (group.color || "#4f6f8f") : SECTION_BUTTON_COLLAPSED_BG, + color: SECTION_BUTTON_TEXT, + }; + for (const widgetName of group.widgets) { + setWidgetVisibility(findWidget(node, widgetName), isExpanded); + } + fitNodeToWidgets(node); + app.graph.setDirtyCanvas(true, true); +} + +function applyPlannerModeVisibility(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + return; + } + const planningMode = String(findWidget(node, "planning_mode")?.value || "manual_segment_seconds"); + const planningExpanded = !!node.properties?.iamccs_section_planning; + const showSegmentPreset = planningExpanded && planningMode === "explicit_preset_seconds"; + setWidgetVisibility(findWidget(node, "segment_preset"), showSegmentPreset); + fitNodeToWidgets(node); +} + +function refreshNodeLayoutState(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + for (const group of groups) { + const button = node.widgets?.find((widget) => widget?._iamccsSectionKey === group.key); + if (!button) { + continue; + } + applyGroupVisibility(node, group, `iamccs_section_${group.key}`, button); + } + + const config = PRESET_CONFIGS[nodeName]; + if (config) { + const presetWidget = findWidget(node, config.presetWidget); + const presetName = String(presetWidget?.value || config.defaultPreset || "custom"); + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } else if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + applyPlannerModeVisibility(node); + } else { + fitNodeToWidgets(node); + } +} + +function addPlannerChip(node) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === "planner_chip_preview")) { + return; + } + const chipWidget = { + type: "custom", + name: "planner_chip_preview", + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 30]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.iamccsPlannerChip || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 22; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = "#18312b"; + ctx.strokeStyle = "#6ea88d"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 9); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#d8efe2"; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(chipWidget); +} + +function addStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 42]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 34; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function addMultiLineStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 78]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const lines = String(text).split("\n").filter(Boolean).slice(0, 4); + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 70; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "top"; + lines.forEach((line, index) => { + ctx.fillText(line, x + 10, top + 8 + index * 15); + }); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function enhanceNodeLayout(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + if (!groups.length || !node.widgets?.length) { + return; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + addPlannerChip(node); + addMultiLineStatusBox(node, "iamccsPlannerDetails", "planner_details_preview", "#24362d", "#72ab8e", "#e4f2e8"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + addStatusBox(node, "iamccsRenderStatus", "render_status_preview", "#1e2f3f", "#6f93ba", "#e4edf7"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + + for (let index = groups.length - 1; index >= 0; index -= 1) { + const group = groups[index]; + const firstWidgetIndex = node.widgets.findIndex((widget) => group.widgets.includes(widget?.name)); + if (firstWidgetIndex >= 0) { + addSectionButton(node, group, firstWidgetIndex); + } + } + + applyPresetConfig(node, nodeName); + fitNodeToWidgets(node); +} + +app.registerExtension({ + name: "IAMCCS.SuperNodesExecUI", + + async beforeRegisterNodeDef(nodeType, nodeData) { + const nodeName = nodeData?.name; + if (!NODE_GROUPS[nodeName]) { + return; + } + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + // MUST run synchronously, BEFORE configure() assigns widgets_values by index. + // Section buttons inserted here occupy their index slots so that the null + // placeholders in widgets_values land on buttons, not on real widgets. + // (Using setTimeout here was the root cause of NaN on load / after undo.) + enhanceNodeLayout(this, nodeName); + const config = PRESET_CONFIGS[nodeName]; + const presetWidget = config ? findWidget(this, config.presetWidget) : null; + if (presetWidget) { + const originalCallback = presetWidget.callback; + presetWidget.callback = (...args) => { + originalCallback?.apply(presetWidget, args); + applyPresetConfig(this, nodeName); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + syncDownstreamVaeDecodeModes(this); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(this); + } + app.graph.setDirtyCanvas(true, true); + }; + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const modularDecodeWidget = findWidget(this, "modular_decode"); + if (modularDecodeWidget) { + const originalCallback = modularDecodeWidget.callback; + modularDecodeWidget.callback = (...args) => { + originalCallback?.apply(modularDecodeWidget, args); + syncDownstreamVaeDecodeModes(this); + app.graph.setDirtyCanvas(true, true); + }; + syncDownstreamVaeDecodeModes(this); + } + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + const planningModeWidget = findWidget(this, "planning_mode"); + if (planningModeWidget) { + const originalCallback = planningModeWidget.callback; + planningModeWidget.callback = (...args) => { + originalCallback?.apply(planningModeWidget, args); + applyPlannerModeVisibility(this); + app.graph.setDirtyCanvas(true, true); + }; + } + applyPlannerModeVisibility(this); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + const decodeModeWidget = findWidget(this, "decode_mode"); + if (decodeModeWidget) { + const originalCallback = decodeModeWidget.callback; + decodeModeWidget.callback = (...args) => { + originalCallback?.apply(decodeModeWidget, args); + applyVaeDecodeModeVisibility(this); + app.graph.setDirtyCanvas(true, true); + }; + } + } + return result; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + const result = onConfigure?.apply(this, arguments); + enhanceNodeLayout(this, nodeName); + rehydrateSerializedWidgets(this, info?.widgets_values); + refreshNodeLayoutState(this, nodeName); + return result; + }; + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + this.properties = this.properties || {}; + if (Array.isArray(message?.planner_chip) && message.planner_chip.length > 0) { + this.properties.iamccsPlannerChip = String(message.planner_chip[0] || ""); + } + const details = []; + const duration = Array.isArray(message?.planned_duration_seconds) && message.planned_duration_seconds.length > 0 + ? message.planned_duration_seconds[0] + : Array.isArray(message?.duration_seconds) && message.duration_seconds.length > 0 + ? message.duration_seconds[0] + : null; + const totalFrames = Array.isArray(message?.planned_total_frames) && message.planned_total_frames.length > 0 + ? message.planned_total_frames[0] + : Array.isArray(message?.total_frames) && message.total_frames.length > 0 + ? message.total_frames[0] + : null; + const segments = Array.isArray(message?.planned_segment_count) && message.planned_segment_count.length > 0 + ? message.planned_segment_count[0] + : Array.isArray(message?.segment_count) && message.segment_count.length > 0 + ? message.segment_count[0] + : null; + if (duration !== null) { + details.push(`duration ${Number(duration || 0).toFixed(2)}s`); + } + if (totalFrames !== null) { + details.push(`total ${totalFrames}f`); + } + if (segments !== null) { + details.push(`segments ${segments}`); + } + if (Array.isArray(message?.recommended_overlap_frames) && message.recommended_overlap_frames.length > 0) { + details.push(`overlap ${message.recommended_overlap_frames[0]}f`); + } + if (Array.isArray(message?.recommended_audio_left_context_s) && message.recommended_audio_left_context_s.length > 0) { + details.push(`left ctx ${Number(message.recommended_audio_left_context_s[0] || 0).toFixed(2)}s`); + } + if (Array.isArray(message?.planning_report) && message.planning_report.length > 0) { + details.push(String(message.planning_report[0] || "")); + } + if (details.length > 0) { + this.properties.iamccsPlannerDetails = details.join("\n"); + app.graph.setDirtyCanvas(true, true); + } + }; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + if (Array.isArray(message?.report) && message.report.length > 0) { + this.properties = this.properties || {}; + this.properties.iamccsRenderStatus = String(message.report[0] || "").split("\n")[0]; + app.graph.setDirtyCanvas(true, true); + } + }; + } + }, +}); diff --git a/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_planner_visibility_fix.bak.js b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_planner_visibility_fix.bak.js new file mode 100644 index 0000000..fe76c82 --- /dev/null +++ b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_planner_visibility_fix.bak.js @@ -0,0 +1,577 @@ +import { app } from "../../../scripts/app.js"; + +const PRESET_CONFIGS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { modular_decode: "low_ram", steps: 16, image_compression: 40, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + balanced: { modular_decode: "normal", steps: 20, image_compression: 33, continuity_anchor_mode: "off", anchor_refresh_interval: 3, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + high_quality: { modular_decode: "high", steps: 24, image_compression: 28, continuity_anchor_mode: "off", anchor_refresh_interval: 1, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + fast_preview: { modular_decode: "low_ram", steps: 12, image_compression: 45, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + }, + visibility: { + low_ram_safe: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + balanced: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + high_quality: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + fast_preview: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: false }, + }, + }, + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { decode_mode: "low_ram", jpg_quality: 92, crf: 21, tiled_tile_size: 512, tiled_overlap: 64 }, + balanced: { decode_mode: "normal", jpg_quality: 95, crf: 19, tiled_tile_size: 512, tiled_overlap: 64 }, + high_quality: { decode_mode: "high", jpg_quality: 100, crf: 16, tiled_tile_size: 768, tiled_overlap: 96 }, + fast_preview: { decode_mode: "low_ram", jpg_quality: 88, crf: 24, tiled_tile_size: 384, tiled_overlap: 48 }, + }, + visibility: { + low_ram_safe: { frames_subdir: true, image_format: true, jpg_quality: true }, + balanced: { frames_subdir: true, image_format: true, jpg_quality: true }, + high_quality: { frames_subdir: true, image_format: true, jpg_quality: true }, + fast_preview: { frames_subdir: true, image_format: true, jpg_quality: true }, + }, + }, +}; + +const NODE_GROUPS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Planner": [ + { key: "timeline", label: "Timeline", color: "#2d6f63", widgets: ["fps", "segment_seconds"] }, + { key: "planning", label: "Planning", color: "#6d5d2a", widgets: ["planning_mode", "segment_preset", "overlap_frames", "ltx_round_mode"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": [ + { key: "preset", label: "Preset", color: "#445c75", widgets: ["ui_preset"] }, + { key: "prompts", label: "Prompting", color: "#6b4f2a", widgets: ["positive_text", "negative_text"] }, + { key: "video", label: "Video", color: "#2f4d7a", widgets: ["width", "height"] }, + { key: "sampling", label: "Sampling", color: "#5b3a73", widgets: ["steps", "cfg", "sampler_name", "seed", "max_shift", "base_shift", "sigma_terminal", "manual_sigmas", "image_strength", "image_compression"] }, + { key: "audio", label: "Audio", color: "#2f6a47", widgets: ["audio_context_mode", "audio_left_context_s", "audio_right_context_s"] }, + { key: "stitch", label: "Stitch", color: "#7a4f2f", widgets: ["stitch_preset", "overlap_side", "overlap_mode", "start_frames_rule"] }, + { key: "anchor", label: "Anchor", color: "#8a4d4d", widgets: ["continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength"] }, + { key: "modular", label: "Modular", color: "#35566e", widgets: ["modular_decode", "downstream_stage_mode", "output_root"] }, + { key: "debug", label: "Debug", color: "#5b5b2f", widgets: ["segment_overlay_mode", "segment_overlay_text"] }, + ], + "IAMCCS-SuperNodes Second Stage": [ + { key: "stage2", label: "Second Stage", color: "#6f4a7f", widgets: ["second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas"] }, + ], + IAMCCS_GC_AudioConcatSupernode: [ + { key: "concat", label: "Audio Concat", color: "#3e6f55", widgets: ["concat_mode", "clip_durations_seconds", "gap_seconds", "intro_seconds", "outro_seconds"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": [ + { key: "preset", label: "Preset", color: "#445c75", widgets: ["ui_preset"] }, + { key: "decode", label: "Decode", color: "#3e5f82", widgets: ["frame_rate", "decode_mode", "output_root", "frames_subdir", "image_format", "jpg_quality", "tiled_tile_size", "tiled_overlap"] }, + { key: "final", label: "Finalize", color: "#6c5330", widgets: ["filename_prefix", "crf", "pix_fmt", "trim_to_audio", "save_metadata"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Finalize": [ + { key: "output", label: "Output", color: "#556677", widgets: ["frame_rate", "filename_prefix", "crf", "pix_fmt", "trim_to_audio"] }, + ], +}; + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget?.name === name); +} + +function setWidgetVisibility(widget, visible) { + if (!widget || widget.type === "converted-widget") { + return; + } + + widget.hidden = !visible; + widget.disabled = !visible; + + if (widget.element) { + widget.element.style.display = visible ? "" : "none"; + } + if (widget.inputEl) { + widget.inputEl.style.display = visible ? "" : "none"; + } + + if (visible) { + if (Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.computeSize = widget.__iamccsOrigComputeSize; + } else { + delete widget.computeSize; + } + } else { + if (!Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.__iamccsOrigComputeSize = widget.computeSize; + } + widget.computeSize = () => [0, -4]; + widget.y = undefined; + widget.last_y = undefined; + } +} + +function fitNodeToWidgets(node) { + const size = node.computeSize?.() || node.size; + node.setSize([ + Math.max(node.size?.[0] || 0, size[0]), + Math.max(size[1], size[1] + 8), + ]); +} + +function insertWidget(node, widget, index) { + const widgets = node.widgets || []; + const currentIndex = widgets.indexOf(widget); + if (currentIndex >= 0) { + widgets.splice(currentIndex, 1); + } + widgets.splice(index, 0, widget); +} + +function setWidgetValue(node, widgetName, value) { + const widget = findWidget(node, widgetName); + if (!widget || widget.value === value) { + return; + } + widget.value = value; + widget.callback?.(value); +} + +function getLinxTargets(node) { + const results = []; + for (const output of node.outputs || []) { + if (output?.type !== "IAMCCS_SUPERNODE_LINX") { + continue; + } + for (const linkId of output.links || []) { + const link = app.graph.links?.[linkId]; + if (!link) { + continue; + } + const targetNode = app.graph.getNodeById?.(link.target_id); + if (targetNode) { + results.push(targetNode); + } + } + } + return results; +} + +function applyVaeDecodeModeVisibility(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + return; + } + const decodeMode = String(findWidget(node, "decode_mode")?.value || "low_ram"); + const isLowRam = decodeMode === "low_ram"; + const isNormal = decodeMode === "normal"; + setWidgetVisibility(findWidget(node, "frames_subdir"), isLowRam); + setWidgetVisibility(findWidget(node, "image_format"), isLowRam); + setWidgetVisibility(findWidget(node, "jpg_quality"), isLowRam); + setWidgetVisibility(findWidget(node, "tiled_tile_size"), isNormal); + setWidgetVisibility(findWidget(node, "tiled_overlap"), isNormal); + fitNodeToWidgets(node); +} + +function syncDownstreamVaeDecodeModes(renderNode) { + const renderDecode = String(findWidget(renderNode, "modular_decode")?.value || "low_ram"); + const visited = new Set(); + const queue = [...getLinxTargets(renderNode)]; + while (queue.length > 0) { + const node = queue.shift(); + if (!node || visited.has(node.id)) { + continue; + } + visited.add(node.id); + if (node.comfyClass === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + setWidgetValue(node, "decode_mode", renderDecode); + setWidgetValue(node, "ui_preset", "custom"); + applyVaeDecodeModeVisibility(node); + app.graph.setDirtyCanvas(true, true); + continue; + } + if (node.comfyClass === "IAMCCS-SuperNodes Second Stage") { + queue.push(...getLinxTargets(node)); + } + } +} + +function applyPresetConfig(node, nodeName) { + const config = PRESET_CONFIGS[nodeName]; + if (!config) { + return; + } + const presetWidget = findWidget(node, config.presetWidget); + if (!presetWidget) { + return; + } + const presetName = String(presetWidget.value || config.defaultPreset || "custom"); + if (presetName !== "custom") { + const values = config.values?.[presetName] || {}; + Object.entries(values).forEach(([widgetName, widgetValue]) => setWidgetValue(node, widgetName, widgetValue)); + } + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + fitNodeToWidgets(node); +} + +function getSerializableWidgets(node) { + return (node.widgets || []).filter((widget) => widget && widget.serialize !== false); +} + +function rehydrateSerializedWidgets(node, serializedValues) { + if (!Array.isArray(serializedValues) || !node.widgets?.length) { + return; + } + + const widgets = getSerializableWidgets(node); + const count = Math.min(widgets.length, serializedValues.length); + for (let index = 0; index < count; index += 1) { + const widget = widgets[index]; + if (!widget) { + continue; + } + widget.value = serializedValues[index]; + } +} + +function addSectionButton(node, group, index) { + // Duplicate guard: if a button for this section key is already in the widget list, + // return it immediately. This prevents double-insertion if onNodeCreated or + // enhanceNodeLayout is called more than once on the same node instance. + const existing = node.widgets?.find((w) => w._iamccsSectionKey === group.key); + if (existing) { + return existing; + } + + node.properties = node.properties || {}; + const propKey = `iamccs_section_${group.key}`; + if (node.properties[propKey] === undefined) { + node.properties[propKey] = true; + } + + const sectionButton = node.addWidget("button", "", "", () => { + node.properties[propKey] = !node.properties[propKey]; + applyGroupVisibility(node, group, propKey, sectionButton); + }); + // IMPORTANT: do NOT set serialize = false. + // Section buttons must occupy a slot in widgets_values so that ComfyUI's + // index-based configure() assignment keeps real widget values aligned. + // Their value (empty string / null) is harmless. + sectionButton._iamccsSectionKey = group.key; + sectionButton.computeSize = (width) => [width || 280, 28]; + sectionButton.label = group.label; + sectionButton.options = { bgcolor: group.color || "#405060", color: "#f4f7fa" }; + + applyGroupVisibility(node, group, propKey, sectionButton); + insertWidget(node, sectionButton, index); + return sectionButton; +} + +function applyGroupVisibility(node, group, propKey, button) { + const isExpanded = !!node.properties[propKey]; + button.name = `${isExpanded ? "β–Ό" : "β–Ά"} ${group.label}`; + for (const widgetName of group.widgets) { + setWidgetVisibility(findWidget(node, widgetName), isExpanded); + } + fitNodeToWidgets(node); + app.graph.setDirtyCanvas(true, true); +} + +function refreshNodeLayoutState(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + for (const group of groups) { + const button = node.widgets?.find((widget) => widget?._iamccsSectionKey === group.key); + if (!button) { + continue; + } + applyGroupVisibility(node, group, `iamccs_section_${group.key}`, button); + } + + const config = PRESET_CONFIGS[nodeName]; + if (config) { + const presetWidget = findWidget(node, config.presetWidget); + const presetName = String(presetWidget?.value || config.defaultPreset || "custom"); + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } else { + fitNodeToWidgets(node); + } +} + +function addPlannerChip(node) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === "planner_chip_preview")) { + return; + } + const chipWidget = { + type: "custom", + name: "planner_chip_preview", + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 30]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.iamccsPlannerChip || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 22; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = "#18312b"; + ctx.strokeStyle = "#6ea88d"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 9); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#d8efe2"; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(chipWidget); +} + +function addStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 42]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 34; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function addMultiLineStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 78]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const lines = String(text).split("\n").filter(Boolean).slice(0, 4); + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 70; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "top"; + lines.forEach((line, index) => { + ctx.fillText(line, x + 10, top + 8 + index * 15); + }); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function enhanceNodeLayout(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + if (!groups.length || !node.widgets?.length) { + return; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + addPlannerChip(node); + addMultiLineStatusBox(node, "iamccsPlannerDetails", "planner_details_preview", "#24362d", "#72ab8e", "#e4f2e8"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + addStatusBox(node, "iamccsRenderStatus", "render_status_preview", "#1e2f3f", "#6f93ba", "#e4edf7"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + + for (let index = groups.length - 1; index >= 0; index -= 1) { + const group = groups[index]; + const firstWidgetIndex = node.widgets.findIndex((widget) => group.widgets.includes(widget?.name)); + if (firstWidgetIndex >= 0) { + addSectionButton(node, group, firstWidgetIndex); + } + } + + applyPresetConfig(node, nodeName); + fitNodeToWidgets(node); +} + +app.registerExtension({ + name: "IAMCCS.SuperNodesExecUI", + + async beforeRegisterNodeDef(nodeType, nodeData) { + const nodeName = nodeData?.name; + if (!NODE_GROUPS[nodeName]) { + return; + } + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + // MUST run synchronously, BEFORE configure() assigns widgets_values by index. + // Section buttons inserted here occupy their index slots so that the null + // placeholders in widgets_values land on buttons, not on real widgets. + // (Using setTimeout here was the root cause of NaN on load / after undo.) + enhanceNodeLayout(this, nodeName); + const config = PRESET_CONFIGS[nodeName]; + const presetWidget = config ? findWidget(this, config.presetWidget) : null; + if (presetWidget) { + const originalCallback = presetWidget.callback; + presetWidget.callback = (...args) => { + originalCallback?.apply(presetWidget, args); + applyPresetConfig(this, nodeName); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + syncDownstreamVaeDecodeModes(this); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(this); + } + app.graph.setDirtyCanvas(true, true); + }; + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const modularDecodeWidget = findWidget(this, "modular_decode"); + if (modularDecodeWidget) { + const originalCallback = modularDecodeWidget.callback; + modularDecodeWidget.callback = (...args) => { + originalCallback?.apply(modularDecodeWidget, args); + syncDownstreamVaeDecodeModes(this); + app.graph.setDirtyCanvas(true, true); + }; + syncDownstreamVaeDecodeModes(this); + } + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + const decodeModeWidget = findWidget(this, "decode_mode"); + if (decodeModeWidget) { + const originalCallback = decodeModeWidget.callback; + decodeModeWidget.callback = (...args) => { + originalCallback?.apply(decodeModeWidget, args); + applyVaeDecodeModeVisibility(this); + app.graph.setDirtyCanvas(true, true); + }; + } + } + return result; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + const result = onConfigure?.apply(this, arguments); + enhanceNodeLayout(this, nodeName); + rehydrateSerializedWidgets(this, info?.widgets_values); + refreshNodeLayoutState(this, nodeName); + return result; + }; + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + this.properties = this.properties || {}; + if (Array.isArray(message?.planner_chip) && message.planner_chip.length > 0) { + this.properties.iamccsPlannerChip = String(message.planner_chip[0] || ""); + } + const details = []; + const duration = Array.isArray(message?.planned_duration_seconds) && message.planned_duration_seconds.length > 0 + ? message.planned_duration_seconds[0] + : Array.isArray(message?.duration_seconds) && message.duration_seconds.length > 0 + ? message.duration_seconds[0] + : null; + const totalFrames = Array.isArray(message?.planned_total_frames) && message.planned_total_frames.length > 0 + ? message.planned_total_frames[0] + : Array.isArray(message?.total_frames) && message.total_frames.length > 0 + ? message.total_frames[0] + : null; + const segments = Array.isArray(message?.planned_segment_count) && message.planned_segment_count.length > 0 + ? message.planned_segment_count[0] + : Array.isArray(message?.segment_count) && message.segment_count.length > 0 + ? message.segment_count[0] + : null; + if (duration !== null) { + details.push(`duration ${Number(duration || 0).toFixed(2)}s`); + } + if (totalFrames !== null) { + details.push(`total ${totalFrames}f`); + } + if (segments !== null) { + details.push(`segments ${segments}`); + } + if (Array.isArray(message?.recommended_overlap_frames) && message.recommended_overlap_frames.length > 0) { + details.push(`overlap ${message.recommended_overlap_frames[0]}f`); + } + if (Array.isArray(message?.recommended_audio_left_context_s) && message.recommended_audio_left_context_s.length > 0) { + details.push(`left ctx ${Number(message.recommended_audio_left_context_s[0] || 0).toFixed(2)}s`); + } + if (Array.isArray(message?.planning_report) && message.planning_report.length > 0) { + details.push(String(message.planning_report[0] || "")); + } + if (details.length > 0) { + this.properties.iamccsPlannerDetails = details.join("\n"); + app.graph.setDirtyCanvas(true, true); + } + }; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + if (Array.isArray(message?.report) && message.report.length > 0) { + this.properties = this.properties || {}; + this.properties.iamccsRenderStatus = String(message.report[0] || "").split("\n")[0]; + app.graph.setDirtyCanvas(true, true); + } + }; + } + }, +}); diff --git a/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_save_metadata.bak.js b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_save_metadata.bak.js new file mode 100644 index 0000000..2bd5f77 --- /dev/null +++ b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_save_metadata.bak.js @@ -0,0 +1,577 @@ +import { app } from "../../../scripts/app.js"; + +const PRESET_CONFIGS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { modular_decode: "low_ram", steps: 16, image_compression: 40, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + balanced: { modular_decode: "normal", steps: 20, image_compression: 33, continuity_anchor_mode: "off", anchor_refresh_interval: 3, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + high_quality: { modular_decode: "high", steps: 24, image_compression: 28, continuity_anchor_mode: "off", anchor_refresh_interval: 1, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + fast_preview: { modular_decode: "low_ram", steps: 12, image_compression: 45, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + }, + visibility: { + low_ram_safe: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + balanced: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + high_quality: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + fast_preview: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: false }, + }, + }, + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { decode_mode: "low_ram", jpg_quality: 92, crf: 21, tiled_tile_size: 512, tiled_overlap: 64 }, + balanced: { decode_mode: "normal", jpg_quality: 95, crf: 19, tiled_tile_size: 512, tiled_overlap: 64 }, + high_quality: { decode_mode: "high", jpg_quality: 100, crf: 16, tiled_tile_size: 768, tiled_overlap: 96 }, + fast_preview: { decode_mode: "low_ram", jpg_quality: 88, crf: 24, tiled_tile_size: 384, tiled_overlap: 48 }, + }, + visibility: { + low_ram_safe: { frames_subdir: true, image_format: true, jpg_quality: true }, + balanced: { frames_subdir: true, image_format: true, jpg_quality: true }, + high_quality: { frames_subdir: true, image_format: true, jpg_quality: true }, + fast_preview: { frames_subdir: true, image_format: true, jpg_quality: true }, + }, + }, +}; + +const NODE_GROUPS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Planner": [ + { key: "timeline", label: "Timeline", color: "#2d6f63", widgets: ["fps", "segment_seconds"] }, + { key: "planning", label: "Planning", color: "#6d5d2a", widgets: ["planning_mode", "content_profile", "overlap_frames", "ltx_round_mode"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": [ + { key: "preset", label: "Preset", color: "#445c75", widgets: ["ui_preset"] }, + { key: "prompts", label: "Prompting", color: "#6b4f2a", widgets: ["positive_text", "negative_text"] }, + { key: "video", label: "Video", color: "#2f4d7a", widgets: ["width", "height"] }, + { key: "sampling", label: "Sampling", color: "#5b3a73", widgets: ["steps", "cfg", "sampler_name", "seed", "max_shift", "base_shift", "sigma_terminal", "manual_sigmas", "image_strength", "image_compression"] }, + { key: "audio", label: "Audio", color: "#2f6a47", widgets: ["audio_context_mode", "audio_left_context_s", "audio_right_context_s"] }, + { key: "stitch", label: "Stitch", color: "#7a4f2f", widgets: ["stitch_preset", "overlap_side", "overlap_mode", "start_frames_rule"] }, + { key: "anchor", label: "Anchor", color: "#8a4d4d", widgets: ["continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength"] }, + { key: "modular", label: "Modular", color: "#35566e", widgets: ["modular_decode", "downstream_stage_mode", "output_root"] }, + { key: "debug", label: "Debug", color: "#5b5b2f", widgets: ["segment_overlay_mode", "segment_overlay_text"] }, + ], + "IAMCCS-SuperNodes Second Stage": [ + { key: "stage2", label: "Second Stage", color: "#6f4a7f", widgets: ["second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas"] }, + ], + IAMCCS_GC_AudioConcatSupernode: [ + { key: "concat", label: "Audio Concat", color: "#3e6f55", widgets: ["concat_mode", "clip_durations_seconds", "gap_seconds", "intro_seconds", "outro_seconds"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": [ + { key: "preset", label: "Preset", color: "#445c75", widgets: ["ui_preset"] }, + { key: "decode", label: "Decode", color: "#3e5f82", widgets: ["frame_rate", "decode_mode", "output_root", "frames_subdir", "image_format", "jpg_quality", "tiled_tile_size", "tiled_overlap"] }, + { key: "final", label: "Finalize", color: "#6c5330", widgets: ["filename_prefix", "crf", "pix_fmt", "trim_to_audio"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Finalize": [ + { key: "output", label: "Output", color: "#556677", widgets: ["frame_rate", "filename_prefix", "crf", "pix_fmt", "trim_to_audio"] }, + ], +}; + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget?.name === name); +} + +function setWidgetVisibility(widget, visible) { + if (!widget || widget.type === "converted-widget") { + return; + } + + widget.hidden = !visible; + widget.disabled = !visible; + + if (widget.element) { + widget.element.style.display = visible ? "" : "none"; + } + if (widget.inputEl) { + widget.inputEl.style.display = visible ? "" : "none"; + } + + if (visible) { + if (Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.computeSize = widget.__iamccsOrigComputeSize; + } else { + delete widget.computeSize; + } + } else { + if (!Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.__iamccsOrigComputeSize = widget.computeSize; + } + widget.computeSize = () => [0, -4]; + widget.y = undefined; + widget.last_y = undefined; + } +} + +function fitNodeToWidgets(node) { + const size = node.computeSize?.() || node.size; + node.setSize([ + Math.max(node.size?.[0] || 0, size[0]), + Math.max(size[1], size[1] + 8), + ]); +} + +function insertWidget(node, widget, index) { + const widgets = node.widgets || []; + const currentIndex = widgets.indexOf(widget); + if (currentIndex >= 0) { + widgets.splice(currentIndex, 1); + } + widgets.splice(index, 0, widget); +} + +function setWidgetValue(node, widgetName, value) { + const widget = findWidget(node, widgetName); + if (!widget || widget.value === value) { + return; + } + widget.value = value; + widget.callback?.(value); +} + +function getLinxTargets(node) { + const results = []; + for (const output of node.outputs || []) { + if (output?.type !== "IAMCCS_SUPERNODE_LINX") { + continue; + } + for (const linkId of output.links || []) { + const link = app.graph.links?.[linkId]; + if (!link) { + continue; + } + const targetNode = app.graph.getNodeById?.(link.target_id); + if (targetNode) { + results.push(targetNode); + } + } + } + return results; +} + +function applyVaeDecodeModeVisibility(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + return; + } + const decodeMode = String(findWidget(node, "decode_mode")?.value || "low_ram"); + const isLowRam = decodeMode === "low_ram"; + const isNormal = decodeMode === "normal"; + setWidgetVisibility(findWidget(node, "frames_subdir"), isLowRam); + setWidgetVisibility(findWidget(node, "image_format"), isLowRam); + setWidgetVisibility(findWidget(node, "jpg_quality"), isLowRam); + setWidgetVisibility(findWidget(node, "tiled_tile_size"), isNormal); + setWidgetVisibility(findWidget(node, "tiled_overlap"), isNormal); + fitNodeToWidgets(node); +} + +function syncDownstreamVaeDecodeModes(renderNode) { + const renderDecode = String(findWidget(renderNode, "modular_decode")?.value || "low_ram"); + const visited = new Set(); + const queue = [...getLinxTargets(renderNode)]; + while (queue.length > 0) { + const node = queue.shift(); + if (!node || visited.has(node.id)) { + continue; + } + visited.add(node.id); + if (node.comfyClass === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + setWidgetValue(node, "decode_mode", renderDecode); + setWidgetValue(node, "ui_preset", "custom"); + applyVaeDecodeModeVisibility(node); + app.graph.setDirtyCanvas(true, true); + continue; + } + if (node.comfyClass === "IAMCCS-SuperNodes Second Stage") { + queue.push(...getLinxTargets(node)); + } + } +} + +function applyPresetConfig(node, nodeName) { + const config = PRESET_CONFIGS[nodeName]; + if (!config) { + return; + } + const presetWidget = findWidget(node, config.presetWidget); + if (!presetWidget) { + return; + } + const presetName = String(presetWidget.value || config.defaultPreset || "custom"); + if (presetName !== "custom") { + const values = config.values?.[presetName] || {}; + Object.entries(values).forEach(([widgetName, widgetValue]) => setWidgetValue(node, widgetName, widgetValue)); + } + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + fitNodeToWidgets(node); +} + +function getSerializableWidgets(node) { + return (node.widgets || []).filter((widget) => widget && widget.serialize !== false); +} + +function rehydrateSerializedWidgets(node, serializedValues) { + if (!Array.isArray(serializedValues) || !node.widgets?.length) { + return; + } + + const widgets = getSerializableWidgets(node); + const count = Math.min(widgets.length, serializedValues.length); + for (let index = 0; index < count; index += 1) { + const widget = widgets[index]; + if (!widget) { + continue; + } + widget.value = serializedValues[index]; + } +} + +function addSectionButton(node, group, index) { + // Duplicate guard: if a button for this section key is already in the widget list, + // return it immediately. This prevents double-insertion if onNodeCreated or + // enhanceNodeLayout is called more than once on the same node instance. + const existing = node.widgets?.find((w) => w._iamccsSectionKey === group.key); + if (existing) { + return existing; + } + + node.properties = node.properties || {}; + const propKey = `iamccs_section_${group.key}`; + if (node.properties[propKey] === undefined) { + node.properties[propKey] = true; + } + + const sectionButton = node.addWidget("button", "", "", () => { + node.properties[propKey] = !node.properties[propKey]; + applyGroupVisibility(node, group, propKey, sectionButton); + }); + // IMPORTANT: do NOT set serialize = false. + // Section buttons must occupy a slot in widgets_values so that ComfyUI's + // index-based configure() assignment keeps real widget values aligned. + // Their value (empty string / null) is harmless. + sectionButton._iamccsSectionKey = group.key; + sectionButton.computeSize = (width) => [width || 280, 28]; + sectionButton.label = group.label; + sectionButton.options = { bgcolor: group.color || "#405060", color: "#f4f7fa" }; + + applyGroupVisibility(node, group, propKey, sectionButton); + insertWidget(node, sectionButton, index); + return sectionButton; +} + +function applyGroupVisibility(node, group, propKey, button) { + const isExpanded = !!node.properties[propKey]; + button.name = `${isExpanded ? "β–Ό" : "β–Ά"} ${group.label}`; + for (const widgetName of group.widgets) { + setWidgetVisibility(findWidget(node, widgetName), isExpanded); + } + fitNodeToWidgets(node); + app.graph.setDirtyCanvas(true, true); +} + +function refreshNodeLayoutState(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + for (const group of groups) { + const button = node.widgets?.find((widget) => widget?._iamccsSectionKey === group.key); + if (!button) { + continue; + } + applyGroupVisibility(node, group, `iamccs_section_${group.key}`, button); + } + + const config = PRESET_CONFIGS[nodeName]; + if (config) { + const presetWidget = findWidget(node, config.presetWidget); + const presetName = String(presetWidget?.value || config.defaultPreset || "custom"); + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } else { + fitNodeToWidgets(node); + } +} + +function addPlannerChip(node) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === "planner_chip_preview")) { + return; + } + const chipWidget = { + type: "custom", + name: "planner_chip_preview", + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 30]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.iamccsPlannerChip || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 22; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = "#18312b"; + ctx.strokeStyle = "#6ea88d"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 9); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#d8efe2"; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(chipWidget); +} + +function addStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 42]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 34; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function addMultiLineStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 78]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const lines = String(text).split("\n").filter(Boolean).slice(0, 4); + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 70; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "top"; + lines.forEach((line, index) => { + ctx.fillText(line, x + 10, top + 8 + index * 15); + }); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function enhanceNodeLayout(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + if (!groups.length || !node.widgets?.length) { + return; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + addPlannerChip(node); + addMultiLineStatusBox(node, "iamccsPlannerDetails", "planner_details_preview", "#24362d", "#72ab8e", "#e4f2e8"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + addStatusBox(node, "iamccsRenderStatus", "render_status_preview", "#1e2f3f", "#6f93ba", "#e4edf7"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + + for (let index = groups.length - 1; index >= 0; index -= 1) { + const group = groups[index]; + const firstWidgetIndex = node.widgets.findIndex((widget) => group.widgets.includes(widget?.name)); + if (firstWidgetIndex >= 0) { + addSectionButton(node, group, firstWidgetIndex); + } + } + + applyPresetConfig(node, nodeName); + fitNodeToWidgets(node); +} + +app.registerExtension({ + name: "IAMCCS.SuperNodesExecUI", + + async beforeRegisterNodeDef(nodeType, nodeData) { + const nodeName = nodeData?.name; + if (!NODE_GROUPS[nodeName]) { + return; + } + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + // MUST run synchronously, BEFORE configure() assigns widgets_values by index. + // Section buttons inserted here occupy their index slots so that the null + // placeholders in widgets_values land on buttons, not on real widgets. + // (Using setTimeout here was the root cause of NaN on load / after undo.) + enhanceNodeLayout(this, nodeName); + const config = PRESET_CONFIGS[nodeName]; + const presetWidget = config ? findWidget(this, config.presetWidget) : null; + if (presetWidget) { + const originalCallback = presetWidget.callback; + presetWidget.callback = (...args) => { + originalCallback?.apply(presetWidget, args); + applyPresetConfig(this, nodeName); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + syncDownstreamVaeDecodeModes(this); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(this); + } + app.graph.setDirtyCanvas(true, true); + }; + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const modularDecodeWidget = findWidget(this, "modular_decode"); + if (modularDecodeWidget) { + const originalCallback = modularDecodeWidget.callback; + modularDecodeWidget.callback = (...args) => { + originalCallback?.apply(modularDecodeWidget, args); + syncDownstreamVaeDecodeModes(this); + app.graph.setDirtyCanvas(true, true); + }; + syncDownstreamVaeDecodeModes(this); + } + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + const decodeModeWidget = findWidget(this, "decode_mode"); + if (decodeModeWidget) { + const originalCallback = decodeModeWidget.callback; + decodeModeWidget.callback = (...args) => { + originalCallback?.apply(decodeModeWidget, args); + applyVaeDecodeModeVisibility(this); + app.graph.setDirtyCanvas(true, true); + }; + } + } + return result; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + const result = onConfigure?.apply(this, arguments); + enhanceNodeLayout(this, nodeName); + rehydrateSerializedWidgets(this, info?.widgets_values); + refreshNodeLayoutState(this, nodeName); + return result; + }; + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + this.properties = this.properties || {}; + if (Array.isArray(message?.planner_chip) && message.planner_chip.length > 0) { + this.properties.iamccsPlannerChip = String(message.planner_chip[0] || ""); + } + const details = []; + const duration = Array.isArray(message?.planned_duration_seconds) && message.planned_duration_seconds.length > 0 + ? message.planned_duration_seconds[0] + : Array.isArray(message?.duration_seconds) && message.duration_seconds.length > 0 + ? message.duration_seconds[0] + : null; + const totalFrames = Array.isArray(message?.planned_total_frames) && message.planned_total_frames.length > 0 + ? message.planned_total_frames[0] + : Array.isArray(message?.total_frames) && message.total_frames.length > 0 + ? message.total_frames[0] + : null; + const segments = Array.isArray(message?.planned_segment_count) && message.planned_segment_count.length > 0 + ? message.planned_segment_count[0] + : Array.isArray(message?.segment_count) && message.segment_count.length > 0 + ? message.segment_count[0] + : null; + if (duration !== null) { + details.push(`duration ${Number(duration || 0).toFixed(2)}s`); + } + if (totalFrames !== null) { + details.push(`total ${totalFrames}f`); + } + if (segments !== null) { + details.push(`segments ${segments}`); + } + if (Array.isArray(message?.recommended_overlap_frames) && message.recommended_overlap_frames.length > 0) { + details.push(`overlap ${message.recommended_overlap_frames[0]}f`); + } + if (Array.isArray(message?.recommended_audio_left_context_s) && message.recommended_audio_left_context_s.length > 0) { + details.push(`left ctx ${Number(message.recommended_audio_left_context_s[0] || 0).toFixed(2)}s`); + } + if (Array.isArray(message?.planning_report) && message.planning_report.length > 0) { + details.push(String(message.planning_report[0] || "")); + } + if (details.length > 0) { + this.properties.iamccsPlannerDetails = details.join("\n"); + app.graph.setDirtyCanvas(true, true); + } + }; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + if (Array.isArray(message?.report) && message.report.length > 0) { + this.properties = this.properties || {}; + this.properties.iamccsRenderStatus = String(message.report[0] || "").split("\n")[0]; + app.graph.setDirtyCanvas(true, true); + } + }; + } + }, +}); diff --git a/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_section_button_style_refresh.bak.js b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_section_button_style_refresh.bak.js new file mode 100644 index 0000000..043b3d1 --- /dev/null +++ b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_section_button_style_refresh.bak.js @@ -0,0 +1,602 @@ +import { app } from "../../../scripts/app.js"; + +const PRESET_CONFIGS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { modular_decode: "low_ram", steps: 16, image_compression: 40, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + balanced: { modular_decode: "normal", steps: 20, image_compression: 33, continuity_anchor_mode: "off", anchor_refresh_interval: 3, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + high_quality: { modular_decode: "high", steps: 24, image_compression: 28, continuity_anchor_mode: "off", anchor_refresh_interval: 1, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + fast_preview: { modular_decode: "low_ram", steps: 12, image_compression: 45, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + }, + visibility: { + low_ram_safe: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + balanced: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + high_quality: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + fast_preview: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: false }, + }, + }, + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { decode_mode: "low_ram", jpg_quality: 92, crf: 21, tiled_tile_size: 512, tiled_overlap: 64 }, + balanced: { decode_mode: "normal", jpg_quality: 95, crf: 19, tiled_tile_size: 512, tiled_overlap: 64 }, + high_quality: { decode_mode: "high", jpg_quality: 100, crf: 16, tiled_tile_size: 768, tiled_overlap: 96 }, + fast_preview: { decode_mode: "low_ram", jpg_quality: 88, crf: 24, tiled_tile_size: 384, tiled_overlap: 48 }, + }, + visibility: { + low_ram_safe: { frames_subdir: true, image_format: true, jpg_quality: true }, + balanced: { frames_subdir: true, image_format: true, jpg_quality: true }, + high_quality: { frames_subdir: true, image_format: true, jpg_quality: true }, + fast_preview: { frames_subdir: true, image_format: true, jpg_quality: true }, + }, + }, +}; + +const NODE_GROUPS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Planner": [ + { key: "timeline", label: "Timeline", color: "#2d6f63", widgets: ["fps", "segment_seconds"] }, + { key: "planning", label: "Planning", color: "#6d5d2a", widgets: ["planning_mode", "segment_preset", "overlap_frames", "ltx_round_mode"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": [ + { key: "preset", label: "Preset", color: "#445c75", widgets: ["ui_preset"] }, + { key: "prompts", label: "Prompting", color: "#6b4f2a", widgets: ["positive_text", "negative_text"] }, + { key: "video", label: "Video", color: "#2f4d7a", widgets: ["width", "height"] }, + { key: "sampling", label: "Sampling", color: "#5b3a73", widgets: ["steps", "cfg", "sampler_name", "seed", "max_shift", "base_shift", "sigma_terminal", "manual_sigmas", "image_strength", "image_compression"] }, + { key: "audio", label: "Audio", color: "#2f6a47", widgets: ["audio_context_mode", "audio_left_context_s", "audio_right_context_s"] }, + { key: "stitch", label: "Stitch", color: "#7a4f2f", widgets: ["stitch_preset", "overlap_side", "overlap_mode", "start_frames_rule"] }, + { key: "anchor", label: "Anchor", color: "#8a4d4d", widgets: ["continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength"] }, + { key: "modular", label: "Modular", color: "#35566e", widgets: ["modular_decode", "downstream_stage_mode", "output_root"] }, + { key: "debug", label: "Debug", color: "#5b5b2f", widgets: ["segment_overlay_mode", "segment_overlay_text"] }, + ], + "IAMCCS-SuperNodes Second Stage": [ + { key: "stage2", label: "Second Stage", color: "#6f4a7f", widgets: ["second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas"] }, + ], + IAMCCS_GC_AudioConcatSupernode: [ + { key: "concat", label: "Audio Concat", color: "#3e6f55", widgets: ["concat_mode", "clip_durations_seconds", "gap_seconds", "intro_seconds", "outro_seconds"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": [ + { key: "preset", label: "Preset", color: "#445c75", widgets: ["ui_preset"] }, + { key: "decode", label: "Decode", color: "#3e5f82", widgets: ["frame_rate", "decode_mode", "output_root", "frames_subdir", "image_format", "jpg_quality", "tiled_tile_size", "tiled_overlap"] }, + { key: "final", label: "Finalize", color: "#6c5330", widgets: ["filename_prefix", "crf", "pix_fmt", "trim_to_audio", "save_metadata"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Finalize": [ + { key: "output", label: "Output", color: "#556677", widgets: ["frame_rate", "filename_prefix", "crf", "pix_fmt", "trim_to_audio"] }, + ], +}; + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget?.name === name); +} + +function setWidgetVisibility(widget, visible) { + if (!widget || widget.type === "converted-widget") { + return; + } + + widget.hidden = !visible; + widget.disabled = !visible; + + if (widget.element) { + widget.element.style.display = visible ? "" : "none"; + } + if (widget.inputEl) { + widget.inputEl.style.display = visible ? "" : "none"; + } + + if (visible) { + if (Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.computeSize = widget.__iamccsOrigComputeSize; + } else { + delete widget.computeSize; + } + } else { + if (!Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.__iamccsOrigComputeSize = widget.computeSize; + } + widget.computeSize = () => [0, -4]; + widget.y = undefined; + widget.last_y = undefined; + } +} + +function fitNodeToWidgets(node) { + const size = node.computeSize?.() || node.size; + node.setSize([ + Math.max(node.size?.[0] || 0, size[0]), + Math.max(size[1], size[1] + 8), + ]); +} + +function insertWidget(node, widget, index) { + const widgets = node.widgets || []; + const currentIndex = widgets.indexOf(widget); + if (currentIndex >= 0) { + widgets.splice(currentIndex, 1); + } + widgets.splice(index, 0, widget); +} + +function setWidgetValue(node, widgetName, value) { + const widget = findWidget(node, widgetName); + if (!widget || widget.value === value) { + return; + } + widget.value = value; + widget.callback?.(value); +} + +function getLinxTargets(node) { + const results = []; + for (const output of node.outputs || []) { + if (output?.type !== "IAMCCS_SUPERNODE_LINX") { + continue; + } + for (const linkId of output.links || []) { + const link = app.graph.links?.[linkId]; + if (!link) { + continue; + } + const targetNode = app.graph.getNodeById?.(link.target_id); + if (targetNode) { + results.push(targetNode); + } + } + } + return results; +} + +function applyVaeDecodeModeVisibility(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + return; + } + const decodeMode = String(findWidget(node, "decode_mode")?.value || "low_ram"); + const isLowRam = decodeMode === "low_ram"; + const isNormal = decodeMode === "normal"; + setWidgetVisibility(findWidget(node, "frames_subdir"), isLowRam); + setWidgetVisibility(findWidget(node, "image_format"), isLowRam); + setWidgetVisibility(findWidget(node, "jpg_quality"), isLowRam); + setWidgetVisibility(findWidget(node, "tiled_tile_size"), isNormal); + setWidgetVisibility(findWidget(node, "tiled_overlap"), isNormal); + fitNodeToWidgets(node); +} + +function syncDownstreamVaeDecodeModes(renderNode) { + const renderDecode = String(findWidget(renderNode, "modular_decode")?.value || "low_ram"); + const visited = new Set(); + const queue = [...getLinxTargets(renderNode)]; + while (queue.length > 0) { + const node = queue.shift(); + if (!node || visited.has(node.id)) { + continue; + } + visited.add(node.id); + if (node.comfyClass === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + setWidgetValue(node, "decode_mode", renderDecode); + setWidgetValue(node, "ui_preset", "custom"); + applyVaeDecodeModeVisibility(node); + app.graph.setDirtyCanvas(true, true); + continue; + } + if (node.comfyClass === "IAMCCS-SuperNodes Second Stage") { + queue.push(...getLinxTargets(node)); + } + } +} + +function applyPresetConfig(node, nodeName) { + const config = PRESET_CONFIGS[nodeName]; + if (!config) { + return; + } + const presetWidget = findWidget(node, config.presetWidget); + if (!presetWidget) { + return; + } + const presetName = String(presetWidget.value || config.defaultPreset || "custom"); + if (presetName !== "custom") { + const values = config.values?.[presetName] || {}; + Object.entries(values).forEach(([widgetName, widgetValue]) => setWidgetValue(node, widgetName, widgetValue)); + } + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + fitNodeToWidgets(node); +} + +function getSerializableWidgets(node) { + return (node.widgets || []).filter((widget) => widget && widget.serialize !== false); +} + +function rehydrateSerializedWidgets(node, serializedValues) { + if (!Array.isArray(serializedValues) || !node.widgets?.length) { + return; + } + + const widgets = getSerializableWidgets(node); + const count = Math.min(widgets.length, serializedValues.length); + for (let index = 0; index < count; index += 1) { + const widget = widgets[index]; + if (!widget) { + continue; + } + widget.value = serializedValues[index]; + } +} + +function addSectionButton(node, group, index) { + // Duplicate guard: if a button for this section key is already in the widget list, + // return it immediately. This prevents double-insertion if onNodeCreated or + // enhanceNodeLayout is called more than once on the same node instance. + const existing = node.widgets?.find((w) => w._iamccsSectionKey === group.key); + if (existing) { + return existing; + } + + node.properties = node.properties || {}; + const propKey = `iamccs_section_${group.key}`; + if (node.properties[propKey] === undefined) { + node.properties[propKey] = true; + } + + const sectionButton = node.addWidget("button", "", "", () => { + node.properties[propKey] = !node.properties[propKey]; + applyGroupVisibility(node, group, propKey, sectionButton); + }); + // IMPORTANT: do NOT set serialize = false. + // Section buttons must occupy a slot in widgets_values so that ComfyUI's + // index-based configure() assignment keeps real widget values aligned. + // Their value (empty string / null) is harmless. + sectionButton._iamccsSectionKey = group.key; + sectionButton.computeSize = (width) => [width || 280, 28]; + sectionButton.label = group.label; + sectionButton.options = { bgcolor: group.color || "#405060", color: "#f4f7fa" }; + + applyGroupVisibility(node, group, propKey, sectionButton); + insertWidget(node, sectionButton, index); + return sectionButton; +} + +function applyGroupVisibility(node, group, propKey, button) { + const isExpanded = !!node.properties[propKey]; + button.name = `${isExpanded ? "β–Ό" : "β–Ά"} ${group.label}`; + for (const widgetName of group.widgets) { + setWidgetVisibility(findWidget(node, widgetName), isExpanded); + } + fitNodeToWidgets(node); + app.graph.setDirtyCanvas(true, true); +} + +function applyPlannerModeVisibility(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + return; + } + const planningMode = String(findWidget(node, "planning_mode")?.value || "manual_segment_seconds"); + const planningExpanded = !!node.properties?.iamccs_section_planning; + const showSegmentPreset = planningExpanded && planningMode === "explicit_preset_seconds"; + setWidgetVisibility(findWidget(node, "segment_preset"), showSegmentPreset); + fitNodeToWidgets(node); +} + +function refreshNodeLayoutState(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + for (const group of groups) { + const button = node.widgets?.find((widget) => widget?._iamccsSectionKey === group.key); + if (!button) { + continue; + } + applyGroupVisibility(node, group, `iamccs_section_${group.key}`, button); + } + + const config = PRESET_CONFIGS[nodeName]; + if (config) { + const presetWidget = findWidget(node, config.presetWidget); + const presetName = String(presetWidget?.value || config.defaultPreset || "custom"); + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } else if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + applyPlannerModeVisibility(node); + } else { + fitNodeToWidgets(node); + } +} + +function addPlannerChip(node) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === "planner_chip_preview")) { + return; + } + const chipWidget = { + type: "custom", + name: "planner_chip_preview", + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 30]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.iamccsPlannerChip || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 22; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = "#18312b"; + ctx.strokeStyle = "#6ea88d"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 9); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#d8efe2"; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(chipWidget); +} + +function addStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 42]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 34; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function addMultiLineStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 78]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const lines = String(text).split("\n").filter(Boolean).slice(0, 4); + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 70; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "top"; + lines.forEach((line, index) => { + ctx.fillText(line, x + 10, top + 8 + index * 15); + }); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function enhanceNodeLayout(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + if (!groups.length || !node.widgets?.length) { + return; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + addPlannerChip(node); + addMultiLineStatusBox(node, "iamccsPlannerDetails", "planner_details_preview", "#24362d", "#72ab8e", "#e4f2e8"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + addStatusBox(node, "iamccsRenderStatus", "render_status_preview", "#1e2f3f", "#6f93ba", "#e4edf7"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + + for (let index = groups.length - 1; index >= 0; index -= 1) { + const group = groups[index]; + const firstWidgetIndex = node.widgets.findIndex((widget) => group.widgets.includes(widget?.name)); + if (firstWidgetIndex >= 0) { + addSectionButton(node, group, firstWidgetIndex); + } + } + + applyPresetConfig(node, nodeName); + fitNodeToWidgets(node); +} + +app.registerExtension({ + name: "IAMCCS.SuperNodesExecUI", + + async beforeRegisterNodeDef(nodeType, nodeData) { + const nodeName = nodeData?.name; + if (!NODE_GROUPS[nodeName]) { + return; + } + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + // MUST run synchronously, BEFORE configure() assigns widgets_values by index. + // Section buttons inserted here occupy their index slots so that the null + // placeholders in widgets_values land on buttons, not on real widgets. + // (Using setTimeout here was the root cause of NaN on load / after undo.) + enhanceNodeLayout(this, nodeName); + const config = PRESET_CONFIGS[nodeName]; + const presetWidget = config ? findWidget(this, config.presetWidget) : null; + if (presetWidget) { + const originalCallback = presetWidget.callback; + presetWidget.callback = (...args) => { + originalCallback?.apply(presetWidget, args); + applyPresetConfig(this, nodeName); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + syncDownstreamVaeDecodeModes(this); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(this); + } + app.graph.setDirtyCanvas(true, true); + }; + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const modularDecodeWidget = findWidget(this, "modular_decode"); + if (modularDecodeWidget) { + const originalCallback = modularDecodeWidget.callback; + modularDecodeWidget.callback = (...args) => { + originalCallback?.apply(modularDecodeWidget, args); + syncDownstreamVaeDecodeModes(this); + app.graph.setDirtyCanvas(true, true); + }; + syncDownstreamVaeDecodeModes(this); + } + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + const planningModeWidget = findWidget(this, "planning_mode"); + if (planningModeWidget) { + const originalCallback = planningModeWidget.callback; + planningModeWidget.callback = (...args) => { + originalCallback?.apply(planningModeWidget, args); + applyPlannerModeVisibility(this); + app.graph.setDirtyCanvas(true, true); + }; + } + applyPlannerModeVisibility(this); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + const decodeModeWidget = findWidget(this, "decode_mode"); + if (decodeModeWidget) { + const originalCallback = decodeModeWidget.callback; + decodeModeWidget.callback = (...args) => { + originalCallback?.apply(decodeModeWidget, args); + applyVaeDecodeModeVisibility(this); + app.graph.setDirtyCanvas(true, true); + }; + } + } + return result; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + const result = onConfigure?.apply(this, arguments); + enhanceNodeLayout(this, nodeName); + rehydrateSerializedWidgets(this, info?.widgets_values); + refreshNodeLayoutState(this, nodeName); + return result; + }; + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + this.properties = this.properties || {}; + if (Array.isArray(message?.planner_chip) && message.planner_chip.length > 0) { + this.properties.iamccsPlannerChip = String(message.planner_chip[0] || ""); + } + const details = []; + const duration = Array.isArray(message?.planned_duration_seconds) && message.planned_duration_seconds.length > 0 + ? message.planned_duration_seconds[0] + : Array.isArray(message?.duration_seconds) && message.duration_seconds.length > 0 + ? message.duration_seconds[0] + : null; + const totalFrames = Array.isArray(message?.planned_total_frames) && message.planned_total_frames.length > 0 + ? message.planned_total_frames[0] + : Array.isArray(message?.total_frames) && message.total_frames.length > 0 + ? message.total_frames[0] + : null; + const segments = Array.isArray(message?.planned_segment_count) && message.planned_segment_count.length > 0 + ? message.planned_segment_count[0] + : Array.isArray(message?.segment_count) && message.segment_count.length > 0 + ? message.segment_count[0] + : null; + if (duration !== null) { + details.push(`duration ${Number(duration || 0).toFixed(2)}s`); + } + if (totalFrames !== null) { + details.push(`total ${totalFrames}f`); + } + if (segments !== null) { + details.push(`segments ${segments}`); + } + if (Array.isArray(message?.recommended_overlap_frames) && message.recommended_overlap_frames.length > 0) { + details.push(`overlap ${message.recommended_overlap_frames[0]}f`); + } + if (Array.isArray(message?.recommended_audio_left_context_s) && message.recommended_audio_left_context_s.length > 0) { + details.push(`left ctx ${Number(message.recommended_audio_left_context_s[0] || 0).toFixed(2)}s`); + } + if (Array.isArray(message?.planning_report) && message.planning_report.length > 0) { + details.push(String(message.planning_report[0] || "")); + } + if (details.length > 0) { + this.properties.iamccsPlannerDetails = details.join("\n"); + app.graph.setDirtyCanvas(true, true); + } + }; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + if (Array.isArray(message?.report) && message.report.length > 0) { + this.properties = this.properties || {}; + this.properties.iamccsRenderStatus = String(message.report[0] || "").split("\n")[0]; + app.graph.setDirtyCanvas(true, true); + } + }; + } + }, +}); diff --git a/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_segment_overlay.bak.js b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_segment_overlay.bak.js new file mode 100644 index 0000000..8f3a913 --- /dev/null +++ b/backup/web_disabled/iamccs_supernodes_exec_ui.20260422_segment_overlay.bak.js @@ -0,0 +1,576 @@ +import { app } from "../../../scripts/app.js"; + +const PRESET_CONFIGS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { modular_decode: "low_ram", steps: 16, image_compression: 40, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + balanced: { modular_decode: "normal", steps: 20, image_compression: 33, continuity_anchor_mode: "off", anchor_refresh_interval: 3, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + high_quality: { modular_decode: "high", steps: 24, image_compression: 28, continuity_anchor_mode: "off", anchor_refresh_interval: 1, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + fast_preview: { modular_decode: "low_ram", steps: 12, image_compression: 45, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + }, + visibility: { + low_ram_safe: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + balanced: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + high_quality: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + fast_preview: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: false }, + }, + }, + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { decode_mode: "low_ram", jpg_quality: 92, crf: 21, tiled_tile_size: 512, tiled_overlap: 64 }, + balanced: { decode_mode: "normal", jpg_quality: 95, crf: 19, tiled_tile_size: 512, tiled_overlap: 64 }, + high_quality: { decode_mode: "high", jpg_quality: 100, crf: 16, tiled_tile_size: 768, tiled_overlap: 96 }, + fast_preview: { decode_mode: "low_ram", jpg_quality: 88, crf: 24, tiled_tile_size: 384, tiled_overlap: 48 }, + }, + visibility: { + low_ram_safe: { frames_subdir: true, image_format: true, jpg_quality: true }, + balanced: { frames_subdir: true, image_format: true, jpg_quality: true }, + high_quality: { frames_subdir: true, image_format: true, jpg_quality: true }, + fast_preview: { frames_subdir: true, image_format: true, jpg_quality: true }, + }, + }, +}; + +const NODE_GROUPS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Planner": [ + { key: "timeline", label: "Timeline", color: "#2d6f63", widgets: ["fps", "segment_seconds"] }, + { key: "planning", label: "Planning", color: "#6d5d2a", widgets: ["planning_mode", "content_profile", "overlap_frames", "ltx_round_mode"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": [ + { key: "preset", label: "Preset", color: "#445c75", widgets: ["ui_preset"] }, + { key: "prompts", label: "Prompting", color: "#6b4f2a", widgets: ["positive_text", "negative_text"] }, + { key: "video", label: "Video", color: "#2f4d7a", widgets: ["width", "height"] }, + { key: "sampling", label: "Sampling", color: "#5b3a73", widgets: ["steps", "cfg", "sampler_name", "seed", "max_shift", "base_shift", "sigma_terminal", "manual_sigmas", "image_strength", "image_compression"] }, + { key: "audio", label: "Audio", color: "#2f6a47", widgets: ["audio_context_mode", "audio_left_context_s", "audio_right_context_s"] }, + { key: "stitch", label: "Stitch", color: "#7a4f2f", widgets: ["stitch_preset", "overlap_side", "overlap_mode", "start_frames_rule"] }, + { key: "anchor", label: "Anchor", color: "#8a4d4d", widgets: ["continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength"] }, + { key: "modular", label: "Modular", color: "#35566e", widgets: ["modular_decode", "downstream_stage_mode", "output_root"] }, + ], + "IAMCCS-SuperNodes Second Stage": [ + { key: "stage2", label: "Second Stage", color: "#6f4a7f", widgets: ["second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas"] }, + ], + IAMCCS_GC_AudioConcatSupernode: [ + { key: "concat", label: "Audio Concat", color: "#3e6f55", widgets: ["concat_mode", "clip_durations_seconds", "gap_seconds", "intro_seconds", "outro_seconds"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": [ + { key: "preset", label: "Preset", color: "#445c75", widgets: ["ui_preset"] }, + { key: "decode", label: "Decode", color: "#3e5f82", widgets: ["frame_rate", "decode_mode", "output_root", "frames_subdir", "image_format", "jpg_quality", "tiled_tile_size", "tiled_overlap"] }, + { key: "final", label: "Finalize", color: "#6c5330", widgets: ["filename_prefix", "crf", "pix_fmt", "trim_to_audio"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Finalize": [ + { key: "output", label: "Output", color: "#556677", widgets: ["frame_rate", "filename_prefix", "crf", "pix_fmt", "trim_to_audio"] }, + ], +}; + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget?.name === name); +} + +function setWidgetVisibility(widget, visible) { + if (!widget || widget.type === "converted-widget") { + return; + } + + widget.hidden = !visible; + widget.disabled = !visible; + + if (widget.element) { + widget.element.style.display = visible ? "" : "none"; + } + if (widget.inputEl) { + widget.inputEl.style.display = visible ? "" : "none"; + } + + if (visible) { + if (Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.computeSize = widget.__iamccsOrigComputeSize; + } else { + delete widget.computeSize; + } + } else { + if (!Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.__iamccsOrigComputeSize = widget.computeSize; + } + widget.computeSize = () => [0, -4]; + widget.y = undefined; + widget.last_y = undefined; + } +} + +function fitNodeToWidgets(node) { + const size = node.computeSize?.() || node.size; + node.setSize([ + Math.max(node.size?.[0] || 0, size[0]), + Math.max(size[1], size[1] + 8), + ]); +} + +function insertWidget(node, widget, index) { + const widgets = node.widgets || []; + const currentIndex = widgets.indexOf(widget); + if (currentIndex >= 0) { + widgets.splice(currentIndex, 1); + } + widgets.splice(index, 0, widget); +} + +function setWidgetValue(node, widgetName, value) { + const widget = findWidget(node, widgetName); + if (!widget || widget.value === value) { + return; + } + widget.value = value; + widget.callback?.(value); +} + +function getLinxTargets(node) { + const results = []; + for (const output of node.outputs || []) { + if (output?.type !== "IAMCCS_SUPERNODE_LINX") { + continue; + } + for (const linkId of output.links || []) { + const link = app.graph.links?.[linkId]; + if (!link) { + continue; + } + const targetNode = app.graph.getNodeById?.(link.target_id); + if (targetNode) { + results.push(targetNode); + } + } + } + return results; +} + +function applyVaeDecodeModeVisibility(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + return; + } + const decodeMode = String(findWidget(node, "decode_mode")?.value || "low_ram"); + const isLowRam = decodeMode === "low_ram"; + const isNormal = decodeMode === "normal"; + setWidgetVisibility(findWidget(node, "frames_subdir"), isLowRam); + setWidgetVisibility(findWidget(node, "image_format"), isLowRam); + setWidgetVisibility(findWidget(node, "jpg_quality"), isLowRam); + setWidgetVisibility(findWidget(node, "tiled_tile_size"), isNormal); + setWidgetVisibility(findWidget(node, "tiled_overlap"), isNormal); + fitNodeToWidgets(node); +} + +function syncDownstreamVaeDecodeModes(renderNode) { + const renderDecode = String(findWidget(renderNode, "modular_decode")?.value || "low_ram"); + const visited = new Set(); + const queue = [...getLinxTargets(renderNode)]; + while (queue.length > 0) { + const node = queue.shift(); + if (!node || visited.has(node.id)) { + continue; + } + visited.add(node.id); + if (node.comfyClass === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + setWidgetValue(node, "decode_mode", renderDecode); + setWidgetValue(node, "ui_preset", "custom"); + applyVaeDecodeModeVisibility(node); + app.graph.setDirtyCanvas(true, true); + continue; + } + if (node.comfyClass === "IAMCCS-SuperNodes Second Stage") { + queue.push(...getLinxTargets(node)); + } + } +} + +function applyPresetConfig(node, nodeName) { + const config = PRESET_CONFIGS[nodeName]; + if (!config) { + return; + } + const presetWidget = findWidget(node, config.presetWidget); + if (!presetWidget) { + return; + } + const presetName = String(presetWidget.value || config.defaultPreset || "custom"); + if (presetName !== "custom") { + const values = config.values?.[presetName] || {}; + Object.entries(values).forEach(([widgetName, widgetValue]) => setWidgetValue(node, widgetName, widgetValue)); + } + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + fitNodeToWidgets(node); +} + +function getSerializableWidgets(node) { + return (node.widgets || []).filter((widget) => widget && widget.serialize !== false); +} + +function rehydrateSerializedWidgets(node, serializedValues) { + if (!Array.isArray(serializedValues) || !node.widgets?.length) { + return; + } + + const widgets = getSerializableWidgets(node); + const count = Math.min(widgets.length, serializedValues.length); + for (let index = 0; index < count; index += 1) { + const widget = widgets[index]; + if (!widget) { + continue; + } + widget.value = serializedValues[index]; + } +} + +function addSectionButton(node, group, index) { + // Duplicate guard: if a button for this section key is already in the widget list, + // return it immediately. This prevents double-insertion if onNodeCreated or + // enhanceNodeLayout is called more than once on the same node instance. + const existing = node.widgets?.find((w) => w._iamccsSectionKey === group.key); + if (existing) { + return existing; + } + + node.properties = node.properties || {}; + const propKey = `iamccs_section_${group.key}`; + if (node.properties[propKey] === undefined) { + node.properties[propKey] = true; + } + + const sectionButton = node.addWidget("button", "", "", () => { + node.properties[propKey] = !node.properties[propKey]; + applyGroupVisibility(node, group, propKey, sectionButton); + }); + // IMPORTANT: do NOT set serialize = false. + // Section buttons must occupy a slot in widgets_values so that ComfyUI's + // index-based configure() assignment keeps real widget values aligned. + // Their value (empty string / null) is harmless. + sectionButton._iamccsSectionKey = group.key; + sectionButton.computeSize = (width) => [width || 280, 28]; + sectionButton.label = group.label; + sectionButton.options = { bgcolor: group.color || "#405060", color: "#f4f7fa" }; + + applyGroupVisibility(node, group, propKey, sectionButton); + insertWidget(node, sectionButton, index); + return sectionButton; +} + +function applyGroupVisibility(node, group, propKey, button) { + const isExpanded = !!node.properties[propKey]; + button.name = `${isExpanded ? "β–Ό" : "β–Ά"} ${group.label}`; + for (const widgetName of group.widgets) { + setWidgetVisibility(findWidget(node, widgetName), isExpanded); + } + fitNodeToWidgets(node); + app.graph.setDirtyCanvas(true, true); +} + +function refreshNodeLayoutState(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + for (const group of groups) { + const button = node.widgets?.find((widget) => widget?._iamccsSectionKey === group.key); + if (!button) { + continue; + } + applyGroupVisibility(node, group, `iamccs_section_${group.key}`, button); + } + + const config = PRESET_CONFIGS[nodeName]; + if (config) { + const presetWidget = findWidget(node, config.presetWidget); + const presetName = String(presetWidget?.value || config.defaultPreset || "custom"); + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } else { + fitNodeToWidgets(node); + } +} + +function addPlannerChip(node) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === "planner_chip_preview")) { + return; + } + const chipWidget = { + type: "custom", + name: "planner_chip_preview", + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 30]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.iamccsPlannerChip || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 22; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = "#18312b"; + ctx.strokeStyle = "#6ea88d"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 9); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#d8efe2"; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(chipWidget); +} + +function addStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 42]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 34; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function addMultiLineStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 78]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const lines = String(text).split("\n").filter(Boolean).slice(0, 4); + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 70; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "top"; + lines.forEach((line, index) => { + ctx.fillText(line, x + 10, top + 8 + index * 15); + }); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function enhanceNodeLayout(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + if (!groups.length || !node.widgets?.length) { + return; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + addPlannerChip(node); + addMultiLineStatusBox(node, "iamccsPlannerDetails", "planner_details_preview", "#24362d", "#72ab8e", "#e4f2e8"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + addStatusBox(node, "iamccsRenderStatus", "render_status_preview", "#1e2f3f", "#6f93ba", "#e4edf7"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + + for (let index = groups.length - 1; index >= 0; index -= 1) { + const group = groups[index]; + const firstWidgetIndex = node.widgets.findIndex((widget) => group.widgets.includes(widget?.name)); + if (firstWidgetIndex >= 0) { + addSectionButton(node, group, firstWidgetIndex); + } + } + + applyPresetConfig(node, nodeName); + fitNodeToWidgets(node); +} + +app.registerExtension({ + name: "IAMCCS.SuperNodesExecUI", + + async beforeRegisterNodeDef(nodeType, nodeData) { + const nodeName = nodeData?.name; + if (!NODE_GROUPS[nodeName]) { + return; + } + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + // MUST run synchronously, BEFORE configure() assigns widgets_values by index. + // Section buttons inserted here occupy their index slots so that the null + // placeholders in widgets_values land on buttons, not on real widgets. + // (Using setTimeout here was the root cause of NaN on load / after undo.) + enhanceNodeLayout(this, nodeName); + const config = PRESET_CONFIGS[nodeName]; + const presetWidget = config ? findWidget(this, config.presetWidget) : null; + if (presetWidget) { + const originalCallback = presetWidget.callback; + presetWidget.callback = (...args) => { + originalCallback?.apply(presetWidget, args); + applyPresetConfig(this, nodeName); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + syncDownstreamVaeDecodeModes(this); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(this); + } + app.graph.setDirtyCanvas(true, true); + }; + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const modularDecodeWidget = findWidget(this, "modular_decode"); + if (modularDecodeWidget) { + const originalCallback = modularDecodeWidget.callback; + modularDecodeWidget.callback = (...args) => { + originalCallback?.apply(modularDecodeWidget, args); + syncDownstreamVaeDecodeModes(this); + app.graph.setDirtyCanvas(true, true); + }; + syncDownstreamVaeDecodeModes(this); + } + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + const decodeModeWidget = findWidget(this, "decode_mode"); + if (decodeModeWidget) { + const originalCallback = decodeModeWidget.callback; + decodeModeWidget.callback = (...args) => { + originalCallback?.apply(decodeModeWidget, args); + applyVaeDecodeModeVisibility(this); + app.graph.setDirtyCanvas(true, true); + }; + } + } + return result; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + const result = onConfigure?.apply(this, arguments); + enhanceNodeLayout(this, nodeName); + rehydrateSerializedWidgets(this, info?.widgets_values); + refreshNodeLayoutState(this, nodeName); + return result; + }; + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + this.properties = this.properties || {}; + if (Array.isArray(message?.planner_chip) && message.planner_chip.length > 0) { + this.properties.iamccsPlannerChip = String(message.planner_chip[0] || ""); + } + const details = []; + const duration = Array.isArray(message?.planned_duration_seconds) && message.planned_duration_seconds.length > 0 + ? message.planned_duration_seconds[0] + : Array.isArray(message?.duration_seconds) && message.duration_seconds.length > 0 + ? message.duration_seconds[0] + : null; + const totalFrames = Array.isArray(message?.planned_total_frames) && message.planned_total_frames.length > 0 + ? message.planned_total_frames[0] + : Array.isArray(message?.total_frames) && message.total_frames.length > 0 + ? message.total_frames[0] + : null; + const segments = Array.isArray(message?.planned_segment_count) && message.planned_segment_count.length > 0 + ? message.planned_segment_count[0] + : Array.isArray(message?.segment_count) && message.segment_count.length > 0 + ? message.segment_count[0] + : null; + if (duration !== null) { + details.push(`duration ${Number(duration || 0).toFixed(2)}s`); + } + if (totalFrames !== null) { + details.push(`total ${totalFrames}f`); + } + if (segments !== null) { + details.push(`segments ${segments}`); + } + if (Array.isArray(message?.recommended_overlap_frames) && message.recommended_overlap_frames.length > 0) { + details.push(`overlap ${message.recommended_overlap_frames[0]}f`); + } + if (Array.isArray(message?.recommended_audio_left_context_s) && message.recommended_audio_left_context_s.length > 0) { + details.push(`left ctx ${Number(message.recommended_audio_left_context_s[0] || 0).toFixed(2)}s`); + } + if (Array.isArray(message?.planning_report) && message.planning_report.length > 0) { + details.push(String(message.planning_report[0] || "")); + } + if (details.length > 0) { + this.properties.iamccsPlannerDetails = details.join("\n"); + app.graph.setDirtyCanvas(true, true); + } + }; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + if (Array.isArray(message?.report) && message.report.length > 0) { + this.properties = this.properties || {}; + this.properties.iamccsRenderStatus = String(message.report[0] || "").split("\n")[0]; + app.graph.setDirtyCanvas(true, true); + } + }; + } + }, +}); diff --git a/backup/web_disabled/iamccs_supernodes_exec_ui.20260424_073005.explicit_preset_sync.bak.js b/backup/web_disabled/iamccs_supernodes_exec_ui.20260424_073005.explicit_preset_sync.bak.js new file mode 100644 index 0000000..90f93cc --- /dev/null +++ b/backup/web_disabled/iamccs_supernodes_exec_ui.20260424_073005.explicit_preset_sync.bak.js @@ -0,0 +1,640 @@ +import { app } from "../../../scripts/app.js"; + +const PRESET_CONFIGS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { modular_decode: "low_ram", steps: 16, image_compression: 40, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + balanced: { modular_decode: "normal", steps: 20, image_compression: 33, continuity_anchor_mode: "off", anchor_refresh_interval: 3, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + high_quality: { modular_decode: "high", steps: 24, image_compression: 28, continuity_anchor_mode: "off", anchor_refresh_interval: 1, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + fast_preview: { modular_decode: "low_ram", steps: 12, image_compression: 45, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + }, + visibility: { + low_ram_safe: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + balanced: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + high_quality: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + fast_preview: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: false }, + }, + }, + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { decode_mode: "low_ram", jpg_quality: 92, crf: 21, tiled_tile_size: 512, tiled_overlap: 64 }, + balanced: { decode_mode: "normal", jpg_quality: 95, crf: 19, tiled_tile_size: 512, tiled_overlap: 64 }, + high_quality: { decode_mode: "high", jpg_quality: 100, crf: 16, tiled_tile_size: 768, tiled_overlap: 96 }, + fast_preview: { decode_mode: "low_ram", jpg_quality: 88, crf: 24, tiled_tile_size: 384, tiled_overlap: 48 }, + }, + visibility: { + low_ram_safe: { frames_subdir: true, image_format: true, jpg_quality: true }, + balanced: { frames_subdir: true, image_format: true, jpg_quality: true }, + high_quality: { frames_subdir: true, image_format: true, jpg_quality: true }, + fast_preview: { frames_subdir: true, image_format: true, jpg_quality: true }, + }, + }, +}; + +const NODE_GROUPS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Planner": [ + { key: "timeline", label: "Timeline", color: "#2f8f80", widgets: ["fps", "segment_seconds"] }, + { key: "planning", label: "Planning", color: "#a07b2c", widgets: ["planning_mode", "segment_preset", "overlap_frames", "ltx_round_mode"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": [ + { key: "preset", label: "Preset", color: "#557ea6", widgets: ["ui_preset"] }, + { key: "prompts", label: "Prompting", color: "#9a6b2f", widgets: ["positive_text", "negative_text"] }, + { key: "video", label: "Video", color: "#3f6fb0", widgets: ["width", "height"] }, + { key: "sampling", label: "Sampling", color: "#8352a6", widgets: ["steps", "cfg", "sampler_name", "seed", "max_shift", "base_shift", "sigma_terminal", "manual_sigmas", "image_strength", "image_compression"] }, + { key: "audio", label: "Audio", color: "#3d8a5c", widgets: ["audio_context_mode", "audio_left_context_s", "audio_right_context_s"] }, + { key: "stitch", label: "Stitch", color: "#a4673d", widgets: ["stitch_preset", "overlap_side", "overlap_mode", "start_frames_rule"] }, + { key: "anchor", label: "Anchor", color: "#b35c5c", widgets: ["continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength"] }, + { key: "modular", label: "Modular", color: "#46769a", widgets: ["modular_decode", "downstream_stage_mode", "output_root"] }, + { key: "debug", label: "Debug", color: "#8a8a36", widgets: ["segment_overlay_mode", "segment_overlay_text"] }, + ], + "IAMCCS-SuperNodes Second Stage": [ + { key: "stage2", label: "Second Stage", color: "#8a5ca0", widgets: ["second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas"] }, + ], + IAMCCS_GC_AudioConcatSupernode: [ + { key: "concat", label: "Audio Concat", color: "#4e8f6b", widgets: ["concat_mode", "clip_durations_seconds", "gap_seconds", "intro_seconds", "outro_seconds"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": [ + { key: "preset", label: "Preset", color: "#557ea6", widgets: ["ui_preset"] }, + { key: "decode", label: "Decode", color: "#4b79a6", widgets: ["frame_rate", "decode_mode", "output_root", "frames_subdir", "image_format", "jpg_quality", "tiled_tile_size", "tiled_overlap"] }, + { key: "final", label: "Finalize", color: "#9b7441", widgets: ["filename_prefix", "crf", "pix_fmt", "trim_to_audio", "save_metadata"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Finalize": [ + { key: "output", label: "Output", color: "#6a85a0", widgets: ["frame_rate", "filename_prefix", "crf", "pix_fmt", "trim_to_audio"] }, + ], +}; + +const SECTION_BUTTON_COLLAPSED_BG = "#22303f"; +const SECTION_BUTTON_TEXT = "#f8fbff"; +const SECTION_BUTTON_HEIGHT = 30; + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget?.name === name); +} + +function setWidgetVisibility(widget, visible) { + if (!widget || widget.type === "converted-widget") { + return; + } + + widget.hidden = !visible; + widget.disabled = !visible; + + if (widget.element) { + widget.element.style.display = visible ? "" : "none"; + } + if (widget.inputEl) { + widget.inputEl.style.display = visible ? "" : "none"; + } + + if (visible) { + if (Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.computeSize = widget.__iamccsOrigComputeSize; + } else { + delete widget.computeSize; + } + } else { + if (!Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.__iamccsOrigComputeSize = widget.computeSize; + } + widget.computeSize = () => [0, -4]; + widget.y = undefined; + widget.last_y = undefined; + } +} + +function fitNodeToWidgets(node) { + const size = node.computeSize?.() || node.size; + node.setSize([ + Math.max(node.size?.[0] || 0, size[0]), + Math.max(size[1], size[1] + 8), + ]); +} + +function setWidgetLabel(node, widgetName, label) { + const widget = findWidget(node, widgetName); + if (!widget) { + return; + } + widget.label = label; +} + +function sectionButtonCaption(group, isExpanded) { + return `${isExpanded ? "β–Ό" : "β–Ά"} ${group.label}`; +} + +function insertWidget(node, widget, index) { + const widgets = node.widgets || []; + const currentIndex = widgets.indexOf(widget); + if (currentIndex >= 0) { + widgets.splice(currentIndex, 1); + } + widgets.splice(index, 0, widget); +} + +function setWidgetValue(node, widgetName, value) { + const widget = findWidget(node, widgetName); + if (!widget || widget.value === value) { + return; + } + widget.value = value; + widget.callback?.(value); +} + +function getLinxTargets(node) { + const results = []; + for (const output of node.outputs || []) { + if (output?.type !== "IAMCCS_SUPERNODE_LINX") { + continue; + } + for (const linkId of output.links || []) { + const link = app.graph.links?.[linkId]; + if (!link) { + continue; + } + const targetNode = app.graph.getNodeById?.(link.target_id); + if (targetNode) { + results.push(targetNode); + } + } + } + return results; +} + +function applyVaeDecodeModeVisibility(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + return; + } + const decodeMode = String(findWidget(node, "decode_mode")?.value || "low_ram"); + const isLowRam = decodeMode === "low_ram"; + const isNormal = decodeMode === "normal"; + setWidgetVisibility(findWidget(node, "frames_subdir"), isLowRam); + setWidgetVisibility(findWidget(node, "image_format"), isLowRam); + setWidgetVisibility(findWidget(node, "jpg_quality"), isLowRam); + setWidgetVisibility(findWidget(node, "tiled_tile_size"), isNormal); + setWidgetVisibility(findWidget(node, "tiled_overlap"), isNormal); + fitNodeToWidgets(node); +} + +function applyRenderAnchorLabels(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + return; + } + setWidgetLabel(node, "continuity_anchor_mode", "Anchor Refresh (second stage only)"); + setWidgetLabel(node, "anchor_refresh_interval", "Refresh Interval (second stage only)"); + setWidgetLabel(node, "anchor_image_strength", "Anchor Guidance Strength (second stage only)"); +} + +function syncDownstreamVaeDecodeModes(renderNode) { + const renderDecode = String(findWidget(renderNode, "modular_decode")?.value || "low_ram"); + const visited = new Set(); + const queue = [...getLinxTargets(renderNode)]; + while (queue.length > 0) { + const node = queue.shift(); + if (!node || visited.has(node.id)) { + continue; + } + visited.add(node.id); + if (node.comfyClass === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + setWidgetValue(node, "decode_mode", renderDecode); + setWidgetValue(node, "ui_preset", "custom"); + applyVaeDecodeModeVisibility(node); + app.graph.setDirtyCanvas(true, true); + continue; + } + if (node.comfyClass === "IAMCCS-SuperNodes Second Stage") { + queue.push(...getLinxTargets(node)); + } + } +} + +function applyPresetConfig(node, nodeName) { + const config = PRESET_CONFIGS[nodeName]; + if (!config) { + return; + } + const presetWidget = findWidget(node, config.presetWidget); + if (!presetWidget) { + return; + } + const presetName = String(presetWidget.value || config.defaultPreset || "custom"); + if (presetName !== "custom") { + const values = config.values?.[presetName] || {}; + Object.entries(values).forEach(([widgetName, widgetValue]) => setWidgetValue(node, widgetName, widgetValue)); + } + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + applyRenderAnchorLabels(node); + } + fitNodeToWidgets(node); +} + +function getSerializableWidgets(node) { + return (node.widgets || []).filter((widget) => widget && widget.serialize !== false); +} + +function rehydrateSerializedWidgets(node, serializedValues) { + if (!Array.isArray(serializedValues) || !node.widgets?.length) { + return; + } + + const widgets = getSerializableWidgets(node); + const count = Math.min(widgets.length, serializedValues.length); + for (let index = 0; index < count; index += 1) { + const widget = widgets[index]; + if (!widget) { + continue; + } + widget.value = serializedValues[index]; + } +} + +function addSectionButton(node, group, index) { + // Duplicate guard: if a button for this section key is already in the widget list, + // return it immediately. This prevents double-insertion if onNodeCreated or + // enhanceNodeLayout is called more than once on the same node instance. + const existing = node.widgets?.find((w) => w._iamccsSectionKey === group.key); + if (existing) { + return existing; + } + + node.properties = node.properties || {}; + const propKey = `iamccs_section_${group.key}`; + if (node.properties[propKey] === undefined) { + node.properties[propKey] = true; + } + + const sectionButton = node.addWidget("button", "", "", () => { + node.properties[propKey] = !node.properties[propKey]; + applyGroupVisibility(node, group, propKey, sectionButton); + }); + // IMPORTANT: do NOT set serialize = false. + // Section buttons must occupy a slot in widgets_values so that ComfyUI's + // index-based configure() assignment keeps real widget values aligned. + // Their value (empty string / null) is harmless. + sectionButton._iamccsSectionKey = group.key; + sectionButton.computeSize = (width) => [width || 280, SECTION_BUTTON_HEIGHT]; + sectionButton.label = group.label; + sectionButton.options = { bgcolor: group.color || "#4f6f8f", color: SECTION_BUTTON_TEXT }; + sectionButton.value = ""; + + applyGroupVisibility(node, group, propKey, sectionButton); + insertWidget(node, sectionButton, index); + return sectionButton; +} + +function applyGroupVisibility(node, group, propKey, button) { + const isExpanded = !!node.properties[propKey]; + const caption = sectionButtonCaption(group, isExpanded); + button.name = caption; + button.label = caption; + button.value = caption; + button.options = { + bgcolor: isExpanded ? (group.color || "#4f6f8f") : SECTION_BUTTON_COLLAPSED_BG, + color: SECTION_BUTTON_TEXT, + }; + for (const widgetName of group.widgets) { + setWidgetVisibility(findWidget(node, widgetName), isExpanded); + } + fitNodeToWidgets(node); + app.graph.setDirtyCanvas(true, true); +} + +function applyPlannerModeVisibility(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + return; + } + const planningMode = String(findWidget(node, "planning_mode")?.value || "manual_segment_seconds"); + const planningExpanded = !!node.properties?.iamccs_section_planning; + const showSegmentPreset = planningExpanded && planningMode === "explicit_preset_seconds"; + setWidgetVisibility(findWidget(node, "segment_preset"), showSegmentPreset); + fitNodeToWidgets(node); +} + +function refreshNodeLayoutState(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + for (const group of groups) { + const button = node.widgets?.find((widget) => widget?._iamccsSectionKey === group.key); + if (!button) { + continue; + } + applyGroupVisibility(node, group, `iamccs_section_${group.key}`, button); + } + + const config = PRESET_CONFIGS[nodeName]; + if (config) { + const presetWidget = findWidget(node, config.presetWidget); + const presetName = String(presetWidget?.value || config.defaultPreset || "custom"); + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } else if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + applyPlannerModeVisibility(node); + } else { + fitNodeToWidgets(node); + } +} + +function addPlannerChip(node) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === "planner_chip_preview")) { + return; + } + const chipWidget = { + type: "custom", + name: "planner_chip_preview", + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 30]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.iamccsPlannerChip || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 22; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = "#18312b"; + ctx.strokeStyle = "#6ea88d"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 9); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#d8efe2"; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(chipWidget); +} + +function addStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 42]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 34; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function addMultiLineStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 78]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const lines = String(text).split("\n").filter(Boolean).slice(0, 4); + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 70; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "top"; + lines.forEach((line, index) => { + ctx.fillText(line, x + 10, top + 8 + index * 15); + }); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function enhanceNodeLayout(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + if (!groups.length || !node.widgets?.length) { + return; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + addPlannerChip(node); + addMultiLineStatusBox(node, "iamccsPlannerDetails", "planner_details_preview", "#24362d", "#72ab8e", "#e4f2e8"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + addStatusBox(node, "iamccsRenderStatus", "render_status_preview", "#1e2f3f", "#6f93ba", "#e4edf7"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + + for (let index = groups.length - 1; index >= 0; index -= 1) { + const group = groups[index]; + const firstWidgetIndex = node.widgets.findIndex((widget) => group.widgets.includes(widget?.name)); + if (firstWidgetIndex >= 0) { + addSectionButton(node, group, firstWidgetIndex); + } + } + + applyPresetConfig(node, nodeName); + fitNodeToWidgets(node); +} + +app.registerExtension({ + name: "IAMCCS.SuperNodesExecUI", + + async beforeRegisterNodeDef(nodeType, nodeData) { + const nodeName = nodeData?.name; + if (!NODE_GROUPS[nodeName]) { + return; + } + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + this.properties = this.properties || {}; + // MUST run synchronously, BEFORE configure() assigns widgets_values by index. + // Section buttons inserted here occupy their index slots so that the null + // placeholders in widgets_values land on buttons, not on real widgets. + // (Using setTimeout here was the root cause of NaN on load / after undo.) + enhanceNodeLayout(this, nodeName); + const config = PRESET_CONFIGS[nodeName]; + const presetWidget = config ? findWidget(this, config.presetWidget) : null; + if (presetWidget) { + const originalCallback = presetWidget.callback; + presetWidget.callback = (...args) => { + originalCallback?.apply(presetWidget, args); + applyPresetConfig(this, nodeName); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + syncDownstreamVaeDecodeModes(this); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(this); + } + app.graph.setDirtyCanvas(true, true); + }; + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const modularDecodeWidget = findWidget(this, "modular_decode"); + if (modularDecodeWidget) { + const originalCallback = modularDecodeWidget.callback; + modularDecodeWidget.callback = (...args) => { + originalCallback?.apply(modularDecodeWidget, args); + syncDownstreamVaeDecodeModes(this); + app.graph.setDirtyCanvas(true, true); + }; + syncDownstreamVaeDecodeModes(this); + } + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + const planningModeWidget = findWidget(this, "planning_mode"); + if (planningModeWidget) { + const originalCallback = planningModeWidget.callback; + planningModeWidget.callback = (...args) => { + originalCallback?.apply(planningModeWidget, args); + applyPlannerModeVisibility(this); + app.graph.setDirtyCanvas(true, true); + }; + } + applyPlannerModeVisibility(this); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + const decodeModeWidget = findWidget(this, "decode_mode"); + if (decodeModeWidget) { + const originalCallback = decodeModeWidget.callback; + decodeModeWidget.callback = (...args) => { + originalCallback?.apply(decodeModeWidget, args); + applyVaeDecodeModeVisibility(this); + app.graph.setDirtyCanvas(true, true); + }; + } + } + return result; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + const result = onConfigure?.apply(this, arguments); + this.properties = this.properties || {}; + enhanceNodeLayout(this, nodeName); + rehydrateSerializedWidgets(this, info?.widgets_values); + refreshNodeLayoutState(this, nodeName); + return result; + }; + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + this.properties = this.properties || {}; + if (Array.isArray(message?.planner_chip) && message.planner_chip.length > 0) { + this.properties.iamccsPlannerChip = String(message.planner_chip[0] || ""); + } + const details = []; + const duration = Array.isArray(message?.planned_duration_seconds) && message.planned_duration_seconds.length > 0 + ? message.planned_duration_seconds[0] + : Array.isArray(message?.duration_seconds) && message.duration_seconds.length > 0 + ? message.duration_seconds[0] + : null; + const totalFrames = Array.isArray(message?.planned_total_frames) && message.planned_total_frames.length > 0 + ? message.planned_total_frames[0] + : Array.isArray(message?.total_frames) && message.total_frames.length > 0 + ? message.total_frames[0] + : null; + const segments = Array.isArray(message?.planned_segment_count) && message.planned_segment_count.length > 0 + ? message.planned_segment_count[0] + : Array.isArray(message?.segment_count) && message.segment_count.length > 0 + ? message.segment_count[0] + : null; + if (duration !== null) { + details.push(`duration ${Number(duration || 0).toFixed(2)}s`); + } + if (totalFrames !== null) { + details.push(`total ${totalFrames}f`); + } + if (segments !== null) { + details.push(`segments ${segments}`); + } + if (Array.isArray(message?.recommended_overlap_frames) && message.recommended_overlap_frames.length > 0) { + details.push(`overlap ${message.recommended_overlap_frames[0]}f`); + } + if (Array.isArray(message?.recommended_audio_left_context_s) && message.recommended_audio_left_context_s.length > 0) { + details.push(`left ctx ${Number(message.recommended_audio_left_context_s[0] || 0).toFixed(2)}s`); + } + if (Array.isArray(message?.planning_report) && message.planning_report.length > 0) { + details.push(String(message.planning_report[0] || "")); + } + if (details.length > 0) { + this.properties.iamccsPlannerDetails = details.join("\n"); + app.graph.setDirtyCanvas(true, true); + } + }; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + if (Array.isArray(message?.report) && message.report.length > 0) { + this.properties = this.properties || {}; + this.properties.iamccsRenderStatus = String(message.report[0] || "").split("\n")[0]; + app.graph.setDirtyCanvas(true, true); + } + }; + } + }, +}); diff --git a/backup/web_disabled/iamccs_wan_lora_schedule_ui.20260420_bank_widget_order_fix.bak.js b/backup/web_disabled/iamccs_wan_lora_schedule_ui.20260420_bank_widget_order_fix.bak.js new file mode 100644 index 0000000..8367363 --- /dev/null +++ b/backup/web_disabled/iamccs_wan_lora_schedule_ui.20260420_bank_widget_order_fix.bak.js @@ -0,0 +1,1607 @@ +import { app } from "../../scripts/app.js"; + +const NODE_NAME = "IAMCCS_WanLoRASchedule"; +const NODE_NAMES = new Set([ + "IAMCCS_WanLoRASchedule", + "IAMCCS_WanLoRAHookSchedule", + "IAMCCS_ApplyScheduledWanLoRAFromConditioning", + "IAMCCS_BuildScheduledWanModelBank", +]); +const MODE_WIDGET = "_iamccs_schedule_mode"; +const LOAD_WIDGET = "_iamccs_schedule_load_preset"; +const SAVE_WIDGET = "_iamccs_schedule_save_preset"; +const ADD_WIDGET = "_iamccs_schedule_add_slot"; +const DELETE_WIDGET = "_iamccs_schedule_delete_slot"; +const RESET_WIDGET = "_iamccs_schedule_reset_rules"; +const MODE_PROP = "iamccs_schedule_ui_mode"; +const SLOTS_PROP = "iamccs_schedule_visible_slots"; +const AUTO_PROP = "iamccs_schedule_auto_synced"; +const INIT_PROP = "iamccs_schedule_inited_slots"; +const DROP_HOVER_TIMEOUT_MS = 250; +const PRESET_KIND = "iamccs_wan_lora_schedule_preset"; +const PRESET_VERSION = 1; +const MAX_SLOTS = 64; +const LEGACY_MAX_SLOTS = 6; +const UI_WIDGET_COUNT = 4; +const EXPECTED_SERIALIZED_VALUES = 3 + (MAX_SLOTS * 5); +const LEGACY_EXPECTED_SERIALIZED_VALUES = 3 + (LEGACY_MAX_SLOTS * 5); +const SIMPLE_WIDTH = 480; +const ADVANCED_WIDTH = 620; +const BASE_HEIGHT = 230; +const SIMPLE_SLOT_HEIGHT = 54; +const ADVANCED_SLOT_HEIGHT = 132; +const EXTRA_HEIGHT = 30; +const SLOT_BLOCK = 5; +const SLOTS_START_IDX = 3; +const VISIBLE_PRESET_OPTIONS = ["custom range", "all generations", "gen 0 only", "gen 1 onwards"]; + +const SLOT_META = [ + { slot: 1, label: "Gen 0", preset: "gen 0 only", start: 0, end: 0 }, + { slot: 2, label: "Gen 1", preset: "custom range", start: 1, end: 1 }, + { slot: 3, label: "Gen 2+", preset: "custom range", start: 2, end: -1 }, +]; + +function getGraph() { + return app?.canvas?.getCurrentGraph?.() || app?.graph || null; +} + +function getGraphLink(graph, linkId) { + if (!graph || linkId == null) return null; + try { + if (typeof graph.links?.get === "function") return graph.links.get(linkId) || graph.links.get(String(linkId)) || null; + return graph.links?.[linkId] || graph.links?.[String(linkId)] || null; + } catch { + return null; + } +} + +function getGraphNode(graph, nodeId) { + if (!graph || nodeId == null) return null; + try { + return graph.getNodeById?.(nodeId) || null; + } catch { + return null; + } +} + +function getWidget(node, name) { + return node?.widgets?.find((widget) => widget?.name === name || widget?.label === name) || null; +} + +function setButtonText(widget, text) { + if (!widget) return; + widget.label = text; + widget.options = { ...(widget.options || {}), label: text }; + widget._iamccsButtonText = text; +} + +function getButtonText(widget, fallback = "") { + return String(widget?._iamccsButtonText || widget?.label || widget?.options?.label || fallback); +} + +function waitForNextPaint() { + return new Promise((resolve) => { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => setTimeout(resolve, 0)); + return; + } + setTimeout(resolve, 0); + }); +} + +async function runButtonAction(node, widget, busyText, action) { + if (!widget || widget._iamccsBusy) return; + const originalText = getButtonText(widget); + widget._iamccsBusy = true; + setButtonText(widget, busyText); + node.setDirtyCanvas?.(true, true); + await waitForNextPaint(); + try { + await action(); + } finally { + widget._iamccsBusy = false; + setButtonText(widget, originalText); + node.setDirtyCanvas?.(true, true); + } +} + +function beginBatchUpdate(node) { + node._iamccsBatchDepth = Number(node._iamccsBatchDepth || 0) + 1; +} + +function endBatchUpdate(node) { + node._iamccsBatchDepth = Math.max(0, Number(node._iamccsBatchDepth || 0) - 1); + if (!node._iamccsBatchDepth && node._iamccsPendingSyncLowNodes) { + node._iamccsPendingSyncLowNodes = false; + requestSyncAllLinkedLowNodes(node); + } +} + +function isBatchUpdating(node) { + return Number(node?._iamccsBatchDepth || 0) > 0; +} + +function isRestoringNodeState(node) { + return Boolean(node?._iamccsRestoringState); +} + +function requestSyncAllLinkedLowNodes(node) { + if (!node) return; + if (isBatchUpdating(node)) { + node._iamccsPendingSyncLowNodes = true; + return; + } + if (isRestoringNodeState(node)) { + node._iamccsPendingSyncLowNodes = true; + return; + } + if (node._iamccsSyncLowNodesTimer) return; + node._iamccsSyncLowNodesTimer = setTimeout(() => { + node._iamccsSyncLowNodesTimer = 0; + void syncAllLinkedLowNodes(node).finally(() => { + node.setDirtyCanvas?.(true, true); + }); + }, 0); +} + +function markUiWidgetNonSerializable(widget) { + if (!widget) return widget; + widget.options = { ...(widget.options || {}), serialize: false }; + widget.serializeValue = () => undefined; + widget._iamccsUiOnly = true; + return widget; +} + +function isModeValue(value) { + return value === "simple" || value === "advanced"; +} + +function isModelTypeValue(value) { + return value === "wan2x" || value === "flow" || value === "standard"; +} + +// Correct serialized count: generation_index(1) + log_prefix(1) + model_type(1) + 6 slots Γ— 5 = 33 +// (generation_index stays as a widget even when wired, in ComfyUI's serialization model) +const EXPECTED_SERIALIZED_VALUES_CONNECTED = EXPECTED_SERIALIZED_VALUES; // 33 – gen_idx present as wired widget + +function slotCountFromSerializedLength(length) { + if (typeof length !== "number" || length < 3) return 0; + return Math.max(0, Math.floor((length - 3) / SLOT_BLOCK)); +} + +function sanitizePresetValue(value) { + const raw = String(value || "custom range"); + const aliases = { + manual_range: "custom range", + even_generations: "custom range", + odd_generations: "custom range", + every_2_from_start: "custom range", + every_3_from_start: "custom range", + "even gens (0,2,4...)": "custom range", + "odd gens (1,3,5...)": "custom range", + "every 2nd gen": "custom range", + "every 3rd gen": "custom range", + }; + return aliases[raw] || raw; +} + +function normalizeLegacyWidgetValues(config) { + const values = config?.widgets_values; + if (!Array.isArray(values)) return; + + let modeValue = null; + let normalized = null; + + // Pattern A (37 values): pre-fix format where 4 UI widgets were serialized at front, + // gen_idx at [-2] and log_prefix at [-1]. + // "simple"/null/null/null model_type ...30 slots... gen_idx log_prefix + if ( + values.length === LEGACY_EXPECTED_SERIALIZED_VALUES + UI_WIDGET_COUNT + && isModeValue(values[0]) + && isModelTypeValue(values[UI_WIDGET_COUNT]) + ) { + modeValue = values[0]; + const generationIndex = values[values.length - 2] ?? 0; + const logPrefix = values[values.length - 1] ?? "WAN LoRA schedule"; + const modelType = values[UI_WIDGET_COUNT] ?? "flow"; + const slotValues = values.slice(UI_WIDGET_COUNT + 1, -2); + // Correct order: gen_idx log_prefix model_type ...30 slots... + normalized = [generationIndex, logPrefix, modelType, ...slotValues]; + + // Pattern B (33 values): intermediate format where model_type was widget[0], + // gen_idx at [-2] and log_prefix at [-1]. + // model_type ...30 slots... gen_idx log_prefix + } else if ( + values.length === LEGACY_EXPECTED_SERIALIZED_VALUES + && isModelTypeValue(values[0]) + && typeof values[values.length - 1] === "string" + ) { + const generationIndex = values[values.length - 2] ?? 0; + const logPrefix = values[values.length - 1] ?? "WAN LoRA schedule"; + const modelType = values[0] ?? "flow"; + const slotValues = values.slice(1, -2); + normalized = [generationIndex, logPrefix, modelType, ...slotValues]; + } + + // Determine what to work on: use normalized if we reordered, else work on original (in-place) + // for preset-migration-only case (already-correct 33-value arrays with legacy preset names). + const inferredSlots = slotCountFromSerializedLength(values.length); + const target = normalized ?? (inferredSlots > 0 ? values : null); + if (!target) return; + + // Migrate legacy internal preset names β†’ current _PRESET_OPTIONS names. + // "manual_range" was an internal alias for "custom range" in early builds. + // ComfyUI server REJECTS it as an invalid combo value because it is not in _PRESET_OPTIONS. + let presetPatched = false; + for (let s = 0; s < slotCountFromSerializedLength(target.length); s++) { + const presetIdx = SLOTS_START_IDX + s * SLOT_BLOCK + 2; + const raw = target[presetIdx]; + const normalizedPreset = sanitizePresetValue(raw); + if (raw != null && normalizedPreset !== raw) { + target[presetIdx] = normalizedPreset; + presetPatched = true; + } + } + + if (!normalized && !presetPatched) return; // nothing changed + + if (normalized) { + config.widgets_values = normalized; + } else if (presetPatched) { + // values was modified in-place above (same array reference) + config.widgets_values = values; + } + config.properties = config.properties || {}; + if (isModeValue(modeValue) && !config.properties[MODE_PROP]) { + config.properties[MODE_PROP] = modeValue; + } +} + +function hideWidget(widget) { + if (!widget) return; + // Use `in` check so we save the REAL original only once, never overwrite with [0,0] + if (!("_iamccsOriginalComputeSize" in widget)) { + widget._iamccsOriginalComputeSize = widget.computeSize ?? null; + } + widget.hidden = true; + widget.disabled = true; + widget.computeSize = () => [0, 0]; +} + +function showWidget(widget) { + if (!widget) return; + widget.hidden = false; + widget.disabled = false; + if (widget._iamccsOriginalComputeSize) { + widget.computeSize = widget._iamccsOriginalComputeSize; + } else { + delete widget.computeSize; + } +} + +function setWidgetValue(node, name, value, options = {}) { + const { invokeCallback = true } = options; + const widget = getWidget(node, name); + if (!widget) return false; + widget.value = value; + if (invokeCallback && !isBatchUpdating(node)) { + try { + widget.callback?.(value, app.canvas, node); + } catch {} + } + return true; +} + +function ensureBoxShape(node) { + try { + if (typeof LiteGraph !== "undefined" && LiteGraph?.BOX_SHAPE != null) { + node.shape = LiteGraph.BOX_SHAPE; + } else { + node.shape = 0; + } + } catch {} +} + +function getVisibleSlots(node) { + node.properties = node.properties || {}; + const raw = Number(node.properties[SLOTS_PROP] ?? 3); + return Math.max(3, Math.min(MAX_SLOTS, Math.trunc(raw) || 3)); +} + +function setVisibleSlots(node, value) { + node.properties = node.properties || {}; + node.properties[SLOTS_PROP] = Math.max(3, Math.min(MAX_SLOTS, Math.trunc(Number(value) || 3))); +} + +function slotMeta(slot) { + const base = SLOT_META.find((item) => item.slot === slot); + if (base) return base; + return { slot, label: `Gen ${slot - 1}`, preset: "custom range", start: slot - 1, end: slot - 1 }; +} + +function ensureSlotDefaults(node, slot) { + node.properties = node.properties || {}; + node.properties[INIT_PROP] = node.properties[INIT_PROP] || {}; + if (node.properties[INIT_PROP][slot]) return; // already set; don't clobber user values + const meta = slotMeta(slot); + const prefix = `slot_${String(slot).padStart(2, "0")}`; + setWidgetValue(node, `${prefix}_preset`, meta.preset); + setWidgetValue(node, `${prefix}_start`, meta.start); + setWidgetValue(node, `${prefix}_end`, meta.end); + const strengthWidget = getWidget(node, `${prefix}_strength`); + if (strengthWidget && (strengthWidget.value == null || Number(strengthWidget.value) === 0)) { + strengthWidget.value = 1; + } + node.properties[INIT_PROP][slot] = true; +} + +function ensureVisibleDefaults(node) { + for (let slot = 1; slot <= getVisibleSlots(node); slot += 1) { + ensureSlotDefaults(node, slot); + } +} + +function resetScheduleRules(node) { + node.properties = node.properties || {}; + node.properties[INIT_PROP] = {}; + node.properties[AUTO_PROP] = {}; + setVisibleSlots(node, 3); + beginBatchUpdate(node); + for (let slot = 1; slot <= MAX_SLOTS; slot += 1) { + const meta = slotMeta(slot); + const prefix = `slot_${String(slot).padStart(2, "0")}`; + setWidgetValue(node, `${prefix}_lora_name`, "no", { invokeCallback: false }); + setWidgetValue(node, `${prefix}_strength`, 1, { invokeCallback: false }); + setWidgetValue(node, `${prefix}_preset`, meta.preset, { invokeCallback: false }); + setWidgetValue(node, `${prefix}_start`, meta.start, { invokeCallback: false }); + setWidgetValue(node, `${prefix}_end`, meta.end, { invokeCallback: false }); + node.properties[INIT_PROP][slot] = true; + } + endBatchUpdate(node); + clearDropHoverSlot(node); +} + +function notifyScheduleUi(message, isError = false, node = null, options = {}) { + const prefix = "[IAMCCS_WanLoRASchedule UI]"; + if (isError) { + console.error(prefix, message); + } else { + console.info(prefix, message); + } + if (node) { + showNodeToast(node, message, { isError, ...options }); + } +} + +function showNodeToast(node, message, options = {}) { + if (!node || !message) return; + const { isError = false, anchorWidgetName = null, durationMs = 1800 } = options; + node._iamccsToast = { + message: String(message), + isError: Boolean(isError), + anchorWidgetName, + until: Date.now() + Math.max(800, Number(durationMs) || 1800), + }; + node.setDirtyCanvas?.(true, true); +} + +function drawNodeToast(node, ctx) { + const toast = node?._iamccsToast; + if (!toast?.message) return; + if ((Number(toast.until) || 0) <= Date.now()) { + delete node._iamccsToast; + node.setDirtyCanvas?.(true, true); + return; + } + + const message = toast.message.length > 72 ? `${toast.message.slice(0, 69)}...` : toast.message; + const anchorWidget = toast.anchorWidgetName ? getWidget(node, toast.anchorWidgetName) : null; + const fallbackWidth = Number(node?.size?.[0]) || SIMPLE_WIDTH; + const toastHeight = 24; + const paddingX = 10; + const textWidth = Math.ceil(ctx.measureText(message).width); + const toastWidth = Math.min(Math.max(textWidth + (paddingX * 2), 110), Math.max(120, fallbackWidth - 12)); + + let anchorY = 18; + if (anchorWidget) { + anchorY = getWidgetBodyY(node, anchorWidget) + (getWidgetHeight(anchorWidget) / 2); + } + + const x = Math.max(6, fallbackWidth - toastWidth - 8); + const maxY = Math.max(8, (Number(node?.size?.[1]) || 200) - toastHeight - 8); + const y = Math.min(Math.max(8, anchorY - (toastHeight / 2)), maxY); + + ctx.save(); + ctx.fillStyle = toast.isError ? "rgba(120, 24, 24, 0.96)" : "rgba(24, 30, 36, 0.96)"; + ctx.strokeStyle = toast.isError ? "rgba(255, 126, 126, 0.95)" : "rgba(110, 214, 156, 0.95)"; + ctx.lineWidth = 1.25; + ctx.beginPath(); + ctx.roundRect(x, y, toastWidth, toastHeight, 8); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#f5f7fa"; + ctx.font = "12px sans-serif"; + ctx.textBaseline = "middle"; + ctx.fillText(message, x + paddingX, y + (toastHeight / 2) + 0.5); + ctx.restore(); +} + +function isAbortError(error) { + return error?.name === "AbortError" || /aborted/i.test(String(error?.message || "")); +} + +function sanitizeFilename(value) { + return String(value || "") + .trim() + .replace(/[<>:"/\\|?*\x00-\x1F]+/g, "_") + .replace(/\s+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 120); +} + +function getScheduleSlotRecord(node, slot) { + const meta = slotMeta(slot); + const prefix = `slot_${String(slot).padStart(2, "0")}`; + return { + slot, + lora_name: String(getWidget(node, `${prefix}_lora_name`)?.value || "no"), + strength: Number(getWidget(node, `${prefix}_strength`)?.value ?? 1) || 1, + preset: sanitizePresetValue(getWidget(node, `${prefix}_preset`)?.value || meta.preset), + start: Math.trunc(Number(getWidget(node, `${prefix}_start`)?.value ?? meta.start) || meta.start), + end: Math.trunc(Number(getWidget(node, `${prefix}_end`)?.value ?? meta.end) || meta.end), + }; +} + +function buildSchedulePreset(node) { + const mode = isModeValue(node?.properties?.[MODE_PROP]) ? node.properties[MODE_PROP] : "simple"; + const modelType = String(getWidget(node, "model_type")?.value || "flow"); + const logPrefix = String(getWidget(node, "log_prefix")?.value || "WAN LoRA schedule"); + return { + kind: PRESET_KIND, + version: PRESET_VERSION, + node_type: node?.type || NODE_NAME, + exported_at: new Date().toISOString(), + mode, + visible_slots: getVisibleSlots(node), + model_type: isModelTypeValue(modelType) ? modelType : "flow", + log_prefix: logPrefix, + slots: Array.from({ length: MAX_SLOTS }, (_, index) => getScheduleSlotRecord(node, index + 1)), + }; +} + +function normalizeLoadedSlotRecord(rawSlot, fallbackSlot) { + const slotNum = Math.max(1, Math.min(MAX_SLOTS, Math.trunc(Number(rawSlot?.slot ?? fallbackSlot) || fallbackSlot))); + const meta = slotMeta(slotNum); + return { + slot: slotNum, + lora_name: String(rawSlot?.lora_name || rawSlot?.loraName || "no"), + strength: Number(rawSlot?.strength ?? 1) || 1, + preset: sanitizePresetValue(rawSlot?.preset || meta.preset), + start: Math.trunc(Number(rawSlot?.start ?? meta.start) || meta.start), + end: Math.trunc(Number(rawSlot?.end ?? meta.end) || meta.end), + }; +} + +async function applySchedulePreset(node, payload) { + if (!payload || typeof payload !== "object") { + throw new Error("Preset file is empty or invalid."); + } + if (payload.kind && payload.kind !== PRESET_KIND) { + throw new Error(`Unsupported preset kind: ${payload.kind}`); + } + + const slotMap = new Map(); + if (Array.isArray(payload.slots)) { + for (let index = 0; index < payload.slots.length; index += 1) { + const normalized = normalizeLoadedSlotRecord(payload.slots[index], index + 1); + slotMap.set(normalized.slot, normalized); + } + } else if (payload.slots && typeof payload.slots === "object") { + for (const [key, value] of Object.entries(payload.slots)) { + const numericSlot = Math.trunc(Number(String(key).replace(/[^0-9]/g, "")) || 0); + if (!numericSlot) continue; + const normalized = normalizeLoadedSlotRecord(value, numericSlot); + slotMap.set(normalized.slot, normalized); + } + } + + const nextMode = isModeValue(payload.mode) ? payload.mode : "simple"; + const nextVisibleSlots = Math.max(3, Math.min(MAX_SLOTS, Math.trunc(Number(payload.visible_slots ?? payload.visibleSlots) || 3))); + const nextModelType = isModelTypeValue(payload.model_type || payload.modelType) ? String(payload.model_type || payload.modelType) : "flow"; + const nextLogPrefix = String(payload.log_prefix || payload.logPrefix || "WAN LoRA schedule"); + + beginBatchUpdate(node); + resetScheduleRules(node); + node.properties = node.properties || {}; + node.properties[MODE_PROP] = nextMode; + setVisibleSlots(node, nextVisibleSlots); + + const modeWidget = getWidget(node, MODE_WIDGET); + if (modeWidget) modeWidget.value = nextMode; + const modelTypeWidget = getWidget(node, "model_type"); + if (modelTypeWidget) modelTypeWidget.value = nextModelType; + const logPrefixWidget = getWidget(node, "log_prefix"); + if (logPrefixWidget) logPrefixWidget.value = nextLogPrefix; + + node.properties[INIT_PROP] = node.properties[INIT_PROP] || {}; + for (let slot = 1; slot <= MAX_SLOTS; slot += 1) { + const record = slotMap.get(slot) || normalizeLoadedSlotRecord({}, slot); + const prefix = `slot_${String(slot).padStart(2, "0")}`; + const loraWidget = getWidget(node, `${prefix}_lora_name`); + const strengthWidget = getWidget(node, `${prefix}_strength`); + const presetWidget = getWidget(node, `${prefix}_preset`); + const startWidget = getWidget(node, `${prefix}_start`); + const endWidget = getWidget(node, `${prefix}_end`); + if (loraWidget) loraWidget.value = record.lora_name; + if (strengthWidget) strengthWidget.value = record.strength; + if (presetWidget) presetWidget.value = record.preset; + if (startWidget) startWidget.value = record.start; + if (endWidget) endWidget.value = record.end; + node.properties[INIT_PROP][slot] = true; + } + endBatchUpdate(node); + + clearDropHoverSlot(node); + applyLayout(node); + requestSyncAllLinkedLowNodes(node); + node.setDirtyCanvas?.(true, true); +} + +function getSuggestedPresetFilename(node) { + const logPrefix = String(getWidget(node, "log_prefix")?.value || node?.title || "wan-lora-schedule"); + const filename = sanitizeFilename(logPrefix) || "wan-lora-schedule"; + return `${filename}.json`; +} + +function downloadTextFile(filename, text) { + const blob = new Blob([text], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +async function saveSchedulePresetAs(node) { + const preset = buildSchedulePreset(node); + const text = JSON.stringify(preset, null, 2); + const suggestedName = getSuggestedPresetFilename(node); + + if (typeof window !== "undefined" && typeof window.showSaveFilePicker === "function") { + const handle = await window.showSaveFilePicker({ + suggestedName, + types: [{ + description: "IAMCCS Wan LoRA Schedule preset", + accept: { "application/json": [".json"] }, + }], + }); + const writable = await handle.createWritable(); + await writable.write(text); + await writable.close(); + } else { + downloadTextFile(suggestedName, text); + } + + notifyScheduleUi(`Preset saved: ${suggestedName}`, false, node, { anchorWidgetName: SAVE_WIDGET }); +} + +async function pickPresetTextFile() { + if (typeof window !== "undefined" && typeof window.showOpenFilePicker === "function") { + const [handle] = await window.showOpenFilePicker({ + multiple: false, + types: [{ + description: "IAMCCS Wan LoRA Schedule preset", + accept: { "application/json": [".json"] }, + }], + }); + const file = await handle.getFile(); + return await file.text(); + } + + return await new Promise((resolve, reject) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json,application/json"; + input.style.display = "none"; + input.addEventListener("change", async () => { + try { + const file = input.files?.[0]; + if (!file) { + reject(new DOMException("User aborted file selection.", "AbortError")); + return; + } + resolve(await file.text()); + } catch (error) { + reject(error); + } finally { + input.remove(); + } + }, { once: true }); + document.body.appendChild(input); + input.click(); + }); +} + +async function loadSchedulePreset(node) { + const text = await pickPresetTextFile(); + const payload = JSON.parse(text); + await applySchedulePreset(node, payload); + notifyScheduleUi("Preset loaded.", false, node, { anchorWidgetName: LOAD_WIDGET }); +} + +function applyLabels(node) { + const modeWidget = getWidget(node, MODE_WIDGET); + if (modeWidget) modeWidget.label = "Mode"; + const modelTypeWidget = getWidget(node, "model_type"); + if (modelTypeWidget) modelTypeWidget.label = "Model type"; + + for (const input of node.inputs || []) { + if (input?.name === "default_lora") input.label = "Always-on LoRA"; + if (input?.name === "linx") input.label = "Linx in"; + if (input?.name === "generation_index") input.label = "Generation"; + } + for (const output of node.outputs || []) { + if (output?.name === "linx") output.label = "Linx out"; + } + + for (let slot = 1; slot <= MAX_SLOTS; slot += 1) { + const meta = slotMeta(slot); + const prefix = `slot_${String(slot).padStart(2, "0")}`; + const loraWidget = getWidget(node, `${prefix}_lora_name`); + const strengthWidget = getWidget(node, `${prefix}_strength`); + const presetWidget = getWidget(node, `${prefix}_preset`); + const startWidget = getWidget(node, `${prefix}_start`); + const endWidget = getWidget(node, `${prefix}_end`); + if (loraWidget) loraWidget.label = `${meta.label} LoRA`; + if (strengthWidget) strengthWidget.label = `${meta.label} Strength`; + if (presetWidget) presetWidget.label = `${meta.label} Apply when`; + if (startWidget) startWidget.label = `${meta.label} Start gen`; + if (endWidget) endWidget.label = `${meta.label} End gen (-1 = forever)`; + } +} + +function applyPresetOptions(node) { + for (let slot = 1; slot <= MAX_SLOTS; slot += 1) { + const presetWidget = getWidget(node, `slot_${String(slot).padStart(2, "0")}_preset`); + if (!presetWidget) continue; + presetWidget.options = { ...(presetWidget.options || {}), values: VISIBLE_PRESET_OPTIONS }; + const current = sanitizePresetValue(presetWidget.value); + if (!VISIBLE_PRESET_OPTIONS.includes(current)) { + presetWidget.value = "custom range"; + } else if (current !== presetWidget.value) { + presetWidget.value = current; + } + } +} + +function ensureModeWidget(node) { + if (getWidget(node, MODE_WIDGET)) return; + node.properties = node.properties || {}; + if (!node.properties[MODE_PROP]) { + node.properties[MODE_PROP] = "simple"; + } + const widget = node.addWidget( + "combo", + MODE_WIDGET, + node.properties[MODE_PROP], + (value) => { + node.properties[MODE_PROP] = String(value || "simple"); + applyLayout(node); + requestSyncAllLinkedLowNodes(node); + node.setDirtyCanvas(true, true); + }, + { values: ["simple", "advanced"], serialize: false } + ); + widget.label = "Mode"; + markUiWidgetNonSerializable(widget); +} + +function ensureActionWidgets(node) { + if (!getWidget(node, LOAD_WIDGET)) { + const widget = node.addWidget("button", "Load Preset", null, () => { + void runButtonAction(node, widget, "Loading...", async () => { + await loadSchedulePreset(node); + }).catch((error) => { + if (isAbortError(error)) return; + notifyScheduleUi(`Load preset failed: ${error?.message || error}`, true, node, { anchorWidgetName: LOAD_WIDGET, durationMs: 2600 }); + }); + }); + widget.name = LOAD_WIDGET; + setButtonText(widget, "Load Preset"); + markUiWidgetNonSerializable(widget); + } + if (!getWidget(node, SAVE_WIDGET)) { + const widget = node.addWidget("button", "Save As...", null, () => { + void runButtonAction(node, widget, "Saving...", async () => { + await saveSchedulePresetAs(node); + }).catch((error) => { + if (isAbortError(error)) return; + notifyScheduleUi(`Save preset failed: ${error?.message || error}`, true, node, { anchorWidgetName: SAVE_WIDGET, durationMs: 2600 }); + }); + }); + widget.name = SAVE_WIDGET; + setButtonText(widget, "Save As..."); + markUiWidgetNonSerializable(widget); + } + if (!getWidget(node, ADD_WIDGET)) { + const widget = node.addWidget("button", "+ Add Slot", null, () => { + const newSlot = getVisibleSlots(node) + 1; + node.properties[INIT_PROP] = node.properties[INIT_PROP] || {}; + delete node.properties[INIT_PROP][newSlot]; // allow fresh defaults for new slot + setVisibleSlots(node, newSlot); + ensureVisibleDefaults(node); + applyLayout(node); + requestSyncAllLinkedLowNodes(node); + node.setDirtyCanvas(true, true); + }); + widget.name = ADD_WIDGET; + setButtonText(widget, "+ Add Slot"); + markUiWidgetNonSerializable(widget); + } + if (!getWidget(node, DELETE_WIDGET)) { + const widget = node.addWidget("button", "- Delete Slot", null, () => { + setVisibleSlots(node, getVisibleSlots(node) - 1); + applyLayout(node); + requestSyncAllLinkedLowNodes(node); + node.setDirtyCanvas(true, true); + }); + widget.name = DELETE_WIDGET; + setButtonText(widget, "- Delete Slot"); + markUiWidgetNonSerializable(widget); + } + if (!getWidget(node, RESET_WIDGET)) { + const widget = node.addWidget("button", "Reset Rules", null, () => { + void runButtonAction(node, widget, "Resetting...", async () => { + resetScheduleRules(node); + applyLayout(node); + requestSyncAllLinkedLowNodes(node); + node.setDirtyCanvas(true, true); + notifyScheduleUi("Rules reset.", false, node, { anchorWidgetName: RESET_WIDGET, durationMs: 1400 }); + }); + }); + widget.name = RESET_WIDGET; + setButtonText(widget, "Reset Rules"); + markUiWidgetNonSerializable(widget); + } +} + +function getOutputIndexByName(node, name) { + return (node?.outputs || []).findIndex((output) => output?.name === name); +} + +function getInputIndexByName(node, name) { + return (node?.inputs || []).findIndex((input) => input?.name === name); +} + +function getLinkedLinxTargets(node) { + const graph = getGraph(); + if (!graph) return []; + const outIndex = getOutputIndexByName(node, "linx"); + if (outIndex < 0) return []; + const output = node.outputs?.[outIndex]; + const links = Array.isArray(output?.links) ? output.links : []; + const out = []; + for (const linkId of links) { + const link = getGraphLink(graph, linkId); + const target = getGraphNode(graph, link?.target_id); + if (target) out.push(target); + } + return out; +} + +function candidateLowNames(name) { + const source = String(name || ""); + if (!source || source === "no") return []; + const pairs = [ + ["_HN_", "_LN_"], + ["-HN_", "-LN_"], + ["_HN-", "_LN-"], + ["_HIGH_", "_LOW_"], + ["-HIGH_", "-LOW_"], + ["_HIGH-", "_LOW-"], + ["HN", "LN"], + ["Hn", "Ln"], + ["hn", "ln"], + ["HIGH", "LOW"], + ["High", "Low"], + ["high", "low"], + ]; + const out = []; + const seen = new Set(); + for (const [oldValue, newValue] of pairs) { + if (!source.includes(oldValue)) continue; + const candidate = source.replace(oldValue, newValue); + if (!seen.has(candidate)) { + out.push(candidate); + seen.add(candidate); + } + } + if (!seen.has(source)) { + out.push(source); + } + return out; +} + +function syncLowNodePresentation(highNode, lowNode) { + lowNode.properties = lowNode.properties || {}; + lowNode.properties[MODE_PROP] = highNode?.properties?.[MODE_PROP] || "simple"; + setVisibleSlots(lowNode, getVisibleSlots(highNode)); + lowNode.flags = lowNode.flags || {}; + lowNode.flags.collapsed = !!highNode?.flags?.collapsed; +} + +function normalizePathSeparators(value) { + return String(value || "").replace(/\\/g, "/").replace(/^file:\/\//i, "").trim(); +} + +function basenameOfPath(value) { + const normalized = normalizePathSeparators(value); + const parts = normalized.split("/"); + return String(parts[parts.length - 1] || "").trim(); +} + +function collectDroppedLoraCandidates(event) { + const out = []; + const seen = new Set(); + const push = (value) => { + const normalized = normalizePathSeparators(value); + if (!normalized) return; + const base = basenameOfPath(normalized); + const variants = [normalized, base]; + for (const variant of variants) { + const key = String(variant || "").trim(); + if (!key || !key.toLowerCase().endsWith(".safetensors") || seen.has(key)) continue; + seen.add(key); + out.push(key); + } + }; + + const fileList = Array.from(event?.dataTransfer?.files || []); + for (const file of fileList) { + push(String(file?.name || "")); + push(String(file?.path || "")); + } + + const payloads = [ + String(event?.dataTransfer?.getData?.("text/uri-list") || ""), + String(event?.dataTransfer?.getData?.("DownloadURL") || ""), + String(event?.dataTransfer?.getData?.("text/x-moz-url") || ""), + String(event?.dataTransfer?.getData?.("text/plain") || ""), + ].filter(Boolean); + + for (const payload of payloads) { + const lines = payload.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + for (const line of lines) { + const chunks = line.split(/\|/).map((part) => part.trim()).filter(Boolean); + for (const chunk of chunks) { + push(chunk); + } + } + } + + return out; +} + +function getOptionSetForNode(node) { + const rawOpts = getWidget(node, "slot_01_lora_name")?.options?.values; + if (Array.isArray(rawOpts) && rawOpts.length > 1) { + return new Set(rawOpts); + } + if (_loraSetCache instanceof Set && _loraSetCache.size > 0) { + return _loraSetCache; + } + return null; +} + +function resolveDroppedLoraName(node, event) { + const candidates = collectDroppedLoraCandidates(event); + if (!candidates.length) return ""; + + const optionSet = getOptionSetForNode(node); + if (optionSet && optionSet.size > 0) { + for (const candidate of candidates) { + if (optionSet.has(candidate)) { + return candidate; + } + } + + const optionEntries = Array.from(optionSet); + for (const candidate of candidates) { + const candidateBase = basenameOfPath(candidate).toLowerCase(); + const matched = optionEntries.find((entry) => basenameOfPath(entry).toLowerCase() === candidateBase); + if (matched) { + return matched; + } + } + } + + return basenameOfPath(candidates[0]); +} + +function isExternalFileDrag(event) { + const dt = event?.dataTransfer; + if (!dt) return false; + + try { + const items = Array.from(dt.items || []); + if (items.some((item) => item?.kind === "file")) { + return true; + } + } catch {} + + try { + const types = Array.from(dt.types || []).map((value) => String(value)); + return types.includes("Files") || types.includes("application/x-moz-file"); + } catch {} + + return false; +} + +function hasDroppedLoraCandidate(event) { + return collectDroppedLoraCandidates(event).length > 0; +} + +// Returns {x, y} in the same coordinate space as widget.y / widget.last_y in LiteGraph. +// The canvas is translated to node.pos before drawNode/onDrawForeground, so the coordinate +// system for drawing starts at (0,0) = top-left of node (including title). +// widget.y is set by _arrangeWidgets and starts at NODE_TITLE_HEIGHT (approximately), +// so no extra correction is needed β€” just subtract node.pos to get node-local coords. +function resolveLocalDropPoint(node, event) { + try { + const canvas = app?.canvas?.canvas; + const ds = app?.canvas?.ds; + const rect = canvas?.getBoundingClientRect?.(); + if (rect && ds && Number.isFinite(event?.clientX) && Number.isFinite(event?.clientY)) { + const scale = ds.scale || 1; + const graphX = ((event.clientX - rect.left) / scale) - (ds.offset?.[0] || 0); + const graphY = ((event.clientY - rect.top) / scale) - (ds.offset?.[1] || 0); + return { + x: graphX - (node?.pos?.[0] || 0), + y: graphY - (node?.pos?.[1] || 0), + }; + } + } catch {} + + const explicitX = Number(event?.graphX ?? event?.canvasX ?? NaN); + const explicitY = Number(event?.graphY ?? event?.canvasY ?? NaN); + if (Number.isFinite(explicitX) && Number.isFinite(explicitY)) { + return { + x: explicitX - (node?.pos?.[0] || 0), + y: explicitY - (node?.pos?.[1] || 0), + }; + } + + const graphMouse = app?.canvas?.graph_mouse; + if (Array.isArray(graphMouse) && graphMouse.length >= 2 && Number.isFinite(graphMouse[0]) && Number.isFinite(graphMouse[1])) { + return { + x: graphMouse[0] - (node?.pos?.[0] || 0), + y: graphMouse[1] - (node?.pos?.[1] || 0), + }; + } + + return null; +} + +function resolveLocalDropY(node, event) { + return resolveLocalDropPoint(node, event)?.y ?? null; +} + +function getWidgetHeight(widget, nodeWidth) { + // Use LiteGraph's already-computed height if available + if (typeof widget?.computedHeight === "number" && Number.isFinite(widget.computedHeight) && widget.computedHeight > 0) { + return widget.computedHeight; + } + try { + const height = widget?.computeSize?.(nodeWidth)?.[1]; + if (typeof height === "number" && Number.isFinite(height)) return Math.max(0, height); + } catch {} + try { + if (typeof LiteGraph !== "undefined" && LiteGraph?.NODE_WIDGET_HEIGHT != null) { + return LiteGraph.NODE_WIDGET_HEIGHT; + } + } catch {} + return 20; +} + +// Get widget local-body Y using LiteGraph's own properties (set during arrange/draw) +// widget.y is set by _arrangeWidgets(), widget.last_y is set during drawWidgets(). +// Both are relative to the node body (not including the title bar). +function getWidgetBodyY(widget) { + // widget.y is set by _arrangeWidgets() β€” always > 0 for arranged widgets (title adds ~30px offset). + // A value of 0 means the widget hasn't been arranged yet (default from addWidget), treat as unknown. + const y = widget?.y; + if (typeof y === "number" && Number.isFinite(y) && y > 0) return y; + const ly = widget?.last_y; + if (typeof ly === "number" && Number.isFinite(ly) && ly > 0) return ly; + return null; +} + +function getWidgetAtBodyY(node, bodyY) { + for (const widget of node.widgets || []) { + if (!widget || widget.hidden) continue; + const wy = getWidgetBodyY(widget); + if (wy === null) continue; + const wh = getWidgetHeight(widget, node.size?.[0] || SIMPLE_WIDTH); + if (bodyY >= wy && bodyY <= wy + wh) return widget; + } + return null; +} + +function getSlotFromWidgetName(name) { + const match = /^slot_(\d+)_/.exec(String(name || "")); + return match ? Number(match[1]) : null; +} + +function getWidgetLayoutRows(node) { + const rows = []; + const nodeWidth = node.size?.[0] || SIMPLE_WIDTH; + for (const widget of node.widgets || []) { + if (!widget || widget.hidden) continue; + const y = getWidgetBodyY(widget); + if (y === null) continue; + const height = getWidgetHeight(widget, nodeWidth); + rows.push({ widget, y, height }); + } + // Sort by Y in case order is wrong + rows.sort((a, b) => a.y - b.y); + return rows; +} + +function getSlotRect(node, slot) { + if (!slot || slot < 1) return null; + const rows = getWidgetLayoutRows(node); + let minY = Infinity; + let maxY = -Infinity; + + for (const row of rows) { + const rowSlot = getSlotFromWidgetName(row.widget?.name); + if (rowSlot !== slot) continue; + minY = Math.min(minY, row.y); + maxY = Math.max(maxY, row.y + row.height); + } + + if (!Number.isFinite(minY) || !Number.isFinite(maxY)) return null; + return { + x: 8, + y: minY - 2, + width: Math.max(0, (node?.size?.[0] || SIMPLE_WIDTH) - 16), + height: Math.max(0, maxY - minY + 4), + }; +} + +function getFallbackEmptySlot(node) { + const visibleSlots = getVisibleSlots(node); + for (let slot = 1; slot <= visibleSlots; slot += 1) { + const widget = getWidget(node, `slot_${String(slot).padStart(2, "0")}_lora_name`); + if (widget && !widget.hidden && String(widget.value || "no") === "no") { + return slot; + } + } + return visibleSlots > 0 ? 1 : null; +} + +function getDropTargetSlot(node, event, options = {}) { + const { fallbackToEmpty = false } = options; + const visibleSlots = getVisibleSlots(node); + const localPoint = resolveLocalDropPoint(node, event); + const localX = Number(localPoint?.x); + const localY = Number(localPoint?.y); + if (Number.isFinite(localY)) { + const slotRects = []; + for (let slot = 1; slot <= visibleSlots; slot += 1) { + const rect = getSlotRect(node, slot); + if (rect) slotRects.push({ slot, rect }); + } + + for (const { slot, rect } of slotRects) { + const insideX = !Number.isFinite(localX) || (localX >= rect.x && localX <= rect.x + rect.width); + if (insideX && localY >= rect.y && localY <= rect.y + rect.height) { + return slot; + } + } + + const hit = getWidgetAtBodyY(node, localY); + const hitSlot = getSlotFromWidgetName(hit?.name); + if (hitSlot != null) return hitSlot; + + for (const row of getWidgetLayoutRows(node)) { + if (localY >= row.y && localY <= row.y + row.height + 4) { + const rowSlot = getSlotFromWidgetName(row.widget?.name); + if (rowSlot != null) return rowSlot; + } + } + + if (slotRects.length > 0) { + let bestSlot = slotRects[0].slot; + let bestDistance = Infinity; + for (const { slot, rect } of slotRects) { + const centerY = rect.y + (rect.height / 2); + const distance = Math.abs(localY - centerY); + if (distance < bestDistance) { + bestDistance = distance; + bestSlot = slot; + } + } + return bestSlot; + } + } + return fallbackToEmpty ? getFallbackEmptySlot(node) : null; +} + +function setDropHoverSlot(node, slot) { + node._iamccsDropHoverSlot = Number(slot) || 0; + node._iamccsDropHoverAt = Date.now(); +} + +function clearDropHoverSlot(node) { + if (!node) return; + if (!node._iamccsDropHoverSlot && !node._iamccsDropHoverAt) return; + node._iamccsDropHoverSlot = 0; + node._iamccsDropHoverAt = 0; + node.setDirtyCanvas?.(true, true); +} + +function drawDropHover(node, ctx) { + const slot = Number(node?._iamccsDropHoverSlot) || 0; + const at = Number(node?._iamccsDropHoverAt) || 0; + if (!slot || !at) return; + if ((Date.now() - at) > DROP_HOVER_TIMEOUT_MS) { + node._iamccsDropHoverSlot = 0; + node._iamccsDropHoverAt = 0; + return; + } + + const rect = getSlotRect(node, slot); + if (!rect) return; + + ctx.save(); + ctx.fillStyle = "rgba(94, 181, 120, 0.14)"; + ctx.strokeStyle = "rgba(94, 181, 120, 0.95)"; + ctx.lineWidth = 2; + ctx.fillRect(rect.x, rect.y, rect.width, rect.height); + ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); + ctx.restore(); +} + +function assignDroppedLora(node, event) { + const loraName = resolveDroppedLoraName(node, event); + if (!loraName) return false; + + const targetSlot = Number(node?._iamccsDropHoverSlot) || getDropTargetSlot(node, event, { fallbackToEmpty: true }); + const targetWidget = targetSlot != null + ? getWidget(node, `slot_${String(targetSlot).padStart(2, "0")}_lora_name`) + : null; + if (!targetWidget) return false; + + const optionValues = Array.isArray(targetWidget?.options?.values) ? targetWidget.options.values : null; + if (optionValues && !optionValues.includes(loraName)) { + targetWidget.options.values = [loraName, ...optionValues]; + } + + targetWidget.value = loraName; + try { + targetWidget.callback?.(loraName, app.canvas, node); + } catch {} + requestSyncAllLinkedLowNodes(node); + clearDropHoverSlot(node); + node.setDirtyCanvas?.(true, true); + return true; +} + +// Cache of LoRA filenames from server API (loaded once). +let _loraSetCache = null; + +async function getLoraSet() { + if (_loraSetCache !== null) return _loraSetCache; + try { + const resp = await app.api.fetchApi("/object_info/IAMCCS_WanLoRASchedule"); + if (resp.ok) { + const data = await resp.json(); + const req = data?.IAMCCS_WanLoRASchedule?.input?.required; + const slot1 = req?.slot_01_lora_name; + if (Array.isArray(slot1?.[0])) { + _loraSetCache = new Set(slot1[0]); + return _loraSetCache; + } + } + } catch {} + // Fallback: try widget.options.values from any lora_name combo on an existing node + try { + const nodeType = Object.keys(app.graph?._nodes_by_id || {}); + for (const id of nodeType) { + const n = app.graph.getNodeById(id); + if (!NODE_NAMES.has(n?.type)) continue; + const w = getWidget(n, "slot_01_lora_name"); + const v = w?.options?.values ?? (Array.isArray(w?.options) ? w.options : null); + if (Array.isArray(v) && v.length > 1) { + _loraSetCache = new Set(v); + return _loraSetCache; + } + } + } catch {} + _loraSetCache = new Set(); + return _loraSetCache; +} + +async function syncSlotToLowNode(highNode, lowNode, slot, providedOptionSet = null) { + const prefix = `slot_${String(slot).padStart(2, "0")}`; + const highWidget = getWidget(highNode, `${prefix}_lora_name`); + const lowWidget = getWidget(lowNode, `${prefix}_lora_name`); + if (!highWidget || !lowWidget) return; + + lowNode.properties = lowNode.properties || {}; + lowNode.properties[AUTO_PROP] = lowNode.properties[AUTO_PROP] || {}; + const autoKey = `${prefix}_lora_name`; + const lastAuto = String(lowNode.properties[AUTO_PROP][autoKey] || ""); + const lowValue = String(lowWidget.value || "no"); + const highValue = String(highWidget.value || "no"); + if (!highValue || highValue === "no") { + if (lowValue === lastAuto || lowValue === "no") { + lowWidget.value = "no"; + delete lowNode.properties[AUTO_PROP][autoKey]; + try { + lowWidget.callback?.("no", app.canvas, lowNode); + } catch {} + } + return; + } + + const candidates = candidateLowNames(highValue); + if (!candidates.length) return; // no _HIGH_/_LOW_ pattern in name + + // Build option set: prefer widget options, then server cache + let optionSet = providedOptionSet; + const rawOpts = lowWidget?.options?.values ?? (Array.isArray(lowWidget?.options) ? lowWidget.options : null); + if (Array.isArray(rawOpts) && rawOpts.length > 1) { + optionSet = new Set(rawOpts); + } else { + optionSet = await getLoraSet(); + } + + const suggested = (optionSet && optionSet.size > 0) + ? candidates.find((c) => optionSet.has(c)) + : candidates[0]; // no list available: try first candidate blindly + if (!suggested) return; + // If we have a list and the candidate is not in it, don't set an invalid value + if (optionSet && optionSet.size > 0 && !optionSet.has(suggested)) return; + + // Don't overwrite if user manually chose something other than "no" or our last auto-set value + if (lowValue !== "no" && lowValue !== lastAuto) return; + + lowWidget.value = suggested; + lowNode.properties[AUTO_PROP][autoKey] = suggested; + try { + lowWidget.callback?.(suggested, app.canvas, lowNode); + } catch {} +} + +function syncSlotSettingsToLowNode(highNode, lowNode, slot) { + const prefix = `slot_${String(slot).padStart(2, "0")}`; + const fieldNames = ["strength", "preset", "start", "end"]; + for (const fieldName of fieldNames) { + const highWidget = getWidget(highNode, `${prefix}_${fieldName}`); + const lowWidget = getWidget(lowNode, `${prefix}_${fieldName}`); + if (!highWidget || !lowWidget) continue; + lowWidget.value = highWidget.value; + try { + lowWidget.callback?.(highWidget.value, app.canvas, lowNode); + } catch {} + } +} + +async function syncAllLinkedLowNodes(highNode) { + const targets = getLinkedLinxTargets(highNode); + if (!targets.length) return; + const optionSet = await getLoraSet(); + for (const lowNode of targets) { + syncLowNodePresentation(highNode, lowNode); + ensureVisibleDefaults(lowNode); + applyLayout(lowNode); + for (let slot = 1; slot <= getVisibleSlots(highNode); slot += 1) { + await syncSlotToLowNode(highNode, lowNode, slot, optionSet); + syncSlotSettingsToLowNode(highNode, lowNode, slot); + } + applyLayout(lowNode); + lowNode.setDirtyCanvas?.(true, true); + } +} + +function wrapSlotCallbacks(node) { + if (node._iamccsScheduleWrappedCallbacks) return; + for (let slot = 1; slot <= MAX_SLOTS; slot += 1) { + const prefix = `slot_${String(slot).padStart(2, "0")}`; + // Wrap lora_name β†’ triggers linx sync + const loraWidget = getWidget(node, `${prefix}_lora_name`); + if (loraWidget && !loraWidget._iamccsWrapped) { + const origLora = loraWidget.callback; + loraWidget.callback = (value, canvas, targetNode) => { + try { origLora?.(value, canvas, targetNode); } finally { + requestSyncAllLinkedLowNodes(node); + } + }; + loraWidget._iamccsWrapped = true; + } + // Wrap preset β†’ refreshes start/end visibility + const presetWidget = getWidget(node, `${prefix}_preset`); + if (presetWidget && !presetWidget._iamccsWrapped) { + const origPreset = presetWidget.callback; + presetWidget.callback = (value, canvas, targetNode) => { + try { origPreset?.(value, canvas, targetNode); } finally { + setTimeout(() => { + applyLayout(node); + requestSyncAllLinkedLowNodes(node); + node.setDirtyCanvas(true, true); + }, 0); + } + }; + presetWidget._iamccsWrapped = true; + } + for (const fieldName of ["strength", "start", "end"]) { + const widget = getWidget(node, `${prefix}_${fieldName}`); + if (!widget || widget._iamccsWrapped) continue; + const origCallback = widget.callback; + widget.callback = (value, canvas, targetNode) => { + try { origCallback?.(value, canvas, targetNode); } finally { + requestSyncAllLinkedLowNodes(node); + } + }; + widget._iamccsWrapped = true; + } + } + node._iamccsScheduleWrappedCallbacks = true; +} + +function presetNeedsRange(preset) { + const s = String(preset || ""); + return !s || s === "custom range" || s === "manual_range"; +} + +function setSlotVisibility(node, slot, visible, advanced) { + const prefix = `slot_${String(slot).padStart(2, "0")}`; + const loraWidget = getWidget(node, `${prefix}_lora_name`); + const strengthWidget = getWidget(node, `${prefix}_strength`); + const presetWidget = getWidget(node, `${prefix}_preset`); + const startWidget = getWidget(node, `${prefix}_start`); + const endWidget = getWidget(node, `${prefix}_end`); + if (visible) { + showWidget(loraWidget); + showWidget(strengthWidget); + if (advanced) { + showWidget(presetWidget); + const currentPreset = String(presetWidget?.value ?? ""); + if (presetNeedsRange(currentPreset)) { + showWidget(startWidget); + showWidget(endWidget); + } else { + hideWidget(startWidget); + hideWidget(endWidget); + } + } else { + hideWidget(presetWidget); + hideWidget(startWidget); + hideWidget(endWidget); + } + } else { + hideWidget(loraWidget); + hideWidget(strengthWidget); + hideWidget(presetWidget); + hideWidget(startWidget); + hideWidget(endWidget); + } +} + +function reorderWidgets(node) { + const orderedNames = [ + "generation_index", + "log_prefix", + "model_type", + MODE_WIDGET, + LOAD_WIDGET, + SAVE_WIDGET, + ADD_WIDGET, + DELETE_WIDGET, + RESET_WIDGET, + ]; + for (let slot = 1; slot <= MAX_SLOTS; slot += 1) { + const prefix = `slot_${String(slot).padStart(2, "0")}`; + orderedNames.push(`${prefix}_lora_name`); + orderedNames.push(`${prefix}_strength`); + orderedNames.push(`${prefix}_preset`); + orderedNames.push(`${prefix}_start`); + orderedNames.push(`${prefix}_end`); + } + const byName = new Map((node.widgets || []).map((widget) => [widget?.name, widget])); + const ordered = []; + for (const name of orderedNames) { + const widget = byName.get(name); + if (widget) { + ordered.push(widget); + byName.delete(name); + } + } + node.widgets = [...ordered, ...Array.from(byName.values())]; +} + +function targetWidth(node) { + return (node?.properties?.[MODE_PROP] || "simple") === "advanced" ? ADVANCED_WIDTH : SIMPLE_WIDTH; +} + +function targetHeight(node) { + const mode = node?.properties?.[MODE_PROP] || "simple"; + const count = getVisibleSlots(node); + const slotHeight = mode === "advanced" ? ADVANCED_SLOT_HEIGHT : SIMPLE_SLOT_HEIGHT; + return BASE_HEIGHT + (count * slotHeight) + EXTRA_HEIGHT; +} + +function applyLayout(node) { + ensureBoxShape(node); + ensureVisibleDefaults(node); + applyLabels(node); + applyPresetOptions(node); + wrapSlotCallbacks(node); + + hideWidget(getWidget(node, "generation_index")); + hideWidget(getWidget(node, "log_prefix")); + + const advanced = (node?.properties?.[MODE_PROP] || "simple") === "advanced"; + if (advanced) { + showWidget(getWidget(node, "model_type")); + } else { + hideWidget(getWidget(node, "model_type")); + } + + const visibleSlots = getVisibleSlots(node); + for (let slot = 1; slot <= MAX_SLOTS; slot += 1) { + setSlotVisibility(node, slot, slot <= visibleSlots, advanced); + } + + reorderWidgets(node); + const width = targetWidth(node); + const height = targetHeight(node); + const computed = node.computeSize?.() || node.size || [width, height]; + node.size = [Math.max(width, computed[0] || width), Math.max(height, computed[1] || height)]; +} + +app.registerExtension({ + name: "iamccs.wan_lora_schedule_ui", + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (!NODE_NAMES.has(nodeData?.name)) return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + this.properties = this.properties || {}; + if (!this.properties[MODE_PROP]) this.properties[MODE_PROP] = "simple"; + if (!this.properties[SLOTS_PROP]) this.properties[SLOTS_PROP] = 3; + ensureModeWidget(this); + ensureActionWidgets(this); + setTimeout(() => { + applyLayout(this); + this.setDirtyCanvas(true, true); + }, 0); + return result; + }; + + const onDragOver = nodeType.prototype.onDragOver; + nodeType.prototype.onDragOver = function (event) { + if (isExternalFileDrag(event)) { + try { + app?.canvas?.adjustMouseEvent?.(event); + } catch {} + const slot = getDropTargetSlot(this, event, { fallbackToEmpty: false }); + if (slot != null) { + setDropHoverSlot(this, slot); + } else { + clearDropHoverSlot(this); + } + try { + event?.preventDefault?.(); + event?.stopPropagation?.(); + if (event?.dataTransfer) event.dataTransfer.dropEffect = "copy"; + } catch {} + this.setDirtyCanvas?.(true, true); + return true; + } + clearDropHoverSlot(this); + return onDragOver?.apply(this, arguments); + }; + + const onDragDrop = nodeType.prototype.onDragDrop; + nodeType.prototype.onDragDrop = function (event) { + try { + app?.canvas?.adjustMouseEvent?.(event); + } catch {} + try { + event?.preventDefault?.(); + event?.stopPropagation?.(); + } catch {} + if (hasDroppedLoraCandidate(event)) { + assignDroppedLora(this, event); + return true; + } + clearDropHoverSlot(this); + return onDragDrop?.apply(this, arguments); + }; + + const onDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function (ctx) { + const result = onDrawForeground?.apply(this, arguments); + drawDropHover(this, ctx); + drawNodeToast(this, ctx); + return result; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (config) { + this._iamccsRestoringState = true; + // 1. Normalize legacy corrupt widget-value arrays (modifies config in place) + normalizeLegacyWidgetValues(config); + + // 2. Re-apply normalized values to widgets explicitly. + // LiteGraph assigns widgets_values β†’ widget.value BEFORE calling onConfigure, + // so if the values were corrupt, they are already set wrong. Re-assigning here + // (in the same iteration order, skipping non-serializable UI widgets) corrects them. + if (Array.isArray(config.widgets_values) && this.widgets) { + let j = 0; + for (const widget of this.widgets) { + if (!widget || widget.options?.serialize === false || widget._iamccsUiOnly) continue; + if (j < config.widgets_values.length) { + widget.value = config.widgets_values[j++]; + } + } + } + + const result = onConfigure?.apply(this, arguments); + this.properties = this.properties || {}; + if (!this.properties[MODE_PROP]) this.properties[MODE_PROP] = "simple"; + if (!this.properties[SLOTS_PROP]) this.properties[SLOTS_PROP] = 3; + ensureModeWidget(this); + ensureActionWidgets(this); + setTimeout(() => { + // Mark ALL slots as already-initialized so ensureSlotDefaults won't clobber + // values that were restored from the saved workflow. + this.properties[INIT_PROP] = this.properties[INIT_PROP] || {}; + for (let s = 1; s <= MAX_SLOTS; s++) { + this.properties[INIT_PROP][s] = true; + } + applyLayout(this); + this._iamccsRestoringState = false; + this._iamccsPendingSyncLowNodes = false; + this.setDirtyCanvas(true, true); + }, 0); + return result; + }; + + const onConnectionsChange = nodeType.prototype.onConnectionsChange; + nodeType.prototype.onConnectionsChange = function () { + const result = onConnectionsChange?.apply(this, arguments); + setTimeout(() => { + applyLayout(this); + if (!isRestoringNodeState(this)) { + requestSyncAllLinkedLowNodes(this); + } + this.setDirtyCanvas(true, true); + }, 0); + return result; + }; + + const collapse = nodeType.prototype.collapse; + if (typeof collapse === "function") { + nodeType.prototype.collapse = function () { + const result = collapse?.apply(this, arguments); + setTimeout(() => { + if (!isRestoringNodeState(this)) { + requestSyncAllLinkedLowNodes(this); + } + this.setDirtyCanvas(true, true); + }, 0); + return result; + }; + } + }, +}); diff --git a/backup/web_disabled/iamccs_workflow_persist_cleanup.20260418_draft_quota_retry.bak.js b/backup/web_disabled/iamccs_workflow_persist_cleanup.20260418_draft_quota_retry.bak.js new file mode 100644 index 0000000..2d9ac5b --- /dev/null +++ b/backup/web_disabled/iamccs_workflow_persist_cleanup.20260418_draft_quota_retry.bak.js @@ -0,0 +1,188 @@ +import { app } from "../../scripts/app.js"; + +const LEGACY_LOCAL_KEYS = [ + "workflow", + "Comfy.OpenWorkflowsPaths", + "Comfy.ActiveWorkflowIndex", +]; + +const LEGACY_PREFIXES = [ + "Comfy.Workflow.Drafts", + "Comfy.Workflow.DraftOrder", +]; + +const V2_PREFIX = { + draftIndex: "Comfy.Workflow.DraftIndex.v2:", + draftPayload: "Comfy.Workflow.Draft.v2:", + lastActivePath: "Comfy.Workflow.LastActivePath:", + lastOpenPaths: "Comfy.Workflow.LastOpenPaths:", +}; + +const MAX_DRAFTS_PER_WORKSPACE = 4; +const MAX_DRAFT_CHARS_PER_WORKSPACE = 2_500_000; + +function safeParseJson(json) { + if (!json) return null; + try { + return JSON.parse(json); + } catch { + return null; + } +} + +function listStorageKeys(storage) { + const keys = []; + try { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key) keys.push(key); + } + } catch { + return []; + } + return keys; +} + +function removeKey(storage, key) { + try { + storage.removeItem(key); + return true; + } catch { + return false; + } +} + +function getPayloadKey(workspaceId, draftKey) { + return `${V2_PREFIX.draftPayload}${workspaceId}:${draftKey}`; +} + +function cleanupLegacyWorkflowStorage(localStorageRef) { + let removed = 0; + for (const key of LEGACY_LOCAL_KEYS) { + if (removeKey(localStorageRef, key)) removed += 1; + } + + for (const key of listStorageKeys(localStorageRef)) { + if (LEGACY_PREFIXES.some((prefix) => key.startsWith(prefix))) { + if (removeKey(localStorageRef, key)) removed += 1; + } + } + + return removed; +} + +function cleanupWorkspaceDrafts(localStorageRef, workspaceId) { + const indexKey = `${V2_PREFIX.draftIndex}${workspaceId}`; + const rawIndex = localStorageRef.getItem(indexKey); + if (!rawIndex) return { removed: 0, rewritten: false }; + + const index = safeParseJson(rawIndex); + if (!index || typeof index !== "object" || !Array.isArray(index.order) || typeof index.entries !== "object" || index.entries == null) { + let removed = removeKey(localStorageRef, indexKey) ? 1 : 0; + const payloadPrefix = `${V2_PREFIX.draftPayload}${workspaceId}:`; + for (const key of listStorageKeys(localStorageRef)) { + if (key.startsWith(payloadPrefix) && removeKey(localStorageRef, key)) { + removed += 1; + } + } + return { removed, rewritten: false }; + } + + const payloadPrefix = `${V2_PREFIX.draftPayload}${workspaceId}:`; + const seenPayloads = new Set(); + const keptEntries = {}; + const keptOrder = []; + let keptChars = 0; + let keptCount = 0; + let removed = 0; + + const newestFirst = [...index.order].reverse(); + for (const draftKey of newestFirst) { + const entry = index.entries[draftKey]; + if (!entry || typeof entry.path !== "string") continue; + + const payloadKey = getPayloadKey(workspaceId, draftKey); + const payloadJson = localStorageRef.getItem(payloadKey); + if (!payloadJson) continue; + + const payloadChars = payloadJson.length; + const canKeep = keptCount < MAX_DRAFTS_PER_WORKSPACE && ( + keptCount === 0 || keptChars + payloadChars <= MAX_DRAFT_CHARS_PER_WORKSPACE + ); + + if (canKeep) { + keptEntries[draftKey] = entry; + keptOrder.unshift(draftKey); + keptChars += payloadChars; + keptCount += 1; + seenPayloads.add(payloadKey); + } else if (removeKey(localStorageRef, payloadKey)) { + removed += 1; + } + } + + for (const key of listStorageKeys(localStorageRef)) { + if (key.startsWith(payloadPrefix) && !seenPayloads.has(key)) { + if (removeKey(localStorageRef, key)) removed += 1; + } + } + + const nextIndex = { + ...index, + updatedAt: Date.now(), + order: keptOrder, + entries: keptEntries, + }; + + const nextIndexJson = JSON.stringify(nextIndex); + const rewritten = nextIndexJson !== rawIndex; + if (rewritten) { + try { + localStorageRef.setItem(indexKey, nextIndexJson); + } catch { + // If index rewrite fails, leaving the trimmed payload set is still better + // than keeping localStorage saturated with stale drafts. + } + } + + return { removed, rewritten }; +} + +function cleanupWorkflowPersistenceStorage() { + try { + const localStorageRef = window.localStorage; + if (!localStorageRef) return; + + let removed = cleanupLegacyWorkflowStorage(localStorageRef); + let rewritten = 0; + + const workspaceIds = new Set(); + for (const key of listStorageKeys(localStorageRef)) { + if (key.startsWith(V2_PREFIX.draftIndex)) { + workspaceIds.add(key.slice(V2_PREFIX.draftIndex.length)); + } + } + + for (const workspaceId of workspaceIds) { + const result = cleanupWorkspaceDrafts(localStorageRef, workspaceId); + removed += result.removed; + if (result.rewritten) rewritten += 1; + } + + if (removed || rewritten) { + console.info("[IAMCCS] Workflow draft storage cleanup applied", { + removedKeys: removed, + rewrittenIndexes: rewritten, + }); + } + } catch (error) { + console.warn("[IAMCCS] Workflow draft storage cleanup skipped", error); + } +} + +app.registerExtension({ + name: "iamccs.workflow_persist_cleanup", + async setup() { + cleanupWorkflowPersistenceStorage(); + }, +}); \ No newline at end of file diff --git a/backup/web_disabled/iamccs_workflow_persist_cleanup.20260419_prototype_guard.bak.js b/backup/web_disabled/iamccs_workflow_persist_cleanup.20260419_prototype_guard.bak.js new file mode 100644 index 0000000..b74bfc6 --- /dev/null +++ b/backup/web_disabled/iamccs_workflow_persist_cleanup.20260419_prototype_guard.bak.js @@ -0,0 +1,366 @@ +import { app } from "../../scripts/app.js"; + +const LEGACY_LOCAL_KEYS = [ + "workflow", + "Comfy.OpenWorkflowsPaths", + "Comfy.ActiveWorkflowIndex", +]; + +const LEGACY_PREFIXES = [ + "Comfy.Workflow.Drafts", + "Comfy.Workflow.DraftOrder", +]; + +const V2_PREFIX = { + draftIndex: "Comfy.Workflow.DraftIndex.v2:", + draftPayload: "Comfy.Workflow.Draft.v2:", + lastActivePath: "Comfy.Workflow.LastActivePath:", + lastOpenPaths: "Comfy.Workflow.LastOpenPaths:", +}; + +const MAX_DRAFTS_PER_WORKSPACE = 4; +const MAX_DRAFT_CHARS_PER_WORKSPACE = 2_500_000; +const STORAGE_PATCH_FLAG = "__iamccsWorkflowDraftQuotaPatchApplied"; + +function safeParseJson(json) { + if (!json) return null; + try { + return JSON.parse(json); + } catch { + return null; + } +} + +function listStorageKeys(storage) { + const keys = []; + try { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key) keys.push(key); + } + } catch { + return []; + } + return keys; +} + +function removeKey(storage, key) { + try { + storage.removeItem(key); + return true; + } catch { + return false; + } +} + +function getPayloadKey(workspaceId, draftKey) { + return `${V2_PREFIX.draftPayload}${workspaceId}:${draftKey}`; +} + +function extractWorkspaceIdFromKey(key) { + if (typeof key !== "string") return null; + if (key.startsWith(V2_PREFIX.draftIndex)) { + return key.slice(V2_PREFIX.draftIndex.length); + } + if (key.startsWith(V2_PREFIX.draftPayload)) { + const rest = key.slice(V2_PREFIX.draftPayload.length); + const separatorIndex = rest.indexOf(":"); + return separatorIndex === -1 ? null : rest.slice(0, separatorIndex); + } + if (key.startsWith(V2_PREFIX.lastActivePath)) { + return key.slice(V2_PREFIX.lastActivePath.length); + } + if (key.startsWith(V2_PREFIX.lastOpenPaths)) { + return key.slice(V2_PREFIX.lastOpenPaths.length); + } + return null; +} + +function isDraftStorageKey(key) { + return typeof key === "string" && ( + key.startsWith(V2_PREFIX.draftIndex) + || key.startsWith(V2_PREFIX.draftPayload) + || key.startsWith(V2_PREFIX.lastActivePath) + || key.startsWith(V2_PREFIX.lastOpenPaths) + || LEGACY_LOCAL_KEYS.includes(key) + || LEGACY_PREFIXES.some((prefix) => key.startsWith(prefix)) + ); +} + +function isQuotaExceededError(error) { + if (!error) return false; + const name = String(error.name || ""); + const message = String(error.message || "").toLowerCase(); + return name === "QuotaExceededError" + || name === "NS_ERROR_DOM_QUOTA_REACHED" + || message.includes("quota") + || message.includes("storage is full"); +} + +function cleanupWorkspaceTrackingStorage(localStorageRef, workspaceId = null) { + let removed = 0; + for (const key of listStorageKeys(localStorageRef)) { + const isTrackingKey = key.startsWith(V2_PREFIX.lastActivePath) + || key.startsWith(V2_PREFIX.lastOpenPaths); + if (!isTrackingKey) continue; + + if (workspaceId !== null && extractWorkspaceIdFromKey(key) !== workspaceId) { + continue; + } + + if (removeKey(localStorageRef, key)) removed += 1; + } + return removed; +} + +function cleanupLegacyWorkflowStorage(localStorageRef) { + let removed = 0; + for (const key of LEGACY_LOCAL_KEYS) { + if (removeKey(localStorageRef, key)) removed += 1; + } + + for (const key of listStorageKeys(localStorageRef)) { + if (LEGACY_PREFIXES.some((prefix) => key.startsWith(prefix))) { + if (removeKey(localStorageRef, key)) removed += 1; + } + } + + return removed; +} + +function cleanupWorkspaceDrafts(localStorageRef, workspaceId) { + const indexKey = `${V2_PREFIX.draftIndex}${workspaceId}`; + const rawIndex = localStorageRef.getItem(indexKey); + if (!rawIndex) return { removed: 0, rewritten: false }; + + const index = safeParseJson(rawIndex); + if (!index || typeof index !== "object" || !Array.isArray(index.order) || typeof index.entries !== "object" || index.entries == null) { + let removed = removeKey(localStorageRef, indexKey) ? 1 : 0; + const payloadPrefix = `${V2_PREFIX.draftPayload}${workspaceId}:`; + for (const key of listStorageKeys(localStorageRef)) { + if (key.startsWith(payloadPrefix) && removeKey(localStorageRef, key)) { + removed += 1; + } + } + return { removed, rewritten: false }; + } + + const payloadPrefix = `${V2_PREFIX.draftPayload}${workspaceId}:`; + const seenPayloads = new Set(); + const keptEntries = {}; + const keptOrder = []; + let keptChars = 0; + let keptCount = 0; + let removed = 0; + + const newestFirst = [...index.order].reverse(); + for (const draftKey of newestFirst) { + const entry = index.entries[draftKey]; + if (!entry || typeof entry.path !== "string") continue; + + const payloadKey = getPayloadKey(workspaceId, draftKey); + const payloadJson = localStorageRef.getItem(payloadKey); + if (!payloadJson) continue; + + const payloadChars = payloadJson.length; + const canKeep = keptCount < MAX_DRAFTS_PER_WORKSPACE && ( + keptCount === 0 || keptChars + payloadChars <= MAX_DRAFT_CHARS_PER_WORKSPACE + ); + + if (canKeep) { + keptEntries[draftKey] = entry; + keptOrder.unshift(draftKey); + keptChars += payloadChars; + keptCount += 1; + seenPayloads.add(payloadKey); + } else if (removeKey(localStorageRef, payloadKey)) { + removed += 1; + } + } + + for (const key of listStorageKeys(localStorageRef)) { + if (key.startsWith(payloadPrefix) && !seenPayloads.has(key)) { + if (removeKey(localStorageRef, key)) removed += 1; + } + } + + const nextIndex = { + ...index, + updatedAt: Date.now(), + order: keptOrder, + entries: keptEntries, + }; + + const nextIndexJson = JSON.stringify(nextIndex); + const rewritten = nextIndexJson !== rawIndex; + if (rewritten) { + try { + localStorageRef.setItem(indexKey, nextIndexJson); + } catch { + // If index rewrite fails, leaving the trimmed payload set is still better + // than keeping localStorage saturated with stale drafts. + } + } + + return { removed, rewritten }; +} + +function cleanupWorkflowPersistenceStorage() { + try { + const localStorageRef = window.localStorage; + if (!localStorageRef) return; + + let removed = cleanupLegacyWorkflowStorage(localStorageRef); + removed += cleanupWorkspaceTrackingStorage(localStorageRef); + let rewritten = 0; + + const workspaceIds = new Set(); + for (const key of listStorageKeys(localStorageRef)) { + if (key.startsWith(V2_PREFIX.draftIndex)) { + workspaceIds.add(key.slice(V2_PREFIX.draftIndex.length)); + } + } + + for (const workspaceId of workspaceIds) { + const result = cleanupWorkspaceDrafts(localStorageRef, workspaceId); + removed += result.removed; + if (result.rewritten) rewritten += 1; + } + + if (removed || rewritten) { + console.info("[IAMCCS] Workflow draft storage cleanup applied", { + removedKeys: removed, + rewrittenIndexes: rewritten, + }); + } + } catch (error) { + console.warn("[IAMCCS] Workflow draft storage cleanup skipped", error); + } +} + +function recoverDraftStorage(localStorageRef, key) { + let removed = cleanupLegacyWorkflowStorage(localStorageRef); + let rewritten = 0; + + const workspaceIds = new Set(); + const workspaceId = extractWorkspaceIdFromKey(key); + if (workspaceId) workspaceIds.add(workspaceId); + + for (const storageKey of listStorageKeys(localStorageRef)) { + if (storageKey.startsWith(V2_PREFIX.draftIndex)) { + workspaceIds.add(storageKey.slice(V2_PREFIX.draftIndex.length)); + } + } + + for (const currentWorkspaceId of workspaceIds) { + removed += cleanupWorkspaceTrackingStorage(localStorageRef, currentWorkspaceId); + const result = cleanupWorkspaceDrafts(localStorageRef, currentWorkspaceId); + removed += result.removed; + if (result.rewritten) rewritten += 1; + } + + return { removed, rewritten }; +} + +function aggressiveRecoverDraftStorage(localStorageRef) { + let removed = cleanupLegacyWorkflowStorage(localStorageRef); + removed += cleanupWorkspaceTrackingStorage(localStorageRef); + + for (const storageKey of listStorageKeys(localStorageRef)) { + if ( + storageKey.startsWith(V2_PREFIX.draftIndex) + || storageKey.startsWith(V2_PREFIX.draftPayload) + ) { + if (removeKey(localStorageRef, storageKey)) removed += 1; + } + } + + return { removed, rewritten: 0 }; +} + +function installDraftStorageQuotaGuard() { + try { + const localStorageRef = window.localStorage; + if (!localStorageRef || localStorageRef[STORAGE_PATCH_FLAG]) return; + + const originalSetItem = localStorageRef.setItem.bind(localStorageRef); + + localStorageRef.setItem = function patchedSetItem(key, value) { + try { + return originalSetItem(key, value); + } catch (error) { + if (!isDraftStorageKey(key) || !isQuotaExceededError(error)) { + throw error; + } + + const recovery = recoverDraftStorage(localStorageRef, key); + console.warn("[IAMCCS] Workflow draft quota reached, attempting recovery", { + key, + removedKeys: recovery.removed, + rewrittenIndexes: recovery.rewritten, + }); + + try { + const result = originalSetItem(key, value); + console.info("[IAMCCS] Workflow draft save recovered after storage cleanup", { + key, + removedKeys: recovery.removed, + rewrittenIndexes: recovery.rewritten, + }); + return result; + } catch (retryError) { + if (isQuotaExceededError(retryError)) { + const aggressiveRecovery = aggressiveRecoverDraftStorage(localStorageRef); + console.warn("[IAMCCS] Workflow draft quota still exceeded, applying aggressive recovery", { + key, + removedKeys: aggressiveRecovery.removed, + }); + + try { + const result = originalSetItem(key, value); + console.info("[IAMCCS] Workflow draft save recovered after aggressive cleanup", { + key, + removedKeys: recovery.removed + aggressiveRecovery.removed, + rewrittenIndexes: recovery.rewritten, + }); + return result; + } catch (finalRetryError) { + console.error("[IAMCCS] Workflow draft save still exceeds storage quota after aggressive cleanup", { + key, + removedKeys: recovery.removed + aggressiveRecovery.removed, + rewrittenIndexes: recovery.rewritten, + retryError: finalRetryError, + }); + throw finalRetryError; + } + } + + console.error("[IAMCCS] Workflow draft save still exceeds storage quota after cleanup", { + key, + removedKeys: recovery.removed, + rewrittenIndexes: recovery.rewritten, + retryError, + }); + throw retryError; + } + } + }; + + Object.defineProperty(localStorageRef, STORAGE_PATCH_FLAG, { + value: true, + configurable: false, + enumerable: false, + writable: false, + }); + } catch (error) { + console.warn("[IAMCCS] Workflow draft quota guard skipped", error); + } +} + +app.registerExtension({ + name: "iamccs.workflow_persist_cleanup", + async setup() { + cleanupWorkflowPersistenceStorage(); + installDraftStorageQuotaGuard(); + }, +}); diff --git a/iamccs_audio_extender.py b/iamccs_audio_extender.py index 2d6e8a5..865cb3c 100644 --- a/iamccs_audio_extender.py +++ b/iamccs_audio_extender.py @@ -717,6 +717,7 @@ class IAMCCS_AudioExtender: segment_start_s = float(start_frames) / float(fps) segment_end_s = float(start_frames + unique_frames) / float(fps) + track_duration_s = float(total_samples) / float(sample_rate) conditioning_start_s = segment_start_s conditioning_end_s = segment_end_s diff --git a/iamccs_auimg2vid_goal1.py b/iamccs_auimg2vid_goal1.py new file mode 100644 index 0000000..769da13 --- /dev/null +++ b/iamccs_auimg2vid_goal1.py @@ -0,0 +1,1647 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, Mapping, Optional + +import torch + +try: + from .iamccs_audio_extender import ( + IAMCCS_AudioExtensionMath, + IAMCCS_AudioExtender, + IAMCCS_AudioTimelineGate, + ) + from .iamccs_ltx2_extension_module import ( + IAMCCS_LTX2_ExtensionModule, + IAMCCS_LTX2_ExtensionModule_Disk, + IAMCCS_LTX2_FirstLastLatentControl_Pro, + IAMCCS_VideoCombineFromDir, + ) + from .iamccs_ltx2_tools import IAMCCS_SegmentPlanner, IAMCCS_SourceRangeFromSegmentPlan +except ImportError: + from iamccs_audio_extender import ( # type: ignore + IAMCCS_AudioExtensionMath, + IAMCCS_AudioExtender, + IAMCCS_AudioTimelineGate, + ) + from iamccs_ltx2_extension_module import ( # type: ignore + IAMCCS_LTX2_ExtensionModule, + IAMCCS_LTX2_ExtensionModule_Disk, + IAMCCS_LTX2_FirstLastLatentControl_Pro, + IAMCCS_VideoCombineFromDir, + ) + from iamccs_ltx2_tools import IAMCCS_SegmentPlanner, IAMCCS_SourceRangeFromSegmentPlan # type: ignore + + +SUPERNODE_CONTRACT_TYPE = "IAMCCS_SUPERNODE_CONTRACT" +SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX" +PIPELINE_KIND = "au_img2vid" +HELPER_CATEGORY = "IAMCCS/Ltx2 Helper Modules" +HELPER_LABEL_PREFIX = "Ltx2HelperModules" + + +def _resolve_goal1_preset(preset: str) -> Dict[str, Any]: + preset_key = str(preset or "custom").strip().lower() + presets: Dict[str, Dict[str, Any]] = { + "custom": {}, + "lowram_safe_videoclip": { + "segment_seconds": 10.0, + "planning_mode": "manual_segment_seconds", + "content_profile": "videoclip", + "overlap_frames": 9, + "backend_binding": "ltx_audio_guided_lowram", + "surface_profile": "compact", + }, + "lowram_fast_videoclip": { + "segment_seconds": 8.0, + "planning_mode": "manual_segment_seconds", + "content_profile": "videoclip", + "overlap_frames": 9, + "backend_binding": "ltx_audio_guided_lowram", + "surface_profile": "compact", + }, + "normal_quality_videoclip": { + "segment_seconds": 20.0, + "planning_mode": "manual_segment_seconds", + "content_profile": "videoclip", + "overlap_frames": 9, + "backend_binding": "ltx_audio_guided_normal", + "surface_profile": "progressive", + }, + "lowram_monologue": { + "segment_seconds": 12.0, + "planning_mode": "manual_segment_seconds", + "content_profile": "monologue", + "overlap_frames": 9, + "backend_binding": "ltx_audio_guided_lowram", + "surface_profile": "compact", + }, + } + return dict(presets.get(preset_key, presets["custom"])) + + +def _resolve_goal1_runtime_profile(goal1_preset: str, backend_binding: str, runtime_profile: str) -> str: + preset_key = str(goal1_preset or "custom").strip().lower() + if preset_key.startswith("lowram_"): + return "disk_low_ram" + if preset_key.startswith("normal_"): + return "native_canvas" + binding = str(backend_binding or "").strip().lower() + if "lowram" in binding: + return "disk_low_ram" + if binding: + return "native_canvas" + return str(runtime_profile) + + +def _resolve_goal1_filename_prefix(goal1_preset: str, filename_prefix: str) -> str: + preset_key = str(goal1_preset or "custom").strip().lower() + base_prefix = str(filename_prefix or "IAMCCS/Ltx2HelperModules") + if preset_key == "custom": + return base_prefix + if base_prefix.startswith("IAMCCS/Ltx2HelperModules"): + slug = preset_key.replace("-", "_").replace(" ", "_").upper() + return f"IAMCCS/Ltx2HelperModules/{slug}" + return base_prefix + + +def _resolve_goal1_refresh_defaults(goal1_preset: str) -> Dict[str, Any]: + preset_key = str(goal1_preset or "custom").strip().lower() + defaults: Dict[str, Dict[str, Any]] = { + "custom": { + "refresh_interval_segments": 0, + "reanchor_every_segments": 0, + "protected_head_frames": 9, + "settling_frames": 9, + "continuity_preset": "videoclip_audio_24fps", + "reanchor_preset": "micro_crossfade_3", + "reanchor_overlap_mode": "filmic_crossfade", + "reanchor_safe_mode": "native_workflow_safe", + "reanchor_start_frames_rule": "ltx2_nearest", + }, + "lowram_safe_videoclip": { + "refresh_interval_segments": 2, + "reanchor_every_segments": 3, + "protected_head_frames": 9, + "settling_frames": 9, + "first_anchor_strength": 0.35, + "last_anchor_strength": 0.20, + "first_lock_slots": 1, + "last_lock_slots": 1, + "end_transition_slots": 1, + "continuity_preset": "videoclip_audio_24fps", + "reanchor_preset": "micro_crossfade_3", + "reanchor_overlap_mode": "filmic_crossfade", + "reanchor_safe_mode": "native_workflow_safe", + "reanchor_start_frames_rule": "ltx2_nearest", + }, + "lowram_fast_videoclip": { + "refresh_interval_segments": 3, + "reanchor_every_segments": 4, + "protected_head_frames": 9, + "settling_frames": 8, + "first_anchor_strength": 0.25, + "last_anchor_strength": 0.10, + "first_lock_slots": 1, + "last_lock_slots": 1, + "end_transition_slots": 0, + "continuity_preset": "videoclip_audio_24fps", + "reanchor_preset": "cut_bestofk_16", + "reanchor_overlap_mode": "linear_blend", + "reanchor_safe_mode": "native_workflow_safe", + "reanchor_start_frames_rule": "ltx2_round_down", + }, + "normal_quality_videoclip": { + "refresh_interval_segments": 2, + "reanchor_every_segments": 2, + "protected_head_frames": 9, + "settling_frames": 10, + "first_anchor_strength": 0.45, + "last_anchor_strength": 0.25, + "first_lock_slots": 1, + "last_lock_slots": 2, + "end_transition_slots": 1, + "continuity_preset": "videoclip_audio_24fps", + "reanchor_preset": "cut_bestofk_16_luma", + "reanchor_overlap_mode": "perceptual_crossfade", + "reanchor_safe_mode": "native_workflow_safe", + "reanchor_start_frames_rule": "ltx2_nearest", + }, + "lowram_monologue": { + "refresh_interval_segments": 3, + "reanchor_every_segments": 4, + "protected_head_frames": 9, + "settling_frames": 8, + "first_anchor_strength": 0.30, + "last_anchor_strength": 0.15, + "first_lock_slots": 1, + "last_lock_slots": 1, + "end_transition_slots": 1, + "continuity_preset": "monologue_audio_24fps", + "reanchor_preset": "micro_crossfade_3", + "reanchor_overlap_mode": "filmic_crossfade", + "reanchor_safe_mode": "native_workflow_safe", + "reanchor_start_frames_rule": "ltx2_nearest", + }, + } + return dict(defaults.get(preset_key, defaults["custom"])) + + +def _parse_payload(payload: Any) -> Dict[str, Any]: + if payload is None: + return {} + if isinstance(payload, dict): + return dict(payload) + if isinstance(payload, str): + value = payload.strip() + if not value: + return {} + try: + parsed = json.loads(value) + if isinstance(parsed, dict): + return parsed + except Exception: + pass + return {"raw": str(payload)} + + +def _json_payload(data: Dict[str, Any]) -> str: + return json.dumps(data, ensure_ascii=True, sort_keys=True) + + +def _parse_contract(payload: Any) -> Dict[str, Any]: + if isinstance(payload, dict): + return dict(payload) + if payload is None: + return {} + if isinstance(payload, str): + stripped = payload.strip() + if not stripped: + return {} + try: + loaded = json.loads(stripped) + if isinstance(loaded, dict): + return loaded + except Exception: + pass + parsed: Dict[str, Any] = {} + for item in str(payload).split(";"): + item = item.strip() + if not item or "=" not in item: + continue + key, value = item.split("=", 1) + parsed[key.strip()] = value.strip() + return parsed + + +def _build_contract( + *, + node_role: str, + node_label: str, + parent_contract: Any, + payload_key: str, + payload_value: str, + extra: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + parent = _parse_contract(parent_contract) + contract = { + "type": SUPERNODE_CONTRACT_TYPE, + "pipeline_key": parent.get("pipeline_key", f"{PIPELINE_KIND}:{node_role}"), + "pipeline_kind": PIPELINE_KIND, + "role": node_role, + "label": node_label, + payload_key: payload_value, + "parent": parent, + } + if extra: + contract.update(extra) + return contract + + +def _build_linx( + *, + existing_linx: Any, + contract: Dict[str, Any], + unique_id: Any, + node_role: str, + node_label: str, + module_keys: Optional[list[str]] = None, +) -> Dict[str, Any]: + if isinstance(existing_linx, dict): + linx_payload = dict(existing_linx) + nodes = list(existing_linx.get("nodes") or []) + else: + linx_payload = {} + nodes = [] + linx_payload["type"] = SUPERNODE_LINX_TYPE + linx_payload["pipeline_kind"] = PIPELINE_KIND + linx_payload["root_contract"] = contract.get("pipeline_key", PIPELINE_KIND) + linx_payload["nodes"] = nodes + linx_payload["nodes"].append( + { + "id": str(unique_id or node_label or node_role), + "role": node_role, + "label": node_label, + "module_keys": list(module_keys or []), + } + ) + return linx_payload + + +def _audio_duration_seconds(audio: Any) -> float: + if not isinstance(audio, Mapping): + raise ValueError("audio input is required when duration_source uses audio") + waveform = audio.get("waveform") + sample_rate = int(audio.get("sample_rate", 0) or 0) + if waveform is None or sample_rate <= 0: + raise ValueError("audio payload missing waveform/sample_rate") + if not isinstance(waveform, torch.Tensor): + waveform = torch.tensor(waveform) + return float(int(waveform.shape[-1])) / float(sample_rate) + + +def _parse_numeric_list(raw: Any) -> list[float]: + if raw is None: + return [] + if isinstance(raw, (list, tuple)): + values = raw + else: + text = str(raw).strip() + if not text: + return [] + try: + loaded = json.loads(text) + if isinstance(loaded, list): + values = loaded + else: + values = [part.strip() for part in text.split(",") if part.strip()] + except Exception: + values = [part.strip() for part in text.split(",") if part.strip()] + parsed: list[float] = [] + for value in values: + try: + parsed.append(float(value)) + except Exception: + continue + return parsed + + +def _build_project_timeline_segments( + *, + total_frames: int, + segment_count: int, + unique_segment_frames: int, + first_segment_raw_frames: int, + continuation_raw_frames: int, + last_segment_unique_frames: int, + fps: float, +) -> list[Dict[str, Any]]: + segments: list[Dict[str, Any]] = [] + for segment_index in range(max(1, int(segment_count))): + start_frame = int(unique_segment_frames) * int(segment_index) + is_last_segment = segment_index >= max(0, int(segment_count) - 1) + unique_frames = int(last_segment_unique_frames) if is_last_segment else int(unique_segment_frames) + end_frame = min(int(total_frames), start_frame + max(0, unique_frames)) + segment_role = "initial" if segment_index == 0 else ("final" if is_last_segment else "continuation") + raw_frames = int(first_segment_raw_frames) if segment_index == 0 else int(continuation_raw_frames) + segments.append( + { + "segment_index": int(segment_index), + "segment_role": segment_role, + "start_frame": int(start_frame), + "end_frame": int(end_frame), + "unique_frames": int(max(0, end_frame - start_frame)), + "raw_frames": int(raw_frames), + "start_seconds": float(start_frame) / float(max(fps, 0.001)), + "end_seconds": float(end_frame) / float(max(fps, 0.001)), + } + ) + return segments + + +# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com +class IAMCCS_ProjectTimelinePlanner: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "goal1_preset": (["custom", "lowram_safe_videoclip", "lowram_fast_videoclip", "normal_quality_videoclip", "lowram_monologue"], {"default": "custom"}), + "fps": ("FLOAT", {"default": 24.0, "min": 0.001, "max": 240.0, "step": 0.01}), + "segment_seconds": ("FLOAT", {"default": 10.0, "min": 0.01, "max": 3600.0, "step": 0.01}), + "target_duration_seconds": ("FLOAT", {"default": 30.0, "min": 0.01, "max": 36000.0, "step": 0.01}), + "duration_source": (["target_duration_seconds", "audio", "max_audio_target"], {"default": "target_duration_seconds"}), + "planning_mode": (["manual_segment_seconds", "auto_profile"], {"default": "auto_profile"}), + "content_profile": (["videoclip", "monologue"], {"default": "videoclip"}), + "overlap_frames": ("INT", {"default": 9, "min": 0, "max": 4096, "step": 1}), + "ltx_round_mode": (["up", "nearest", "down"], {"default": "up"}), + "surface_profile": (["compact", "progressive", "debug_surface"], {"default": "compact"}), + "backend_binding": (["ltx_audio_guided_lowram", "ltx_audio_guided_normal", "custom"], {"default": "ltx_audio_guided_lowram"}), + }, + "optional": { + "audio": ("AUDIO",), + "upstream_contract": (SUPERNODE_CONTRACT_TYPE,), + "linx": (SUPERNODE_LINX_TYPE,), + }, + "hidden": {"unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = ("STRING", SUPERNODE_CONTRACT_TYPE, SUPERNODE_LINX_TYPE, "INT", "INT", "FLOAT", "FLOAT", "INT", "STRING", "STRING") + RETURN_NAMES = ( + "project_timeline_payload", + "contract", + "linx", + "total_frames", + "segment_count", + "total_seconds", + "segment_seconds_out", + "unique_segment_frames", + "segment_ranges_json", + "report", + ) + FUNCTION = "build" + CATEGORY = HELPER_CATEGORY + + def build( + self, + goal1_preset, + fps, + segment_seconds, + target_duration_seconds, + duration_source, + planning_mode, + content_profile, + overlap_frames, + ltx_round_mode, + surface_profile, + backend_binding, + audio=None, + upstream_contract=None, + linx=None, + unique_id=None, + ): + preset_values = _resolve_goal1_preset(goal1_preset) + segment_seconds = float(preset_values.get("segment_seconds", segment_seconds)) + planning_mode = str(preset_values.get("planning_mode", planning_mode)) + content_profile = str(preset_values.get("content_profile", content_profile)) + overlap_frames = int(preset_values.get("overlap_frames", overlap_frames)) + backend_binding = str(preset_values.get("backend_binding", backend_binding)) + surface_profile = str(preset_values.get("surface_profile", surface_profile)) + + effective_duration = float(target_duration_seconds) + if duration_source == "audio": + effective_duration = _audio_duration_seconds(audio) + elif duration_source == "max_audio_target" and audio is not None: + effective_duration = max(float(target_duration_seconds), _audio_duration_seconds(audio)) + + planner_result = IAMCCS_SegmentPlanner().plan( + effective_duration, + fps, + segment_seconds, + planning_mode, + content_profile, + overlap_frames, + ltx_round_mode, + 0, + ) + + effective_fps = float(planner_result[17]) + total_frames = int(planner_result[0]) + segment_count = int(planner_result[4]) + unique_segment_frames = int(planner_result[1]) + first_segment_raw_frames = int(planner_result[2]) + continuation_raw_frames = int(planner_result[3]) + last_segment_unique_frames = int(planner_result[6]) + segments = _build_project_timeline_segments( + total_frames=total_frames, + segment_count=segment_count, + unique_segment_frames=unique_segment_frames, + first_segment_raw_frames=first_segment_raw_frames, + continuation_raw_frames=continuation_raw_frames, + last_segment_unique_frames=last_segment_unique_frames, + fps=effective_fps, + ) + meter_markers_frames = [int(segment["start_frame"]) for segment in segments] + meter_markers_frames.append(int(total_frames)) + + project_timeline_payload_dict = { + "timeline_family": "project_timeline", + "pipeline_kind": PIPELINE_KIND, + "goal1_preset": str(goal1_preset), + "target_duration_seconds": float(effective_duration), + "duration_source": str(duration_source), + "surface_profile": str(surface_profile), + "backend_binding": str(backend_binding), + "fps": float(effective_fps), + "total_seconds": float(effective_duration), + "total_frames": int(total_frames), + "segment_seconds": float(segment_seconds), + "segment_count": int(segment_count), + "unique_segment_frames": int(unique_segment_frames), + "first_segment_raw_frames": int(first_segment_raw_frames), + "continuation_raw_frames": int(continuation_raw_frames), + "last_segment_unique_frames": int(last_segment_unique_frames), + "recommended_overlap_frames": int(planner_result[18]), + "recommended_audio_left_context_s": float(planner_result[19]), + "recommended_extension_preset": str(planner_result[20]), + "effective_planning_mode": str(planner_result[21]), + "segments": segments, + "meter_markers_frames": meter_markers_frames, + } + project_timeline_payload = _json_payload(project_timeline_payload_dict) + segment_ranges_json = _json_payload({"segments": segments}) + contract = _build_contract( + node_role="project_timeline", + node_label="ProjectTimelinePlanner", + parent_contract=upstream_contract, + payload_key="project_timeline_payload", + payload_value=project_timeline_payload, + extra={"surface_profile": surface_profile, "backend_binding": backend_binding}, + ) + linx_payload = _build_linx( + existing_linx=linx, + contract=contract, + unique_id=unique_id, + node_role="project_timeline", + node_label="ProjectTimelinePlanner", + module_keys=["timeline_family", "segments", "meter_markers_frames"], + ) + report = ( + f"project_timeline duration={effective_duration:.3f}s | total={total_frames}f | " + f"segments={segment_count} | segment_seconds={segment_seconds:.3f}s | fps={effective_fps:.3f}" + ) + return ( + project_timeline_payload, + contract, + linx_payload, + int(total_frames), + int(segment_count), + float(effective_duration), + float(segment_seconds), + int(unique_segment_frames), + segment_ranges_json, + report, + ) + + +# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com +class IAMCCS_Ltx2HelperModules_Planner: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "goal1_preset": (["custom", "lowram_safe_videoclip", "lowram_fast_videoclip", "normal_quality_videoclip", "lowram_monologue"], {"default": "custom"}), + "fps": ("FLOAT", {"default": 24.0, "min": 0.001, "max": 240.0, "step": 0.01}), + "segment_seconds": ("FLOAT", {"default": 10.0, "min": 0.01, "max": 3600.0, "step": 0.01}), + "target_duration_seconds": ("FLOAT", {"default": 30.0, "min": 0.01, "max": 36000.0, "step": 0.01}), + "duration_source": (["target_duration_seconds", "audio", "max_audio_target"], {"default": "target_duration_seconds"}), + "planning_mode": (["manual_segment_seconds", "auto_profile"], {"default": "auto_profile"}), + "content_profile": (["videoclip", "monologue"], {"default": "videoclip"}), + "overlap_frames": ("INT", {"default": 9, "min": 0, "max": 4096, "step": 1}), + "ltx_round_mode": (["up", "nearest", "down"], {"default": "up"}), + "segment_index": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}), + "surface_profile": (["compact", "progressive", "debug_surface"], {"default": "compact"}), + "backend_binding": (["ltx_audio_guided_lowram", "ltx_audio_guided_normal", "custom"], {"default": "ltx_audio_guided_lowram"}), + }, + "optional": { + "audio": ("AUDIO",), + "upstream_contract": (SUPERNODE_CONTRACT_TYPE,), + "linx": (SUPERNODE_LINX_TYPE,), + }, + "hidden": {"unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = ("STRING", SUPERNODE_CONTRACT_TYPE, SUPERNODE_LINX_TYPE, "INT", "INT", "INT", "INT", "INT", "STRING", "INT", "INT", "INT", "INT", "FLOAT") + RETURN_NAMES = ( + "planner_payload", + "contract", + "linx", + "segment_count", + "continuation_loops", + "current_segment_start_frames", + "current_segment_unique_frames", + "current_source_end", + "report", + "total_frames", + "unique_segment_frames", + "first_segment_raw_frames", + "continuation_raw_frames", + "fps_out", + ) + FUNCTION = "build" + CATEGORY = HELPER_CATEGORY + + def build( + self, + goal1_preset, + fps, + segment_seconds, + target_duration_seconds, + duration_source, + planning_mode, + content_profile, + overlap_frames, + ltx_round_mode, + segment_index, + surface_profile, + backend_binding, + audio=None, + upstream_contract=None, + linx=None, + unique_id=None, + ): + preset_values = _resolve_goal1_preset(goal1_preset) + segment_seconds = float(preset_values.get("segment_seconds", segment_seconds)) + planning_mode = str(preset_values.get("planning_mode", planning_mode)) + content_profile = str(preset_values.get("content_profile", content_profile)) + overlap_frames = int(preset_values.get("overlap_frames", overlap_frames)) + backend_binding = str(preset_values.get("backend_binding", backend_binding)) + surface_profile = str(preset_values.get("surface_profile", surface_profile)) + + effective_duration = float(target_duration_seconds) + if duration_source == "audio": + effective_duration = _audio_duration_seconds(audio) + elif duration_source == "max_audio_target" and audio is not None: + effective_duration = max(float(target_duration_seconds), _audio_duration_seconds(audio)) + + planner_result = IAMCCS_SegmentPlanner().plan( + effective_duration, + fps, + segment_seconds, + planning_mode, + content_profile, + overlap_frames, + ltx_round_mode, + segment_index, + ) + source_range = IAMCCS_SourceRangeFromSegmentPlan().derive( + planner_result[8], + planner_result[9], + planner_result[10], + planner_result[11], + ) + planner_payload_dict = { + "pipeline_kind": PIPELINE_KIND, + "goal1_preset": str(goal1_preset), + "target_duration_seconds": float(effective_duration), + "duration_source": str(duration_source), + "surface_profile": str(surface_profile), + "backend_binding": str(backend_binding), + "fps": float(planner_result[17]), + "total_frames": int(planner_result[0]), + "unique_segment_frames": int(planner_result[1]), + "first_segment_raw_frames": int(planner_result[2]), + "continuation_raw_frames": int(planner_result[3]), + "segment_count": int(planner_result[4]), + "continuation_loops": int(planner_result[5]), + "last_segment_unique_frames": int(planner_result[6]), + "segment_index": int(planner_result[8]), + "current_segment_raw_frames": int(planner_result[9]), + "current_segment_unique_frames": int(planner_result[10]), + "current_segment_start_frames": int(planner_result[11]), + "current_segment_end_frames": int(planner_result[12]), + "current_remaining_frames_after": int(planner_result[13]), + "current_segment_start_s": float(planner_result[14]), + "current_segment_end_s": float(planner_result[15]), + "recommended_overlap_frames": int(planner_result[18]), + "recommended_audio_left_context_s": float(planner_result[19]), + "recommended_extension_preset": str(planner_result[20]), + "effective_planning_mode": str(planner_result[21]), + "planning_profile_report": str(planner_result[22]), + "current_source_start": int(source_range[0]), + "current_source_end": int(source_range[1]), + "current_source_count": int(source_range[2]), + } + planner_payload = _json_payload(planner_payload_dict) + contract = _build_contract( + node_role="planner", + node_label=f"{HELPER_LABEL_PREFIX} Planner", + parent_contract=upstream_contract, + payload_key="planner_payload", + payload_value=planner_payload, + extra={"surface_profile": surface_profile, "backend_binding": backend_binding}, + ) + linx_payload = _build_linx( + existing_linx=linx, + contract=contract, + unique_id=unique_id, + node_role="planner", + node_label=f"{HELPER_LABEL_PREFIX} Planner", + ) + report = ( + f"ltx2_helper_modules_planner preset={planner_payload_dict['goal1_preset']} | duration={effective_duration:.3f}s | total={planner_payload_dict['total_frames']}f | " + f"segments={planner_payload_dict['segment_count']} | seg={planner_payload_dict['segment_index']} | " + f"source=[{planner_payload_dict['current_source_start']}..{planner_payload_dict['current_source_end']}) | " + f"preset={planner_payload_dict['recommended_extension_preset']}" + ) + return ( + planner_payload, + contract, + linx_payload, + int(planner_payload_dict["segment_count"]), + int(planner_payload_dict["continuation_loops"]), + int(planner_payload_dict["current_segment_start_frames"]), + int(planner_payload_dict["current_segment_unique_frames"]), + int(planner_payload_dict["current_source_end"]), + report, + int(planner_payload_dict["total_frames"]), + int(planner_payload_dict["unique_segment_frames"]), + int(planner_payload_dict["first_segment_raw_frames"]), + int(planner_payload_dict["continuation_raw_frames"]), + float(planner_payload_dict["fps"]), + ) + + +# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com +class IAMCCS_Ltx2HelperModules_AudioTimeline: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "planner_payload": ("STRING", {"default": "{}", "multiline": True}), + "audio": ("AUDIO",), + "mode": (["left_context_only", "right_context_only", "symmetric_context", "no_overlap"], {"default": "left_context_only"}), + "left_overlap_s": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 30.0, "step": 0.01}), + "right_overlap_s": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 30.0, "step": 0.01}), + "clamp_policy": (["soft_clamp", "strict"], {"default": "soft_clamp"}), + "min_next_frames": ("INT", {"default": 1, "min": 1, "max": 100000, "step": 1}), + }, + "optional": { + "parent_contract": (SUPERNODE_CONTRACT_TYPE,), + "linx": (SUPERNODE_LINX_TYPE,), + }, + "hidden": {"unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = ("STRING", SUPERNODE_CONTRACT_TYPE, SUPERNODE_LINX_TYPE, "AUDIO", "AUDIO", "BOOLEAN", "INT", "INT", "STRING") + RETURN_NAMES = ( + "audio_payload", + "contract", + "linx", + "conditioning_audio", + "segment_audio", + "continue_generation", + "effective_unique_frames", + "trim_frames", + "report", + ) + FUNCTION = "build" + CATEGORY = HELPER_CATEGORY + + def build(self, planner_payload, audio, mode, left_overlap_s, right_overlap_s, clamp_policy, min_next_frames, parent_contract=None, linx=None, unique_id=None): + planner = _parse_payload(planner_payload) + fps = float(planner.get("fps", 24.0)) + segment_index = int(planner.get("segment_index", 0)) + generated_frames = int(planner.get("current_segment_raw_frames", planner.get("continuation_raw_frames", 1))) + extension_frames = int(planner.get("current_segment_unique_frames", planner.get("unique_segment_frames", generated_frames))) + segment_start_frames = int(planner.get("current_segment_start_frames", 0)) + first_pass_unique_frames = int(planner.get("unique_segment_frames", extension_frames)) + segment_duration_s = float(extension_frames) / float(max(fps, 0.001)) + + math_result = IAMCCS_AudioExtensionMath().compute( + audio, + fps, + segment_index, + generated_frames, + extension_frames, + True, + first_pass_unique_frames=first_pass_unique_frames, + cursor_frames_in=segment_start_frames, + ) + extender_result = IAMCCS_AudioExtender().slice_segment( + audio, + fps, + mode, + left_overlap_s, + right_overlap_s, + "use_timeline_cursor", + "snap_to_video_duration", + clamp_policy, + segment_index=segment_index, + segment_duration_s=segment_duration_s, + video_frames=generated_frames, + generated_frames=generated_frames, + extension_frames=extension_frames, + timeline_cursor_frames=segment_start_frames, + segment_start_frames=math_result[1], + effective_unique_frames=math_result[3], + first_pass_unique_frames=first_pass_unique_frames, + ) + gate_result = IAMCCS_AudioTimelineGate().decide( + math_result[7], + math_result[3], + min_next_frames, + True, + is_last_segment=math_result[8], + cursor_frames_out=math_result[0], + ) + + audio_payload_dict = { + "pipeline_kind": PIPELINE_KIND, + "segment_index": segment_index, + "fps": fps, + "cursor_frames_out": int(math_result[0]), + "segment_start_frames": int(math_result[1]), + "segment_end_frames": int(math_result[2]), + "effective_unique_frames": int(math_result[3]), + "trim_frames": int(math_result[4]), + "segment_start_s": float(math_result[5]), + "segment_end_s": float(math_result[6]), + "remaining_frames_after": int(math_result[7]), + "is_last_segment": int(math_result[8]), + "conditioning_duration_s": float(extender_result[3]), + "segment_duration_s": float(extender_result[2]), + "segment_start_sample": int(extender_result[4]), + "segment_end_sample": int(extender_result[5]), + "conditioning_start_sample": int(extender_result[6]), + "conditioning_end_sample": int(extender_result[7]), + "continue_generation": bool(gate_result[0]), + "gate_reason": str(gate_result[4]), + } + audio_payload = _json_payload(audio_payload_dict) + contract = _build_contract( + node_role="audio", + node_label=f"{HELPER_LABEL_PREFIX} AudioTimeline", + parent_contract=parent_contract, + payload_key="audio_payload", + payload_value=audio_payload, + ) + linx_payload = _build_linx( + existing_linx=linx, + contract=contract, + unique_id=unique_id, + node_role="audio", + node_label=f"{HELPER_LABEL_PREFIX} AudioTimeline", + ) + report = ( + f"ltx2_helper_modules_audio seg={segment_index} | effective={audio_payload_dict['effective_unique_frames']}f | " + f"trim={audio_payload_dict['trim_frames']}f | continue={'yes' if audio_payload_dict['continue_generation'] else 'no'} | " + f"gate={audio_payload_dict['gate_reason']}" + ) + return ( + audio_payload, + contract, + linx_payload, + extender_result[0], + extender_result[1], + bool(audio_payload_dict["continue_generation"]), + int(audio_payload_dict["effective_unique_frames"]), + int(audio_payload_dict["trim_frames"]), + report, + ) + + +# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com +class IAMCCS_Ltx2HelperModules_KeyframeTimeline: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "planner_payload": ("STRING", {"default": "{}", "multiline": True}), + "keyframe_positions_mode": (["global_frames", "global_seconds", "global_ratio", "none"], {"default": "none"}), + "keyframe_positions": ("STRING", {"default": "", "multiline": True}), + "refresh_mode_policy": (["auto", "anchor_only", "tail_refresh", "flf_blend", "disabled"], {"default": "auto"}), + "protected_head_frames": ("INT", {"default": 9, "min": 0, "max": 256, "step": 1}), + "settling_frames_default": ("INT", {"default": 9, "min": 0, "max": 256, "step": 1}), + "min_keyframe_spacing_frames": ("INT", {"default": 8, "min": 0, "max": 512, "step": 1}), + "refresh_interval_segments": ("INT", {"default": 2, "min": 0, "max": 256, "step": 1}), + "reanchor_every_segments": ("INT", {"default": 3, "min": 0, "max": 256, "step": 1}), + "prefer_tail_refresh": ("BOOLEAN", {"default": True}), + }, + "optional": { + "parent_contract": (SUPERNODE_CONTRACT_TYPE,), + "linx": (SUPERNODE_LINX_TYPE,), + }, + "hidden": {"unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = ("STRING", SUPERNODE_CONTRACT_TYPE, SUPERNODE_LINX_TYPE, "BOOLEAN", "BOOLEAN", "STRING", "INT", "INT", "STRING") + RETURN_NAMES = ( + "keyframe_payload", + "contract", + "linx", + "has_refresh", + "is_reanchor_segment", + "recommended_refresh_mode", + "protected_head_frames_out", + "preferred_tail_start", + "report", + ) + FUNCTION = "build" + CATEGORY = HELPER_CATEGORY + + def build( + self, + planner_payload, + keyframe_positions_mode, + keyframe_positions, + refresh_mode_policy, + protected_head_frames, + settling_frames_default, + min_keyframe_spacing_frames, + refresh_interval_segments, + reanchor_every_segments, + prefer_tail_refresh, + parent_contract=None, + linx=None, + unique_id=None, + ): + planner = _parse_payload(planner_payload) + goal1_preset = str(planner.get("goal1_preset", "custom")) + defaults = _resolve_goal1_refresh_defaults(goal1_preset) + fps = float(planner.get("fps", 24.0)) + total_frames = int(planner.get("total_frames", 0)) + segment_index = int(planner.get("segment_index", 0)) + segment_start_frame = int(planner.get("current_segment_start_frames", 0)) + segment_end_frame = int(planner.get("current_segment_end_frames", segment_start_frame)) + unique_frames = int(planner.get("current_segment_unique_frames", planner.get("unique_segment_frames", 0))) + + effective_protected_head = int(defaults.get("protected_head_frames", protected_head_frames)) + effective_settling = int(defaults.get("settling_frames", settling_frames_default)) + effective_refresh_interval = int(defaults.get("refresh_interval_segments", refresh_interval_segments)) + effective_reanchor_every = int(defaults.get("reanchor_every_segments", reanchor_every_segments)) + + global_positions = _parse_numeric_list(keyframe_positions) + converted_frames: list[int] = [] + for value in global_positions: + if keyframe_positions_mode == "global_seconds": + converted_frames.append(int(round(value * fps))) + elif keyframe_positions_mode == "global_ratio": + converted_frames.append(int(round(value * total_frames))) + elif keyframe_positions_mode == "global_frames": + converted_frames.append(int(round(value))) + + local_refresh_frames: list[int] = [] + for frame in sorted(converted_frames): + local_frame = int(frame) - segment_start_frame + if local_frame < effective_protected_head: + continue + if local_frame >= unique_frames: + continue + if local_refresh_frames and (local_frame - local_refresh_frames[-1]) < int(min_keyframe_spacing_frames): + continue + local_refresh_frames.append(local_frame) + + periodic_refresh = bool(effective_refresh_interval > 0 and segment_index > 0 and (segment_index % effective_refresh_interval) == 0) + is_reanchor_segment = bool(effective_reanchor_every > 0 and segment_index > 0 and (segment_index % effective_reanchor_every) == 0) + preferred_tail_start = max(effective_protected_head, unique_frames - max(effective_settling, 1)) + if periodic_refresh and preferred_tail_start not in local_refresh_frames: + local_refresh_frames.append(preferred_tail_start) + local_refresh_frames.sort() + + has_refresh = bool(local_refresh_frames or periodic_refresh or is_reanchor_segment) + recommended_refresh_mode = str(refresh_mode_policy) + if recommended_refresh_mode == "auto": + if is_reanchor_segment: + recommended_refresh_mode = "flf_blend" + elif has_refresh and bool(prefer_tail_refresh): + recommended_refresh_mode = "tail_refresh" + elif has_refresh: + recommended_refresh_mode = "anchor_only" + else: + recommended_refresh_mode = "disabled" + + keyframe_payload_dict = { + "pipeline_kind": PIPELINE_KIND, + "feature_family": "keyframe_refresh", + "goal1_preset": goal1_preset, + "segment_index": segment_index, + "segment_start_frame": segment_start_frame, + "segment_end_frame": segment_end_frame, + "segment_role": "reanchor" if is_reanchor_segment else "continuity", + "keyframe_positions_mode": str(keyframe_positions_mode), + "local_refresh_frames": list(local_refresh_frames), + "has_refresh": bool(has_refresh), + "periodic_refresh": bool(periodic_refresh), + "is_reanchor_segment": bool(is_reanchor_segment), + "recommended_refresh_mode": recommended_refresh_mode, + "protected_head_frames": int(effective_protected_head), + "settling_frames": int(effective_settling), + "trim_advisory": int(effective_settling if has_refresh else 0), + "preferred_tail_start": int(preferred_tail_start), + "continuity_preset_override": str(defaults.get("continuity_preset", planner.get("recommended_extension_preset", "custom"))), + "reanchor_preset_override": str(defaults.get("reanchor_preset", "micro_crossfade_3")), + "reanchor_overlap_mode_override": str(defaults.get("reanchor_overlap_mode", "filmic_crossfade")), + "reanchor_safe_mode_override": str(defaults.get("reanchor_safe_mode", "native_workflow_safe")), + "reanchor_start_frames_rule_override": str(defaults.get("reanchor_start_frames_rule", "ltx2_nearest")), + } + keyframe_payload = _json_payload(keyframe_payload_dict) + contract = _build_contract( + node_role="keyframe_timeline", + node_label=f"{HELPER_LABEL_PREFIX} KeyframeTimeline", + parent_contract=parent_contract, + payload_key="keyframe_payload", + payload_value=keyframe_payload, + ) + linx_payload = _build_linx( + existing_linx=linx, + contract=contract, + unique_id=unique_id, + node_role="keyframe_timeline", + node_label=f"{HELPER_LABEL_PREFIX} KeyframeTimeline", + ) + report = ( + f"ltx2_helper_modules_keyframe seg={segment_index} | role={keyframe_payload_dict['segment_role']} | " + f"refresh={recommended_refresh_mode} | local={keyframe_payload_dict['local_refresh_frames']}" + ) + return ( + keyframe_payload, + contract, + linx_payload, + bool(has_refresh), + bool(is_reanchor_segment), + recommended_refresh_mode, + int(effective_protected_head), + int(preferred_tail_start), + report, + ) + + +# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com +class IAMCCS_Ltx2HelperModules_RefreshPolicy: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "planner_payload": ("STRING", {"default": "{}", "multiline": True}), + "keyframe_payload": ("STRING", {"default": "{}", "multiline": True}), + "segment_policy": (["planner_driven", "continuity_only", "reanchor_mix", "reanchor_only"], {"default": "planner_driven"}), + "continuity_preset": (["custom", "target_extension_ltx2", "videoclip_audio_24fps", "monologue_audio_24fps", "cut_bestofk_16", "cut_bestofk_16_luma", "cut_bestofk_32", "micro_crossfade_3"], {"default": "videoclip_audio_24fps"}), + "reanchor_preset": (["custom", "target_extension_ltx2", "videoclip_audio_24fps", "monologue_audio_24fps", "cut_bestofk_16", "cut_bestofk_16_luma", "cut_bestofk_32", "micro_crossfade_3"], {"default": "micro_crossfade_3"}), + "first_anchor_strength": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01}), + "last_anchor_strength": ("FLOAT", {"default": 0.20, "min": 0.0, "max": 1.0, "step": 0.01}), + "tail_refresh_strength": ("FLOAT", {"default": 0.30, "min": 0.0, "max": 1.0, "step": 0.01}), + "reanchor_overlap_mode": (["cut", "linear_blend", "ease_in_out", "filmic_crossfade", "perceptual_crossfade"], {"default": "filmic_crossfade"}), + }, + "optional": { + "parent_contract": (SUPERNODE_CONTRACT_TYPE,), + "linx": (SUPERNODE_LINX_TYPE,), + }, + "hidden": {"unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = ("STRING", SUPERNODE_CONTRACT_TYPE, SUPERNODE_LINX_TYPE, "BOOLEAN", "STRING", "STRING", "FLOAT", "FLOAT", "INT", "INT", "INT", "STRING") + RETURN_NAMES = ( + "refresh_payload", + "contract", + "linx", + "apply_refresh", + "segment_role", + "continuity_preset_override", + "first_anchor_strength_effective", + "last_anchor_strength_effective", + "first_lock_slots", + "last_lock_slots", + "end_transition_slots", + "report", + ) + FUNCTION = "build" + CATEGORY = HELPER_CATEGORY + + def build( + self, + planner_payload, + keyframe_payload, + segment_policy, + continuity_preset, + reanchor_preset, + first_anchor_strength, + last_anchor_strength, + tail_refresh_strength, + reanchor_overlap_mode, + parent_contract=None, + linx=None, + unique_id=None, + ): + planner = _parse_payload(planner_payload) + keyframe = _parse_payload(keyframe_payload) + goal1_preset = str(planner.get("goal1_preset", "custom")) + defaults = _resolve_goal1_refresh_defaults(goal1_preset) + + planner_reanchor = bool(keyframe.get("is_reanchor_segment", False)) + if segment_policy == "reanchor_only": + segment_role = "reanchor" + elif segment_policy == "continuity_only": + segment_role = "continuity" + else: + segment_role = "reanchor" if planner_reanchor else "continuity" + + apply_refresh = bool(segment_role == "reanchor" or keyframe.get("has_refresh", False)) + continuity_preset_override = str(defaults.get("continuity_preset", continuity_preset)) + reanchor_preset_override = str(defaults.get("reanchor_preset", reanchor_preset)) + effective_preset_override = reanchor_preset_override if segment_role == "reanchor" else continuity_preset_override + first_anchor_strength_effective = float(defaults.get("first_anchor_strength", first_anchor_strength)) if segment_role == "reanchor" else 0.0 + last_anchor_strength_effective = float(defaults.get("last_anchor_strength", last_anchor_strength)) if segment_role == "reanchor" else 0.0 + first_lock_slots_effective = int(defaults.get("first_lock_slots", 1)) if segment_role == "reanchor" else 0 + last_lock_slots_effective = int(defaults.get("last_lock_slots", 1)) if segment_role == "reanchor" else 0 + end_transition_slots_effective = int(defaults.get("end_transition_slots", 0)) if segment_role == "reanchor" else 0 + + refresh_payload_dict = { + "pipeline_kind": PIPELINE_KIND, + "feature_family": "keyframe_refresh", + "goal1_preset": goal1_preset, + "segment_index": int(planner.get("segment_index", 0)), + "segment_role": segment_role, + "segment_policy": str(segment_policy), + "apply_refresh": bool(apply_refresh), + "recommended_refresh_mode": str(keyframe.get("recommended_refresh_mode", "disabled")), + "continuity_preset_override": continuity_preset_override, + "reanchor_preset_override": reanchor_preset_override, + "continuity_preset_effective": effective_preset_override, + "reanchor_overlap_mode_override": str(defaults.get("reanchor_overlap_mode", reanchor_overlap_mode)), + "reanchor_safe_mode_override": str(defaults.get("reanchor_safe_mode", "native_workflow_safe")), + "reanchor_start_frames_rule_override": str(defaults.get("reanchor_start_frames_rule", "ltx2_nearest")), + "protected_head_frames": int(keyframe.get("protected_head_frames", defaults.get("protected_head_frames", 9))), + "preferred_tail_start": int(keyframe.get("preferred_tail_start", 0)), + "first_anchor_strength": float(first_anchor_strength), + "last_anchor_strength": float(last_anchor_strength), + "tail_refresh_strength": float(tail_refresh_strength), + "first_anchor_strength_effective": float(first_anchor_strength_effective), + "last_anchor_strength_effective": float(last_anchor_strength_effective), + "first_lock_slots": int(first_lock_slots_effective), + "last_lock_slots": int(last_lock_slots_effective), + "end_transition_slots": int(end_transition_slots_effective), + "hide_historical_preset_nodes": True, + } + refresh_payload = _json_payload(refresh_payload_dict) + contract = _build_contract( + node_role="refresh_policy", + node_label=f"{HELPER_LABEL_PREFIX} RefreshPolicy", + parent_contract=parent_contract, + payload_key="refresh_payload", + payload_value=refresh_payload, + ) + linx_payload = _build_linx( + existing_linx=linx, + contract=contract, + unique_id=unique_id, + node_role="refresh_policy", + node_label=f"{HELPER_LABEL_PREFIX} RefreshPolicy", + ) + report = ( + f"ltx2_helper_modules_refresh seg={refresh_payload_dict['segment_index']} | role={segment_role} | " + f"apply={'yes' if apply_refresh else 'no'} | preset={effective_preset_override}" + ) + return ( + refresh_payload, + contract, + linx_payload, + bool(apply_refresh), + segment_role, + effective_preset_override, + float(first_anchor_strength_effective), + float(last_anchor_strength_effective), + int(first_lock_slots_effective), + int(last_lock_slots_effective), + int(end_transition_slots_effective), + report, + ) + + +# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com +class IAMCCS_Ltx2HelperModules_ReanchorLatent: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "vae": ("VAE",), + "latent": ("LATENT",), + "refresh_payload": ("STRING", {"default": "{}", "multiline": True}), + }, + "optional": { + "first_anchor_image": ("IMAGE",), + "last_anchor_image": ("IMAGE",), + "parent_contract": (SUPERNODE_CONTRACT_TYPE,), + "linx": (SUPERNODE_LINX_TYPE,), + }, + "hidden": {"unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = ("LATENT", "STRING", SUPERNODE_CONTRACT_TYPE, SUPERNODE_LINX_TYPE, "STRING") + RETURN_NAMES = ("latent", "reanchor_payload", "contract", "linx", "report") + FUNCTION = "build" + CATEGORY = HELPER_CATEGORY + + def build(self, vae, latent, refresh_payload, first_anchor_image=None, last_anchor_image=None, parent_contract=None, linx=None, unique_id=None): + refresh = _parse_payload(refresh_payload) + segment_role = str(refresh.get("segment_role", "continuity")) + first_strength = float(refresh.get("first_anchor_strength_effective", 0.0)) + last_strength = float(refresh.get("last_anchor_strength_effective", 0.0)) + first_lock_slots = int(refresh.get("first_lock_slots", 0)) + last_lock_slots = int(refresh.get("last_lock_slots", 0)) + end_transition_slots = int(refresh.get("end_transition_slots", 0)) + + if segment_role != "reanchor" or (first_strength <= 0.0 and last_strength <= 0.0): + out_latent = latent + reanchor_payload_dict = { + "pipeline_kind": PIPELINE_KIND, + "segment_role": segment_role, + "reanchor_applied": False, + } + report = "ltx2_helper_modules_reanchor latent passthrough" + else: + out_latent, inner_report = IAMCCS_LTX2_FirstLastLatentControl_Pro().execute( + vae, + latent, + first_strength, + last_strength, + first_lock_slots, + last_lock_slots, + end_transition_slots, + first_image=first_anchor_image, + last_image=last_anchor_image, + middle_frames=None, + ) + reanchor_payload_dict = { + "pipeline_kind": PIPELINE_KIND, + "segment_role": segment_role, + "reanchor_applied": True, + "first_anchor_strength_effective": float(first_strength), + "last_anchor_strength_effective": float(last_strength), + "first_lock_slots": int(first_lock_slots), + "last_lock_slots": int(last_lock_slots), + } + report = f"ltx2_helper_modules_reanchor {inner_report}" + + reanchor_payload = _json_payload(reanchor_payload_dict) + contract = _build_contract( + node_role="reanchor_latent", + node_label=f"{HELPER_LABEL_PREFIX} ReanchorLatent", + parent_contract=parent_contract, + payload_key="reanchor_payload", + payload_value=reanchor_payload, + ) + linx_payload = _build_linx( + existing_linx=linx, + contract=contract, + unique_id=unique_id, + node_role="reanchor_latent", + node_label=f"{HELPER_LABEL_PREFIX} ReanchorLatent", + ) + return (out_latent, reanchor_payload, contract, linx_payload, report) + + +# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com +class IAMCCS_Ltx2HelperModules_DiskExtension: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "source_dir": ("STRING", {"default": "iamccs_extension_disk/loop_seg0_extended"}), + "new_dir": ("STRING", {"default": "iamccs_vae_frames/loop_audio_sync/current"}), + "planner_payload": ("STRING", {"default": "{}", "multiline": True}), + "refresh_payload": ("STRING", {"default": "{}", "multiline": True}), + "output_dir": ("STRING", {"default": "iamccs_extension_disk/loop_extended"}), + "start_dir": ("STRING", {"default": "iamccs_extension_disk/loop_start"}), + }, + "optional": { + "parent_contract": (SUPERNODE_CONTRACT_TYPE,), + "linx": (SUPERNODE_LINX_TYPE,), + }, + "hidden": {"unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = ("STRING", "STRING", "INT", "INT", "INT", "STRING", SUPERNODE_CONTRACT_TYPE, SUPERNODE_LINX_TYPE, "STRING") + RETURN_NAMES = ( + "extended_dir", + "start_dir_out", + "overlap_frames", + "calculated_frames", + "extension_frames", + "disk_payload", + "contract", + "linx", + "report", + ) + FUNCTION = "build" + CATEGORY = HELPER_CATEGORY + + def build(self, source_dir, new_dir, planner_payload, refresh_payload, output_dir, start_dir, parent_contract=None, linx=None, unique_id=None): + planner = _parse_payload(planner_payload) + refresh = _parse_payload(refresh_payload) + segment_role = str(refresh.get("segment_role", "continuity")) + preset_override = str(refresh.get("continuity_preset_effective", planner.get("recommended_extension_preset", "custom"))) + overlap_mode = str(refresh.get("reanchor_overlap_mode_override", "cut")) if segment_role == "reanchor" else "cut" + safe_mode = str(refresh.get("reanchor_safe_mode_override", "none")) if segment_role == "reanchor" else "none" + start_frames_rule = str(refresh.get("reanchor_start_frames_rule_override", "none")) if segment_role == "reanchor" else "none" + overlap_frames = int(planner.get("recommended_overlap_frames", 9)) + + extended_dir, start_dir_out, effective_overlap, calculated_frames, extension_frames, inner_report = IAMCCS_LTX2_ExtensionModule_Disk().process_extension_disk( + source_dir, + output_dir, + start_dir, + overlap_frames, + "source", + overlap_mode, + True, + "none", + safe_mode, + start_frames_rule, + preset=preset_override, + new_dir=new_dir, + math_value_b=1, + ) + disk_payload_dict = { + "pipeline_kind": PIPELINE_KIND, + "segment_role": segment_role, + "preset_override": preset_override, + "extended_dir": str(extended_dir), + "start_dir": str(start_dir_out), + "overlap_frames": int(effective_overlap), + "calculated_frames": int(calculated_frames), + "extension_frames": int(extension_frames), + } + disk_payload = _json_payload(disk_payload_dict) + contract = _build_contract( + node_role="disk_extension", + node_label=f"{HELPER_LABEL_PREFIX} DiskExtension", + parent_contract=parent_contract, + payload_key="disk_payload", + payload_value=disk_payload, + ) + linx_payload = _build_linx( + existing_linx=linx, + contract=contract, + unique_id=unique_id, + node_role="disk_extension", + node_label=f"{HELPER_LABEL_PREFIX} DiskExtension", + ) + report = f"ltx2_helper_modules_disk role={segment_role} | preset={preset_override} | {inner_report}" + return ( + str(extended_dir), + str(start_dir_out), + int(effective_overlap), + int(calculated_frames), + int(extension_frames), + disk_payload, + contract, + linx_payload, + report, + ) + + +# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com +class IAMCCS_Ltx2HelperModules_RuntimeBridge: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "planner_payload": ("STRING", {"default": "{}", "multiline": True}), + "audio_payload": ("STRING", {"default": "{}", "multiline": True}), + "runtime_profile": (["native_canvas", "disk_low_ram", "image_batch"], {"default": "native_canvas"}), + "model_binding": (["external_canvas", "ltx_runtime", "custom"], {"default": "external_canvas"}), + "prompt_summary": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "parent_contract": (SUPERNODE_CONTRACT_TYPE,), + "linx": (SUPERNODE_LINX_TYPE,), + "continuity_payload": ("STRING", {"default": "{}", "multiline": True}), + "keyframe_payload": ("STRING", {"default": "{}", "multiline": True}), + "refresh_payload": ("STRING", {"default": "{}", "multiline": True}), + }, + "hidden": {"unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = ("STRING", SUPERNODE_CONTRACT_TYPE, SUPERNODE_LINX_TYPE, "STRING") + RETURN_NAMES = ("backend_payload", "contract", "linx", "report") + FUNCTION = "build" + CATEGORY = HELPER_CATEGORY + + def build(self, planner_payload, audio_payload, runtime_profile, model_binding, prompt_summary, parent_contract=None, linx=None, continuity_payload="{}", keyframe_payload="{}", refresh_payload="{}", unique_id=None): + planner = _parse_payload(planner_payload) + audio = _parse_payload(audio_payload) + continuity = _parse_payload(continuity_payload) + keyframe = _parse_payload(keyframe_payload) + refresh = _parse_payload(refresh_payload) + goal1_preset = str(planner.get("goal1_preset", "custom")) + effective_backend_binding = str(planner.get("backend_binding", model_binding)) + effective_runtime_profile = _resolve_goal1_runtime_profile(goal1_preset, effective_backend_binding, runtime_profile) + backend_payload_dict = { + "pipeline_kind": PIPELINE_KIND, + "goal1_preset": goal1_preset, + "runtime_profile": effective_runtime_profile, + "model_binding": effective_backend_binding, + "segment_index": int(planner.get("segment_index", 0)), + "segment_count": int(planner.get("segment_count", 1)), + "recommended_extension_preset": str(planner.get("recommended_extension_preset", "custom")), + "effective_unique_frames": int(audio.get("effective_unique_frames", 0)), + "continue_generation": bool(audio.get("continue_generation", False)), + "has_continuity_payload": bool(continuity), + "has_keyframe_payload": bool(keyframe), + "has_refresh_payload": bool(refresh), + "segment_role": str(refresh.get("segment_role", keyframe.get("segment_role", "continuity"))), + "recommended_refresh_mode": str(keyframe.get("recommended_refresh_mode", "disabled")), + "continuity_preset_effective": str(refresh.get("continuity_preset_effective", planner.get("recommended_extension_preset", "custom"))), + "prompt_summary": str(prompt_summary or ""), + } + backend_payload = _json_payload(backend_payload_dict) + contract = _build_contract( + node_role="backend", + node_label=f"{HELPER_LABEL_PREFIX} RuntimeBridge", + parent_contract=parent_contract, + payload_key="backend_payload", + payload_value=backend_payload, + ) + linx_payload = _build_linx( + existing_linx=linx, + contract=contract, + unique_id=unique_id, + node_role="backend", + node_label=f"{HELPER_LABEL_PREFIX} RuntimeBridge", + ) + report = ( + f"ltx2_helper_modules_runtime goal1={goal1_preset} | seg={backend_payload_dict['segment_index']}/{backend_payload_dict['segment_count']} | " + f"runtime={effective_runtime_profile} | role={backend_payload_dict['segment_role']} | model_binding={effective_backend_binding} | continue={'yes' if backend_payload_dict['continue_generation'] else 'no'}" + ) + return (backend_payload, contract, linx_payload, report) + + +# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com +class IAMCCS_Ltx2HelperModules_Continuity: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "source_images": ("IMAGE",), + "overlap_frames": ("INT", {"default": 9, "min": 1, "max": 256, "step": 1}), + "overlap_side": (["source", "new_images"], {"default": "source"}), + "overlap_mode": (["cut", "linear_blend", "ease_in_out", "filmic_crossfade", "perceptual_crossfade"], {"default": "cut"}), + "enable_math": ("BOOLEAN", {"default": True}), + "math_operation": (["none", "a-b", "a-1", "a+b", "a*b", "a/b", "min(a,b)", "max(a,b)"], {"default": "a-b"}), + "safe_mode": (["none", "native_workflow_safe"], {"default": "none"}), + "start_frames_rule": (["none", "ltx2_round_down", "ltx2_nearest"], {"default": "none"}), + "preset": (["custom", "target_extension_ltx2", "videoclip_audio_24fps", "monologue_audio_24fps", "cut_bestofk_16", "cut_bestofk_16_luma", "cut_bestofk_32", "micro_crossfade_3"], {"default": "custom"}), + "use_planner_preset": ("BOOLEAN", {"default": True}), + }, + "optional": { + "new_images": ("IMAGE",), + "planner_payload": ("STRING", {"default": "{}", "multiline": True}), + "refresh_payload": ("STRING", {"default": "{}", "multiline": True}), + "parent_contract": (SUPERNODE_CONTRACT_TYPE,), + "linx": (SUPERNODE_LINX_TYPE,), + "math_value_b": ("INT", {"default": 1, "min": 0, "max": 256, "step": 1}), + }, + "hidden": {"unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = ("STRING", SUPERNODE_CONTRACT_TYPE, SUPERNODE_LINX_TYPE, "IMAGE", "IMAGE", "IMAGE", "INT", "INT", "INT", "STRING") + RETURN_NAMES = ( + "continuity_payload", + "contract", + "linx", + "source_images_passthrough", + "start_images", + "extended_images", + "overlap_frames_out", + "calculated_frames", + "extension_frames", + "report", + ) + FUNCTION = "build" + CATEGORY = HELPER_CATEGORY + + def build( + self, + source_images, + overlap_frames, + overlap_side, + overlap_mode, + enable_math, + math_operation, + safe_mode, + start_frames_rule, + preset, + use_planner_preset, + new_images=None, + planner_payload="{}", + refresh_payload="{}", + parent_contract=None, + linx=None, + math_value_b=1, + unique_id=None, + ): + planner = _parse_payload(planner_payload) + refresh = _parse_payload(refresh_payload) + goal1_preset = str(planner.get("goal1_preset", "custom")) + effective_preset = str(planner.get("recommended_extension_preset", preset)) if bool(use_planner_preset) else str(preset) + if refresh.get("continuity_preset_effective"): + effective_preset = str(refresh.get("continuity_preset_effective")) + effective_overlap_frames = int(planner.get("recommended_overlap_frames", overlap_frames)) if bool(use_planner_preset) else int(overlap_frames) + effective_overlap_mode = str(refresh.get("reanchor_overlap_mode_override", overlap_mode)) if str(refresh.get("segment_role", "continuity")) == "reanchor" else str(overlap_mode) + effective_safe_mode = str(refresh.get("reanchor_safe_mode_override", safe_mode)) if str(refresh.get("segment_role", "continuity")) == "reanchor" else str(safe_mode) + effective_start_frames_rule = str(refresh.get("reanchor_start_frames_rule_override", start_frames_rule)) if str(refresh.get("segment_role", "continuity")) == "reanchor" else str(start_frames_rule) + result = IAMCCS_LTX2_ExtensionModule().process_extension( + source_images, + effective_overlap_frames, + overlap_side, + effective_overlap_mode, + enable_math, + math_operation, + effective_safe_mode, + effective_start_frames_rule, + "none", + 1.0, + 8, + "none", + 0, + 1.0, + 0.5, + effective_preset, + new_images=new_images, + math_value_b=math_value_b, + ) + continuity_payload_dict = { + "pipeline_kind": PIPELINE_KIND, + "goal1_preset": goal1_preset, + "segment_index": int(planner.get("segment_index", 0)), + "segment_role": str(refresh.get("segment_role", "continuity")), + "preset": effective_preset, + "overlap_frames": int(result[3]), + "calculated_frames": int(result[4]), + "extension_frames": int(result[5]), + "has_new_images": new_images is not None, + } + continuity_payload = _json_payload(continuity_payload_dict) + contract = _build_contract( + node_role="continuity", + node_label=f"{HELPER_LABEL_PREFIX} Continuity", + parent_contract=parent_contract, + payload_key="continuity_payload", + payload_value=continuity_payload, + ) + linx_payload = _build_linx( + existing_linx=linx, + contract=contract, + unique_id=unique_id, + node_role="continuity", + node_label=f"{HELPER_LABEL_PREFIX} Continuity", + ) + report = ( + f"ltx2_helper_modules_continuity goal1={goal1_preset} | seg={continuity_payload_dict['segment_index']} | role={continuity_payload_dict['segment_role']} | preset={effective_preset} | " + f"overlap={continuity_payload_dict['overlap_frames']} | extension={continuity_payload_dict['extension_frames']}" + ) + return ( + continuity_payload, + contract, + linx_payload, + result[0], + result[1], + result[2], + int(result[3]), + int(result[4]), + int(result[5]), + report, + ) + + +# By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com +class IAMCCS_Ltx2HelperModules_Finalize: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "frames_dir": ("STRING", {"default": "iamccs_extension_disk/final_extended"}), + "frame_rate": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 240.0, "step": 0.01}), + "filename_prefix": ("STRING", {"default": "IAMCCS/Ltx2HelperModules"}), + "crf": ("INT", {"default": 19, "min": 0, "max": 51, "step": 1}), + "pix_fmt": (["yuv420p", "yuv444p"], {"default": "yuv420p"}), + "trim_to_audio": ("BOOLEAN", {"default": True}), + "use_planner_fps": ("BOOLEAN", {"default": True}), + }, + "optional": { + "audio": ("AUDIO",), + "planner_payload": ("STRING", {"default": "{}", "multiline": True}), + "parent_contract": (SUPERNODE_CONTRACT_TYPE,), + "linx": (SUPERNODE_LINX_TYPE,), + }, + "hidden": {"unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = ("STRING", SUPERNODE_CONTRACT_TYPE, SUPERNODE_LINX_TYPE, "STRING", "STRING") + RETURN_NAMES = ("finalize_payload", "contract", "linx", "video_path", "report") + FUNCTION = "build" + CATEGORY = HELPER_CATEGORY + OUTPUT_NODE = True + + def build(self, frames_dir, frame_rate, filename_prefix, crf, pix_fmt, trim_to_audio, use_planner_fps, audio=None, planner_payload="{}", parent_contract=None, linx=None, unique_id=None): + planner = _parse_payload(planner_payload) + goal1_preset = str(planner.get("goal1_preset", "custom")) + effective_fps = float(planner.get("fps", frame_rate)) if bool(use_planner_fps) else float(frame_rate) + effective_filename_prefix = _resolve_goal1_filename_prefix(goal1_preset, filename_prefix) + video_path, combine_report = IAMCCS_VideoCombineFromDir().combine( + frames_dir, + effective_fps, + effective_filename_prefix, + crf, + pix_fmt, + trim_to_audio, + audio=audio, + ) + finalize_payload_dict = { + "pipeline_kind": PIPELINE_KIND, + "goal1_preset": goal1_preset, + "frames_dir": str(frames_dir), + "frame_rate": float(effective_fps), + "filename_prefix": str(effective_filename_prefix), + "video_path": str(video_path), + "trim_to_audio": bool(trim_to_audio), + } + finalize_payload = _json_payload(finalize_payload_dict) + contract = _build_contract( + node_role="output", + node_label=f"{HELPER_LABEL_PREFIX} Finalize", + parent_contract=parent_contract, + payload_key="finalize_payload", + payload_value=finalize_payload, + ) + linx_payload = _build_linx( + existing_linx=linx, + contract=contract, + unique_id=unique_id, + node_role="output", + node_label=f"{HELPER_LABEL_PREFIX} Finalize", + ) + report = f"ltx2_helper_modules_finalize goal1={goal1_preset} | fps={effective_fps:.3f} | path={video_path} | {combine_report}" + return (finalize_payload, contract, linx_payload, video_path, report) + + +NODE_CLASS_MAPPINGS = { + "IAMCCS_ProjectTimelinePlanner": IAMCCS_ProjectTimelinePlanner, + "IAMCCS_Ltx2HelperModules_ProjectTimelinePlanner": IAMCCS_ProjectTimelinePlanner, + "IAMCCS_Ltx2HelperModules_Planner": IAMCCS_Ltx2HelperModules_Planner, + "IAMCCS_Ltx2HelperModules_AudioTimeline": IAMCCS_Ltx2HelperModules_AudioTimeline, + "IAMCCS_Ltx2HelperModules_KeyframeTimeline": IAMCCS_Ltx2HelperModules_KeyframeTimeline, + "IAMCCS_Ltx2HelperModules_RefreshPolicy": IAMCCS_Ltx2HelperModules_RefreshPolicy, + "IAMCCS_Ltx2HelperModules_ReanchorLatent": IAMCCS_Ltx2HelperModules_ReanchorLatent, + "IAMCCS_Ltx2HelperModules_DiskExtension": IAMCCS_Ltx2HelperModules_DiskExtension, + "IAMCCS_Ltx2HelperModules_RuntimeBridge": IAMCCS_Ltx2HelperModules_RuntimeBridge, + "IAMCCS_Ltx2HelperModules_Continuity": IAMCCS_Ltx2HelperModules_Continuity, + "IAMCCS_Ltx2HelperModules_Finalize": IAMCCS_Ltx2HelperModules_Finalize, +} + + +NODE_DISPLAY_NAME_MAPPINGS = { + "IAMCCS_ProjectTimelinePlanner": "IAMCCS Project Timeline Planner", + "IAMCCS_Ltx2HelperModules_ProjectTimelinePlanner": "IAMCCS_Ltx2HelperModules Project Timeline Planner", + "IAMCCS_Ltx2HelperModules_Planner": "IAMCCS_Ltx2HelperModules Planner", + "IAMCCS_Ltx2HelperModules_AudioTimeline": "IAMCCS_Ltx2HelperModules Audio Timeline", + "IAMCCS_Ltx2HelperModules_KeyframeTimeline": "IAMCCS_Ltx2HelperModules Keyframe Timeline", + "IAMCCS_Ltx2HelperModules_RefreshPolicy": "IAMCCS_Ltx2HelperModules Refresh Policy", + "IAMCCS_Ltx2HelperModules_ReanchorLatent": "IAMCCS_Ltx2HelperModules Reanchor Latent", + "IAMCCS_Ltx2HelperModules_DiskExtension": "IAMCCS_Ltx2HelperModules Disk Extension", + "IAMCCS_Ltx2HelperModules_RuntimeBridge": "IAMCCS_Ltx2HelperModules Runtime Bridge", + "IAMCCS_Ltx2HelperModules_Continuity": "IAMCCS_Ltx2HelperModules Continuity", + "IAMCCS_Ltx2HelperModules_Finalize": "IAMCCS_Ltx2HelperModules Finalize", +} + + +IAMCCS_Ltx2HelperModules_ProjectTimelinePlanner = IAMCCS_ProjectTimelinePlanner +IAMCCS_AUIMG2VID_Planner = IAMCCS_Ltx2HelperModules_Planner +IAMCCS_AUIMG2VID_ProjectTimelinePlanner = IAMCCS_ProjectTimelinePlanner +IAMCCS_AUIMG2VID_AudioTimeline = IAMCCS_Ltx2HelperModules_AudioTimeline +IAMCCS_AUIMG2VID_KeyframeTimeline = IAMCCS_Ltx2HelperModules_KeyframeTimeline +IAMCCS_AUIMG2VID_RefreshPolicy = IAMCCS_Ltx2HelperModules_RefreshPolicy +IAMCCS_AUIMG2VID_ReanchorLatent = IAMCCS_Ltx2HelperModules_ReanchorLatent +IAMCCS_AUIMG2VID_DiskExtension = IAMCCS_Ltx2HelperModules_DiskExtension +IAMCCS_AUIMG2VID_RuntimeBridge = IAMCCS_Ltx2HelperModules_RuntimeBridge +IAMCCS_AUIMG2VID_Continuity = IAMCCS_Ltx2HelperModules_Continuity +IAMCCS_AUIMG2VID_Finalize = IAMCCS_Ltx2HelperModules_Finalize \ No newline at end of file diff --git a/iamccs_flux_klein_multigen.py b/iamccs_flux_klein_multigen.py index b4105ac..b460af3 100644 --- a/iamccs_flux_klein_multigen.py +++ b/iamccs_flux_klein_multigen.py @@ -215,6 +215,7 @@ class IAMCCS_FluxKleinMultiGen: "output_height": ("INT", {"default": 1024, "min": 16, "max": 8192, "step": 16}), "debug_enabled": ("BOOLEAN", {"default": False}), "debug_prefix": ("STRING", {"default": "flux_klein_debug"}), + "defer_cpu_transfer": ("BOOLEAN", {"default": False}), }, } @@ -241,6 +242,7 @@ class IAMCCS_FluxKleinMultiGen: output_height=1024, debug_enabled=False, debug_prefix="flux_klein_debug", + defer_cpu_transfer=False, ): sep = separator.replace("\\n", "\n") prompts = [p.strip() for p in (multi_prompt or "").split(sep) if p.strip()] @@ -339,25 +341,38 @@ class IAMCCS_FluxKleinMultiGen: decoded = decoded[0] if decoded.ndim == 3: decoded = decoded.unsqueeze(0) - decoded = decoded.cpu() + + # 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 + # By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + # By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + if defer_cpu_transfer: + stored_decoded = decoded + else: + stored_decoded = decoded.cpu() if dbg: _write_debug(dbg, { "event": "iteration_result", "idx": idx, "seed": current_seed, "prompt": prompt, - "decoded_shape": _shape(decoded), + "decoded_shape": _shape(stored_decoded), }) - results.append(decoded) + results.append(stored_decoded) if save_images and save_dir: slug = _prompt_slug(prompt) path = _unique_path(save_dir, f"{output_prefix}_{slug}", "png") - arr = (decoded[0].numpy() * 255).clip(0, 255).astype(np.uint8) + save_decoded = stored_decoded if not defer_cpu_transfer else decoded.detach().cpu() + arr = (save_decoded[0].numpy() * 255).clip(0, 255).astype(np.uint8) _PILImage.fromarray(arr).save(path) if dbg: _write_debug(dbg, {"event": "run_end", "count": len(results)}) - return (torch.cat(results, dim=0), len(prompts)) + batched = torch.cat(results, dim=0) + if defer_cpu_transfer: + batched = batched.cpu() + return (batched, len(prompts)) diff --git a/iamccs_lazy_switch.py b/iamccs_lazy_switch.py new file mode 100644 index 0000000..29d0f2b --- /dev/null +++ b/iamccs_lazy_switch.py @@ -0,0 +1,65 @@ +import logging + + +_log = logging.getLogger("IAMCCS.LazySwitch") + + +class IAMCCS_LazyAnySwitch: + """ + 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 + By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + By Carmine Cristallo Scalzi AI research (IAMCCS) - patreon.com/IAMCCS - carminecristalloscalzi.com + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "select": (["Qwen", "Flux"], {"default": "Qwen"}), + }, + "optional": { + "input1": ("*", {"lazy": True}), + "input2": ("*", {"lazy": True}), + }, + } + + RETURN_TYPES = ("*",) + RETURN_NAMES = ("output",) + FUNCTION = "run" + CATEGORY = "IAMCCS/Logic" + + @staticmethod + def _normalize_selection(select): + if isinstance(select, str): + value = select.strip().lower() + if value in ("qwen", "input1", "1"): + return 1 + if value in ("flux", "input2", "2"): + return 2 + try: + return 1 if int(select) <= 1 else 2 + except Exception: + return 1 + + def check_lazy_status(self, select, input1=None, input2=None, **kwargs): + selected = self._normalize_selection(select) + if selected == 1 and input1 is None: + return ["input1"] + if selected == 2 and input2 is None: + return ["input2"] + return [] + + def run(self, select, input1=None, input2=None): + selected = self._normalize_selection(select) + if selected == 1: + if input1 is None: + raise ValueError("IAMCCS_LazyAnySwitch: input1 is not connected or not available") + _log.info("[IAMCCS_LazyAnySwitch] selecting Qwen/input1") + return (input1,) + + if input2 is None: + raise ValueError("IAMCCS_LazyAnySwitch: input2 is not connected or not available") + _log.info("[IAMCCS_LazyAnySwitch] selecting Flux/input2") + return (input2,) \ No newline at end of file diff --git a/iamccs_ltx2_cinematic_flf.py b/iamccs_ltx2_cinematic_flf.py new file mode 100644 index 0000000..971b3db --- /dev/null +++ b/iamccs_ltx2_cinematic_flf.py @@ -0,0 +1,2615 @@ +from __future__ import annotations + +import json +import logging +import re +from typing import Any, Dict, List, Optional, Tuple + +import torch + + +_log = logging.getLogger("IAMCCS.LTX2.CinematicFLF") + + +def _json_dumps(data: Dict[str, Any]) -> str: + return json.dumps(data, ensure_ascii=False, indent=2) + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except Exception: + return float(default) + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(round(float(value))) + except Exception: + return int(default) + + +def _clamp(value: float, lo: float, hi: float) -> float: + return max(float(lo), min(float(hi), float(value))) + + +def _fix_ltx_frames(frames: int, mode: str) -> int: + frames = max(1, int(frames)) + mode = str(mode or "up") + if mode == "none": + return frames + rem = (frames - 1) % 8 + if rem == 0: + return frames + down = max(1, frames - rem) + up = frames + (8 - rem) + if mode == "down": + return down + if mode == "nearest": + return up if (up - frames) <= (frames - down) else down + return up + + +def _audio_duration_seconds(audio: Any) -> Optional[float]: + if audio is None: + return None + current = audio + for _ in range(6): + if current is None: + return None + if isinstance(current, dict): + if "waveform" in current and "sample_rate" in current: + waveform = current.get("waveform") + sample_rate = _safe_int(current.get("sample_rate"), 0) + if sample_rate <= 0 or not torch.is_tensor(waveform): + return None + return float(waveform.shape[-1]) / float(sample_rate) + if "audio" in current: + current = current.get("audio") + continue + if len(current) == 1: + current = next(iter(current.values())) + continue + if isinstance(current, (list, tuple)) and current: + current = current[0] + continue + break + return None + + +def _parse_numeric_list(text: str) -> List[float]: + if not text: + return [] + values: List[float] = [] + for part in re.split(r"[\n,;|]+", str(text)): + token = part.strip() + if not token: + continue + try: + values.append(float(token)) + except Exception: + continue + return values + + +def _parse_strengths(text: str, count: int, default: float) -> List[float]: + raw = [_clamp(v, 0.0, 1.0) for v in _parse_numeric_list(text)] + if not raw: + raw = [float(default)] + while len(raw) < int(count): + raw.append(raw[-1]) + return raw[: int(count)] + + +def _split_custom_line(line: str) -> List[str]: + if "|" in line: + return [p.strip() for p in line.split("|")] + if "," in line: + return [p.strip() for p in line.split(",")] + return [line.strip()] + + +def _position_to_frame(value: float, mode: str, total_frames: int, fps: float) -> int: + total_frames = max(1, int(total_frames)) + fps = max(0.001, float(fps)) + mode = str(mode or "ratio") + if value < 0: + return max(0, min(total_frames - 1, total_frames + int(round(value)))) + if mode == "seconds": + frame = int(round(float(value) * fps)) + elif mode == "frames": + frame = int(round(float(value))) + else: + frame = int(round(_clamp(float(value), 0.0, 1.0) * float(total_frames - 1))) + return max(0, min(total_frames - 1, frame)) + + +def _frame_to_ratio(frame: int, total_frames: int) -> float: + total_frames = max(1, int(total_frames)) + if total_frames <= 1: + return 0.0 + return _clamp(float(frame) / float(total_frames - 1), 0.0, 1.0) + + +def _preset_positions(preset: str) -> Tuple[List[float], List[int], List[float], List[str]]: + preset = str(preset or "shot_reverse_shot") + if preset == "room_coverage": + return ( + [0.0, 0.25, 0.50, 0.75, -1.0], + [1, 2, 3, 2, 1], + [0.92, 0.72, 0.72, 0.70, 0.88], + ["establish", "character_a", "wide_or_room", "character_b", "resolve"], + ) + if preset == "two_reference_dialogue": + return ( + [0.0, 0.22, 0.50, 0.78, -1.0], + [1, 2, 1, 2, 1], + [0.94, 0.78, 0.70, 0.78, 0.88], + ["speaker_a_open", "speaker_b_reply", "speaker_a_react", "speaker_b_close", "speaker_a_tail"], + ) + if preset == "beat_markers": + return ([0.0, 0.33, 0.66, -1.0], [1, 2, 3, 4], [0.90, 0.72, 0.72, 0.86], ["beat_1", "beat_2", "beat_3", "beat_4"]) + return ( + [0.0, 0.30, 0.58, -1.0], + [1, 2, 1, 2], + [0.95, 0.78, 0.72, 0.90], + ["field_open", "reverse", "field_reaction", "reverse_tail"], + ) + + +class IAMCCS_LTX2_CinematicShotPlanner: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "duration_source": (["manual", "audio", "max_manual_audio"], {"default": "manual"}), + "duration_seconds": ("FLOAT", {"default": 8.0, "min": 0.01, "max": 36000.0, "step": 0.01}), + "fps": ("FLOAT", {"default": 24.0, "min": 0.001, "max": 240.0, "step": 0.01}), + "total_frames_override": ("INT", {"default": 0, "min": 0, "max": 10000000, "step": 1}), + "ltx_round_mode": (["up", "nearest", "down", "none"], {"default": "up"}), + "preset": (["shot_reverse_shot", "room_coverage", "two_reference_dialogue", "beat_markers", "custom"], {"default": "shot_reverse_shot"}), + "reference_count": ("INT", {"default": 2, "min": 1, "max": 8, "step": 1}), + "position_mode": (["ratio", "seconds", "frames"], {"default": "ratio"}), + "positions": ("STRING", {"default": "0\n0.30\n0.58\n-1", "multiline": True}), + "strengths": ("STRING", {"default": "0.95\n0.78\n0.72\n0.90", "multiline": True}), + "min_spacing_frames": ("INT", {"default": 8, "min": 0, "max": 100000, "step": 1}), + }, + "optional": { + "audio": ("AUDIO",), + }, + } + + RETURN_TYPES = ( + "STRING", + "INT", + "FLOAT", + "INT", "INT", "INT", "INT", "INT", "INT", "INT", "INT", + "FLOAT", "FLOAT", "FLOAT", "FLOAT", "FLOAT", "FLOAT", "FLOAT", "FLOAT", + "STRING", + ) + RETURN_NAMES = ( + "plan_json", + "total_frames", + "total_seconds", + "frame_1", "frame_2", "frame_3", "frame_4", "frame_5", "frame_6", "frame_7", "frame_8", + "strength_1", "strength_2", "strength_3", "strength_4", "strength_5", "strength_6", "strength_7", "strength_8", + "report", + ) + FUNCTION = "plan" + CATEGORY = "IAMCCS/LTX-2/Cinematic" + + def _build_entries_from_custom( + self, + positions: str, + strengths: str, + position_mode: str, + total_frames: int, + fps: float, + reference_count: int, + ) -> List[Dict[str, Any]]: + raw_lines = [line.strip() for line in str(positions or "").splitlines() if line.strip()] + simple_positions: List[float] = [] + detailed_entries: List[Dict[str, Any]] = [] + for idx, line in enumerate(raw_lines): + parts = _split_custom_line(line) + if not parts: + continue + pos_token = parts[0].strip().lower() + if pos_token in {"end", "last", "tail"}: + value = -1.0 + else: + try: + value = float(pos_token) + except Exception: + continue + if len(parts) == 1: + simple_positions.append(value) + continue + strength = _safe_float(parts[1], 0.8) if len(parts) > 1 else 0.8 + ref_index = _safe_int(parts[2], (idx % reference_count) + 1) if len(parts) > 2 else (idx % reference_count) + 1 + label = parts[3] if len(parts) > 3 else f"custom_{idx + 1}" + frame = _position_to_frame(value, position_mode, total_frames, fps) + detailed_entries.append({ + "frame": int(frame), + "position": _frame_to_ratio(frame, total_frames), + "reference_index": max(1, min(int(reference_count), int(ref_index))), + "strength": _clamp(strength, 0.0, 1.0), + "label": label, + }) + + if detailed_entries: + return detailed_entries + + if not simple_positions: + simple_positions = [0.0, 0.30, 0.58, -1.0] + parsed_strengths = _parse_strengths(strengths, len(simple_positions), 0.8) + entries = [] + for idx, pos in enumerate(simple_positions): + frame = _position_to_frame(pos, position_mode, total_frames, fps) + entries.append({ + "frame": int(frame), + "position": _frame_to_ratio(frame, total_frames), + "reference_index": (idx % max(1, int(reference_count))) + 1, + "strength": float(parsed_strengths[idx]), + "label": f"custom_{idx + 1}", + }) + return entries + + def _filter_spacing(self, entries: List[Dict[str, Any]], min_spacing_frames: int, total_frames: int) -> List[Dict[str, Any]]: + min_spacing = max(0, int(min_spacing_frames)) + ordered = sorted(entries, key=lambda item: int(item.get("frame", 0))) + if min_spacing <= 0 or len(ordered) <= 2: + return ordered + kept: List[Dict[str, Any]] = [] + last_frame: Optional[int] = None + for idx, entry in enumerate(ordered): + frame = int(entry.get("frame", 0)) + is_tail = frame >= int(total_frames) - 1 + if last_frame is None or frame - last_frame >= min_spacing or is_tail: + kept.append(entry) + last_frame = frame + return kept + + def plan( + self, + duration_source, + duration_seconds, + fps, + total_frames_override, + ltx_round_mode, + preset, + reference_count, + position_mode, + positions, + strengths, + min_spacing_frames, + audio=None, + ): + fps = max(0.001, float(fps)) + manual_seconds = max(0.01, float(duration_seconds)) + audio_seconds = _audio_duration_seconds(audio) + if duration_source == "audio" and audio_seconds is not None: + total_seconds = float(audio_seconds) + duration_note = "audio" + elif duration_source == "max_manual_audio" and audio_seconds is not None: + total_seconds = max(manual_seconds, float(audio_seconds)) + duration_note = "max_manual_audio" + else: + total_seconds = manual_seconds + duration_note = "manual" + + if int(total_frames_override) > 0: + total_frames = max(1, int(total_frames_override)) + frames_note = "override" + else: + total_frames = _fix_ltx_frames(int(round(total_seconds * fps)), str(ltx_round_mode)) + frames_note = f"{ltx_round_mode}" + + reference_count = max(1, min(8, int(reference_count))) + if preset == "custom": + entries = self._build_entries_from_custom( + positions, strengths, position_mode, total_frames, fps, reference_count + ) + preset_note = "custom" + else: + preset_positions, preset_refs, preset_strengths, preset_labels = _preset_positions(str(preset)) + entries = [] + for idx, pos in enumerate(preset_positions): + frame = _position_to_frame(pos, "ratio", total_frames, fps) + ref_index = ((int(preset_refs[idx]) - 1) % reference_count) + 1 + entries.append({ + "frame": int(frame), + "position": _frame_to_ratio(frame, total_frames), + "reference_index": int(ref_index), + "strength": float(preset_strengths[idx]), + "label": str(preset_labels[idx]), + }) + preset_note = str(preset) + + entries = self._filter_spacing(entries, min_spacing_frames, total_frames) + for order, entry in enumerate(entries): + entry["order"] = int(order) + entry["frame"] = max(0, min(int(total_frames) - 1, int(entry.get("frame", 0)))) + entry["position"] = _frame_to_ratio(int(entry["frame"]), total_frames) + entry["reference_index"] = max(1, min(reference_count, int(entry.get("reference_index", 1)))) + entry["strength"] = _clamp(float(entry.get("strength", 0.8)), 0.0, 1.0) + + frames = [int(entry.get("frame", 0)) for entry in entries][:8] + strength_values = [float(entry.get("strength", 0.0)) for entry in entries][:8] + while len(frames) < 8: + frames.append(0) + while len(strength_values) < 8: + strength_values.append(0.0) + + plan = { + "schema": "iamccs_ltx2_cinematic_flf_plan_v1", + "preset": preset_note, + "duration_source": duration_note, + "fps": float(fps), + "total_seconds": float(total_frames) / float(fps), + "manual_seconds": float(manual_seconds), + "audio_seconds": audio_seconds, + "total_frames": int(total_frames), + "ltx_round_mode": frames_note, + "reference_count": int(reference_count), + "entries": entries, + } + report = ( + f"cinematic_plan preset={preset_note} refs={reference_count} " + f"frames={total_frames} @ {fps:.3f}fps entries={len(entries)} duration={duration_note}" + ) + _log.info("[CinematicShotPlanner] %s", report) + return ( + _json_dumps(plan), + int(total_frames), + float(plan["total_seconds"]), + *frames, + *strength_values, + report, + ) + + +class IAMCCS_LTX2_CinematicRefLatentControl: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "vae": ("VAE", {}), + "latent": ("LATENT", {}), + "plan_json": ("STRING", {"default": "{}", "multiline": True}), + "default_strength": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01}), + "lock_slots": ("INT", {"default": 1, "min": 1, "max": 8, "step": 1}), + "soft_transition_slots": ("INT", {"default": 0, "min": 0, "max": 32, "step": 1}), + "reference_fallback": (["cycle", "clamp", "skip"], {"default": "cycle"}), + }, + "optional": { + "reference_1": ("IMAGE",), + "reference_2": ("IMAGE",), + "reference_3": ("IMAGE",), + "reference_4": ("IMAGE",), + "reference_5": ("IMAGE",), + "reference_6": ("IMAGE",), + "reference_7": ("IMAGE",), + "reference_8": ("IMAGE",), + }, + } + + RETURN_TYPES = ("LATENT", "INT", "STRING") + RETURN_NAMES = ("latent", "applied_count", "report") + FUNCTION = "apply" + CATEGORY = "IAMCCS/LTX-2/Cinematic" + + @staticmethod + def _common_upscale_nhwc_to(images: torch.Tensor, target_h: int, target_w: int) -> torch.Tensor: + try: + import comfy.utils # type: ignore + except Exception as e: + raise ImportError("comfy.utils is required for this node") from e + + if int(images.shape[1]) == int(target_h) and int(images.shape[2]) == int(target_w): + return images + return comfy.utils.common_upscale( + images.movedim(-1, 1), + int(target_w), + int(target_h), + "bilinear", + "center", + ).movedim(1, -1) + + @classmethod + def _encode_image(cls, vae, image: torch.Tensor, target_h: int, target_w: int) -> torch.Tensor: + if int(image.shape[0]) > 1: + image = image[:1] + pixels = cls._common_upscale_nhwc_to(image, int(target_h), int(target_w)) + return vae.encode(pixels[:, :, :, :3]) + + @staticmethod + def _match_latent_batch(base_samples: torch.Tensor, other_samples: torch.Tensor) -> torch.Tensor: + bb = int(base_samples.shape[0]) + ob = int(other_samples.shape[0]) + if bb == ob: + return other_samples + if ob == 1 and bb > 1: + reps = [bb] + [1] * (other_samples.dim() - 1) + return other_samples.repeat(*reps) + return other_samples[:bb] + + @staticmethod + def _ensure_noise_mask(latent: Dict[str, Any], samples: torch.Tensor) -> torch.Tensor: + batch, _, latent_frames, _, _ = samples.shape + if "noise_mask" in latent and latent["noise_mask"] is not None: + noise_mask = latent["noise_mask"].clone() + if int(noise_mask.shape[0]) == 1 and int(batch) > 1: + noise_mask = noise_mask.repeat(int(batch), 1, 1, 1, 1) + elif int(noise_mask.shape[0]) != int(batch): + noise_mask = noise_mask[: int(batch)] + return noise_mask + return torch.ones((batch, 1, latent_frames, 1, 1), dtype=torch.float32, device=samples.device) + + @staticmethod + def _load_plan(plan_json: str) -> Dict[str, Any]: + try: + plan = json.loads(str(plan_json or "{}")) + if isinstance(plan, dict): + return plan + except Exception: + pass + return {"entries": []} + + @staticmethod + def _select_reference(refs: List[Optional[torch.Tensor]], ref_index: int, fallback: str) -> Optional[torch.Tensor]: + available = [(idx + 1, image) for idx, image in enumerate(refs) if image is not None] + if not available: + return None + ref_index = int(ref_index) + if 1 <= ref_index <= len(refs) and refs[ref_index - 1] is not None: + return refs[ref_index - 1] + if fallback == "skip": + return None + if fallback == "clamp": + target = max(1, min(ref_index, len(available))) + return available[target - 1][1] + return available[(max(1, ref_index) - 1) % len(available)][1] + + @staticmethod + def _frame_to_latent_index(frame: int, total_pixel_frames: int, time_scale_factor: int, latent_frames: int, slots: int) -> int: + if int(frame) < 0: + frame = int(total_pixel_frames) + int(frame) + frame = max(0, min(int(total_pixel_frames) - 1, int(frame))) + latent_idx = int(frame) // max(1, int(time_scale_factor)) + if frame >= int(total_pixel_frames) - max(1, int(time_scale_factor)): + latent_idx = int(latent_frames) - int(slots) + return max(0, min(int(latent_frames) - int(slots), int(latent_idx))) + + @staticmethod + def _smoothstep(x: torch.Tensor) -> torch.Tensor: + return x * x * (3.0 - 2.0 * x) + + def _apply_soft_transition( + self, + samples: torch.Tensor, + noise_mask: torch.Tensor, + ref_slot: torch.Tensor, + start_idx: int, + strength: float, + soft_transition_slots: int, + ) -> Tuple[torch.Tensor, torch.Tensor, int]: + soft_slots = max(0, min(int(soft_transition_slots), int(start_idx))) + if soft_slots <= 0: + return samples, noise_mask, 0 + trans_start = int(start_idx) - soft_slots + weights = torch.linspace( + 0.0, + 1.0, + steps=soft_slots + 2, + device=samples.device, + dtype=torch.float32, + )[1:-1] + for offset in range(soft_slots): + alpha = float(self._smoothstep(weights[offset : offset + 1]).item()) + t = trans_start + offset + samples[:, :, t : t + 1] = ( + (1.0 - alpha) * samples[:, :, t : t + 1] + alpha * ref_slot + ) + target_mask = 1.0 - (float(strength) * alpha * 0.5) + cur = noise_mask[:, :, t : t + 1] + noise_mask[:, :, t : t + 1] = torch.minimum(cur, torch.full_like(cur, float(target_mask))) + return samples, noise_mask, soft_slots + + def apply( + self, + vae, + latent, + plan_json, + default_strength, + lock_slots, + soft_transition_slots, + reference_fallback, + reference_1=None, + reference_2=None, + reference_3=None, + reference_4=None, + reference_5=None, + reference_6=None, + reference_7=None, + reference_8=None, + ): + samples_in = latent.get("samples") + if samples_in is None: + raise ValueError("LATENT input is missing 'samples'") + + samples = samples_in.clone() + batch, _, latent_frames, latent_height, latent_width = samples.shape + scale_factors = getattr(vae, "downscale_index_formula", (8, 8, 8)) + time_scale_factor = int(scale_factors[0]) + height_scale_factor = int(scale_factors[1]) + width_scale_factor = int(scale_factors[2]) + target_width = int(latent_width) * width_scale_factor + target_height = int(latent_height) * height_scale_factor + total_pixel_frames = (int(latent_frames) - 1) * max(1, time_scale_factor) + 1 + lock_slots = max(1, min(8, int(lock_slots), int(latent_frames))) + default_strength = _clamp(default_strength, 0.0, 1.0) + + plan = self._load_plan(plan_json) + entries = plan.get("entries", []) + if not isinstance(entries, list): + entries = [] + refs = [reference_1, reference_2, reference_3, reference_4, reference_5, reference_6, reference_7, reference_8] + noise_mask = self._ensure_noise_mask(latent, samples) + + encoded_cache: Dict[int, torch.Tensor] = {} + applied = 0 + ops: List[str] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + ref_index = max(1, int(entry.get("reference_index", 1))) + image = self._select_reference(refs, ref_index, str(reference_fallback)) + if image is None: + ops.append(f"skip(ref={ref_index})") + continue + if ref_index not in encoded_cache: + ref_latent = self._encode_image(vae, image, target_height, target_width) + ref_latent = self._match_latent_batch(samples, ref_latent) + encoded_cache[ref_index] = ref_latent.to(device=samples.device, dtype=samples.dtype) + ref_latent = encoded_cache[ref_index] + slots = min(lock_slots, int(ref_latent.shape[2]), int(latent_frames)) + if slots <= 0: + continue + frame = int(entry.get("frame", 0)) + strength = _clamp(entry.get("strength", default_strength), 0.0, 1.0) + if strength <= 0.0: + continue + latent_idx = self._frame_to_latent_index(frame, total_pixel_frames, time_scale_factor, latent_frames, slots) + ref_slice = ref_latent[:, :, :slots] + if frame >= total_pixel_frames - time_scale_factor: + ref_slice = ref_latent[:, :, -slots:] + + samples, noise_mask, soft_used = self._apply_soft_transition( + samples, + noise_mask, + ref_slice[:, :, :1], + latent_idx, + strength, + int(soft_transition_slots), + ) + samples[:, :, latent_idx : latent_idx + slots] = ref_slice + cur = noise_mask[:, :, latent_idx : latent_idx + slots] + noise_mask[:, :, latent_idx : latent_idx + slots] = torch.minimum( + cur, + torch.full_like(cur, 1.0 - float(strength)), + ) + applied += 1 + label = str(entry.get("label", f"ref_{ref_index}")) + ops.append(f"{label}:f{frame}->t{latent_idx}+{slots},ref={ref_index},s={strength:.2f},soft={soft_used}") + + out_latent = dict(latent) + out_latent["samples"] = samples + out_latent["noise_mask"] = noise_mask + report = ( + f"cinematic_ref_latent applied={applied}/{len(entries)} " + f"latent_frames={latent_frames} pixel_frames={total_pixel_frames} | " + + ("; ".join(ops) if ops else "no-op") + ) + _log.info("[CinematicRefLatentControl] %s", report) + return (out_latent, int(applied), report) + + +class IAMCCS_LTX2_AudioPromptDirector: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene_prompt": ("STRING", {"default": "", "multiline": True}), + "action_prompt": ("STRING", {"default": "", "multiline": True}), + "dialogue_text": ("STRING", {"default": "", "multiline": True}), + "voice_direction": ("STRING", {"default": "", "multiline": True}), + "speaker_label": ("STRING", {"default": "the character"}), + "audio_role": (["off", "dialogue_only", "dialogue_plus_action", "ambience_only"], {"default": "dialogue_plus_action"}), + "prompt_style": (["cinematic", "literal_ltx", "compact"], {"default": "cinematic"}), + "fps": ("FLOAT", {"default": 24.0, "min": 0.001, "max": 240.0, "step": 0.01}), + }, + "optional": { + "audio": ("AUDIO",), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "FLOAT", "INT", "STRING") + RETURN_NAMES = ("prompt", "dialogue_clause", "audio_seconds", "audio_frames", "report") + FUNCTION = "build" + CATEGORY = "IAMCCS/LTX-2/Cinematic" + + @staticmethod + def _clean(text: str) -> str: + return re.sub(r"\s+", " ", str(text or "").strip()) + + def build( + self, + scene_prompt, + action_prompt, + dialogue_text, + voice_direction, + speaker_label, + audio_role, + prompt_style, + fps, + audio=None, + ): + scene = self._clean(scene_prompt) + action = self._clean(action_prompt) + dialogue = self._clean(dialogue_text) + voice = self._clean(voice_direction) + speaker = self._clean(speaker_label) or "the character" + audio_seconds = _audio_duration_seconds(audio) + audio_frames = int(round(float(audio_seconds or 0.0) * max(0.001, float(fps)))) + + dialogue_clause = "" + if audio_role in {"dialogue_only", "dialogue_plus_action"} and dialogue: + dialogue_clause = f'{speaker} says "{dialogue}"' + if voice: + dialogue_clause += f" with {voice}" + elif audio_role == "ambience_only": + dialogue_clause = "the provided audio is used as ambience and timing texture" + + parts: List[str] = [] + if prompt_style == "compact": + parts = [p for p in [scene, action, dialogue_clause] if p] + elif prompt_style == "literal_ltx": + if scene: + parts.append(f"Scene: {scene}") + if action: + parts.append(f"Action and camera direction: {action}") + if dialogue_clause: + parts.append(f"Audio/dialogue: {dialogue_clause}") + if audio_role != "off": + parts.append("Follow the selected audio for voice timing, while the visual action remains directed by the prompt.") + else: + if scene: + parts.append(scene) + if action: + parts.append(action) + if dialogue_clause: + parts.append(dialogue_clause) + if audio_role != "off": + parts.append("The voice follows the provided audio, but blocking, camera movement, expressions, and cuts follow the written direction.") + + if not parts: + parts.append("A cinematic shot with coherent motion and natural continuity.") + prompt = ". ".join(part.rstrip(".") for part in parts if part).strip() + if prompt and not prompt.endswith("."): + prompt += "." + + report = ( + f"audio_prompt_director role={audio_role} style={prompt_style} " + f"audio_seconds={(audio_seconds if audio_seconds is not None else 0.0):.3f} frames={audio_frames}" + ) + return (prompt, dialogue_clause, float(audio_seconds or 0.0), int(audio_frames), report) + + +class IAMCCS_LTX2_CinematicPromptRelayAdapter: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "plan_json": ("STRING", {"default": "{}", "multiline": True}), + "global_prompt": ("STRING", {"default": "", "multiline": True}), + "local_prompt_lines": ("STRING", { + "default": "", + "multiline": True, + "tooltip": "Optional. One line per PromptRelay segment, or use | separators. Supports {label}, {role}, {ref}, {start}, {end}, {frames}, {global}.", + }), + "role_lines": ("STRING", { + "default": ( + "reference 1: field / character A\n" + "reference 2: reverse field / character B\n" + "reference 3: room / wide shot\n" + "reference 4: insert / beat detail" + ), + "multiline": True, + }), + "segment_mode": (["plan_keyframes", "even_by_entries", "single_scene"], {"default": "plan_keyframes"}), + "include_global_in_local": (["no", "yes"], {"default": "no"}), + "merge_short_segments": (["yes", "no"], {"default": "yes"}), + "min_segment_frames": ("INT", {"default": 4, "min": 1, "max": 100000, "step": 1}), + "default_local_prompt": ("STRING", {"default": "cinematic continuity, natural motion, coherent staging", "multiline": True}), + } + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "INT", "STRING") + RETURN_NAMES = ("global_prompt", "local_prompts", "segment_lengths", "segment_count", "report") + FUNCTION = "build" + CATEGORY = "IAMCCS/LTX-2/Cinematic" + + @staticmethod + def _load_plan(plan_json: Any) -> Dict[str, Any]: + if isinstance(plan_json, dict): + return plan_json + try: + data = json.loads(str(plan_json or "{}")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + @staticmethod + def _clean(text: Any) -> str: + return re.sub(r"\s+", " ", str(text or "").strip()) + + @staticmethod + def _split_prompt_lines(text: str) -> List[str]: + raw = str(text or "").strip() + if not raw: + return [] + if "|" in raw: + parts = raw.split("|") + else: + parts = raw.splitlines() + return [p.strip() for p in parts if p.strip()] + + @staticmethod + def _parse_roles(text: str) -> List[str]: + roles: List[str] = [] + for line in str(text or "").splitlines(): + token = line.strip() + if not token: + continue + if ":" in token: + token = token.split(":", 1)[1].strip() + roles.append(token) + if not roles: + roles = ["reference 1", "reference 2", "reference 3", "reference 4"] + return roles + + @staticmethod + def _valid_entries(plan: Dict[str, Any], total_frames: int) -> List[Dict[str, Any]]: + entries = plan.get("entries", []) + if not isinstance(entries, list): + return [] + out: List[Dict[str, Any]] = [] + for idx, entry in enumerate(entries): + if not isinstance(entry, dict): + continue + frame = max(0, min(max(0, int(total_frames) - 1), _safe_int(entry.get("frame", 0), 0))) + out.append({ + "frame": frame, + "reference_index": max(1, _safe_int(entry.get("reference_index", idx + 1), idx + 1)), + "label": str(entry.get("label", f"beat_{idx + 1}")), + "strength": _clamp(entry.get("strength", 0.8), 0.0, 1.0), + }) + out.sort(key=lambda item: (int(item.get("frame", 0)), int(item.get("reference_index", 1)))) + deduped: List[Dict[str, Any]] = [] + seen = set() + for entry in out: + key = int(entry["frame"]) + if key in seen: + continue + deduped.append(entry) + seen.add(key) + return deduped + + @staticmethod + def _distributed_lengths(total_frames: int, count: int) -> List[int]: + total_frames = max(1, int(total_frames)) + count = max(1, min(int(count), int(total_frames))) + base = total_frames // count + remainder = total_frames % count + return [base + (1 if idx < remainder else 0) for idx in range(count)] + + def _segments_from_entries( + self, + entries: List[Dict[str, Any]], + total_frames: int, + segment_mode: str, + manual_count: int, + ) -> List[Dict[str, Any]]: + total_frames = max(1, int(total_frames)) + if segment_mode == "single_scene": + entry = entries[0] if entries else {} + return [{"start": 0, "end": total_frames, "frames": total_frames, "entry": entry, "kind": "single"}] + + if segment_mode == "even_by_entries": + count = max(len(entries), int(manual_count), 1) + lengths = self._distributed_lengths(total_frames, count) + segments: List[Dict[str, Any]] = [] + cursor = 0 + for idx, length in enumerate(lengths): + entry = entries[min(idx, len(entries) - 1)] if entries else {} + end = cursor + int(length) + segments.append({"start": cursor, "end": end, "frames": int(length), "entry": entry, "kind": "even"}) + cursor = end + return segments + + if not entries: + return [{"start": 0, "end": total_frames, "frames": total_frames, "entry": {}, "kind": "empty"}] + + segments = [] + cursor = 0 + for idx, entry in enumerate(entries): + start = int(entry.get("frame", 0)) + if start > cursor: + segments.append({ + "start": cursor, + "end": start, + "frames": start - cursor, + "entry": {}, + "kind": "prelude", + }) + next_start = int(entries[idx + 1].get("frame", total_frames)) if idx + 1 < len(entries) else total_frames + end = max(start + 1, min(total_frames, next_start)) + if end > start: + segments.append({ + "start": start, + "end": end, + "frames": end - start, + "entry": entry, + "kind": "plan", + }) + cursor = max(cursor, end) + if cursor < total_frames: + segments.append({"start": cursor, "end": total_frames, "frames": total_frames - cursor, "entry": entries[-1], "kind": "tail"}) + return [seg for seg in segments if int(seg.get("frames", 0)) > 0] + + @staticmethod + def _merge_short(segments: List[Dict[str, Any]], min_segment_frames: int, enabled: bool) -> List[Dict[str, Any]]: + if not enabled: + return segments + min_frames = max(1, int(min_segment_frames)) + merged: List[Dict[str, Any]] = [] + for seg in segments: + seg = dict(seg) + if int(seg.get("frames", 0)) < min_frames and merged: + prev = merged[-1] + prev["end"] = seg.get("end", prev.get("end", 0)) + prev["frames"] = int(prev.get("frames", 0)) + int(seg.get("frames", 0)) + if not prev.get("entry") and seg.get("entry"): + prev["entry"] = seg.get("entry") + prev["kind"] = f"{prev.get('kind', 'segment')}+{seg.get('kind', 'short')}" + else: + merged.append(seg) + return merged + + @staticmethod + def _format_template(template: str, mapping: Dict[str, Any]) -> str: + try: + return str(template).format(**mapping) + except Exception: + return str(template) + + def _auto_prompt( + self, + segment: Dict[str, Any], + role: str, + global_prompt: str, + include_global: bool, + default_local_prompt: str, + ) -> str: + entry = segment.get("entry") if isinstance(segment.get("entry"), dict) else {} + label = self._clean(str(entry.get("label", "")).replace("_", " ")) + parts: List[str] = [] + if include_global and global_prompt: + parts.append(global_prompt) + if role: + parts.append(role) + if label: + parts.append(label) + if segment.get("kind") == "prelude": + parts.append("establish the scene before the next beat") + if not parts: + parts.append(self._clean(default_local_prompt) or "cinematic continuity") + return ". ".join(part.rstrip(".") for part in parts if part).strip() + + def build( + self, + plan_json, + global_prompt, + local_prompt_lines, + role_lines, + segment_mode, + include_global_in_local, + merge_short_segments, + min_segment_frames, + default_local_prompt, + ): + plan = self._load_plan(plan_json) + total_frames = max(1, _safe_int(plan.get("total_frames", 0), 0)) + if total_frames <= 1: + entries_for_total = plan.get("entries", []) + if isinstance(entries_for_total, list) and entries_for_total: + total_frames = max(1, max(_safe_int(e.get("frame", 0), 0) for e in entries_for_total if isinstance(e, dict)) + 1) + + global_out = self._clean(global_prompt) + if not global_out: + global_out = "A cinematic shot with coherent motion and natural continuity." + + manual_lines = self._split_prompt_lines(local_prompt_lines) + roles = self._parse_roles(role_lines) + entries = self._valid_entries(plan, total_frames) + segments = self._segments_from_entries(entries, total_frames, str(segment_mode), len(manual_lines)) + segments = self._merge_short(segments, int(min_segment_frames), str(merge_short_segments) == "yes") + if not segments: + segments = [{"start": 0, "end": total_frames, "frames": total_frames, "entry": {}, "kind": "empty"}] + + include_global = str(include_global_in_local) == "yes" + prompts: List[str] = [] + lengths: List[int] = [] + ops: List[str] = [] + for idx, segment in enumerate(segments): + entry = segment.get("entry") if isinstance(segment.get("entry"), dict) else {} + ref_index = max(1, _safe_int(entry.get("reference_index", idx + 1), idx + 1)) + role = roles[min(ref_index - 1, len(roles) - 1)] if roles else f"reference {ref_index}" + mapping = { + "label": self._clean(str(entry.get("label", f"segment_{idx + 1}")).replace("_", " ")), + "role": role, + "ref": ref_index, + "start": int(segment.get("start", 0)), + "end": int(segment.get("end", 0)), + "frames": int(segment.get("frames", 0)), + "global": global_out, + } + if manual_lines: + template = manual_lines[min(idx, len(manual_lines) - 1)] + prompt = self._clean(self._format_template(template, mapping)) + if include_global and global_out and global_out not in prompt: + prompt = f"{global_out}. {prompt}" + else: + prompt = self._auto_prompt(segment, role, global_out, include_global, default_local_prompt) + prompts.append(prompt) + lengths.append(max(1, int(segment.get("frames", 1)))) + ops.append(f"{idx + 1}:{segment.get('kind')} f{mapping['start']}-{mapping['end']} len={mapping['frames']} ref={ref_index}") + + segment_lengths = ",".join(str(length) for length in lengths) + local_prompts = "\n|\n".join(prompts) + report = ( + f"prompt_relay_adapter segments={len(prompts)} total_frames={total_frames} " + f"length_sum={sum(lengths)} mode={segment_mode} | " + + "; ".join(ops) + ) + return (global_out, local_prompts, segment_lengths, int(len(prompts)), report) + + +def _cin_clean(text: Any) -> str: + return re.sub(r"\s+", " ", str(text or "").strip()) + + +def _cin_slug(text: Any, default: str) -> str: + raw = re.sub(r"[^A-Za-z0-9_-]+", "_", str(text or "").strip()).strip("_") + return raw[:64] or default + + +def _cin_join_prompt_parts(*parts: Any) -> str: + cleaned: List[str] = [] + for part in parts: + value = _cin_clean(part) + if not value or value.lower() in {"none", "off", "no", "custom"}: + continue + cleaned.append(value.rstrip(".")) + return ". ".join(cleaned).strip() + + +class IAMCCS_LTX2_CinematicPromptComposer: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "framing": ([ + "custom", + "wide establishing shot", + "medium shot", + "medium close-up", + "close-up", + "extreme close-up", + "over-the-shoulder", + "reverse angle", + "insert detail", + "two-shot", + "tracking shot", + ], {"default": "medium close-up"}), + "camera_movement": ([ + "custom", + "locked-off camera", + "slow push-in", + "slow pull-back", + "subtle handheld", + "gentle pan", + "tracking left to right", + "tracking forward", + "static observational", + "match cut motion", + ], {"default": "subtle handheld"}), + "lens_style": ([ + "custom", + "natural 35mm lens", + "intimate 50mm lens", + "compressed telephoto portrait", + "wide lens with room geography", + "shallow depth of field", + "deep focus", + "anamorphic cinematic look", + ], {"default": "natural 35mm lens"}), + "lighting": ([ + "custom", + "soft window light", + "warm practical lamps", + "noir desk lamp", + "moonlight through window", + "rainy night reflections", + "overcast natural light", + "low-key cinematic lighting", + "high contrast silhouette", + ], {"default": "low-key cinematic lighting"}), + "subject": ("STRING", {"default": "Mara", "multiline": False}), + "action_prompt": ("STRING", { + "default": "she listens, then speaks softly while holding eye contact", + "multiline": True, + }), + "emotion": ([ + "custom", + "controlled hurt", + "calm suspicion", + "quiet regret", + "restrained fear", + "tender hesitation", + "cold determination", + "nervous confession", + "silent reaction", + "neutral documentary realism", + ], {"default": "controlled hurt"}), + "location_context": ("STRING", { + "default": "inside a small kitchen at night, rain on the window", + "multiline": True, + }), + "continuity_note": ("STRING", { + "default": "keep coherent eyelines, natural acting, stable identity, cinematic room geography", + "multiline": True, + }), + }, + "optional": { + "extra_prompt": ("STRING", {"default": "", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("shot_prompt", "report") + FUNCTION = "compose" + CATEGORY = "IAMCCS/LTX-2/Cinematic/User Friendly" + + def compose( + self, + framing, + camera_movement, + lens_style, + lighting, + subject, + action_prompt, + emotion, + location_context, + continuity_note, + extra_prompt="", + ): + subject_text = _cin_clean(subject) or "the subject" + action = _cin_clean(action_prompt) + if action: + action = f"{subject_text} {action}" if subject_text.lower() not in action.lower() else action + prompt = _cin_join_prompt_parts( + framing, + action, + emotion, + camera_movement, + lens_style, + lighting, + location_context, + continuity_note, + extra_prompt, + ) + if not prompt: + prompt = f"medium close-up on {subject_text}, natural cinematic acting" + report = f"prompt_composer subject={subject_text} framing={framing} movement={camera_movement}" + return (prompt, report) + + +class IAMCCS_LTX2_CinematicShotLineBuilder: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "duration_seconds": ("FLOAT", {"default": 4.0, "min": 0.01, "max": 3600.0, "step": 0.01}), + "cut_mode": (["hard_cut", "continuity_cut", "soft_cut", "match_cut"], {"default": "hard_cut"}), + "primary_reference_index": ("INT", {"default": 1, "min": 1, "max": 8, "step": 1}), + "reference_mode": (["single", "start_to_end", "dual"], {"default": "single"}), + "secondary_reference_index": ("INT", {"default": 0, "min": 0, "max": 8, "step": 1}), + "audio_index": ("INT", {"default": 1, "min": 0, "max": 8, "step": 1}), + "label": ("STRING", {"default": "campo_mara", "multiline": False}), + "framing": ([ + "custom", + "wide establishing shot", + "medium shot", + "medium close-up", + "close-up", + "extreme close-up", + "over-the-shoulder", + "reverse angle", + "insert detail", + "two-shot", + ], {"default": "medium close-up"}), + "subject": ("STRING", {"default": "Mara", "multiline": False}), + "action_prompt": ("STRING", { + "default": "near the kitchen window, she speaks softly but firmly", + "multiline": True, + }), + "dialogue_text": ("STRING", { + "default": "You left the city without saying goodbye.", + "multiline": True, + }), + "voice_direction": ("STRING", { + "default": "controlled hurt voice", + "multiline": True, + }), + "camera_movement": ([ + "custom", + "locked-off camera", + "slow push-in", + "slow pull-back", + "subtle handheld", + "gentle pan", + "tracking shot", + ], {"default": "subtle handheld"}), + "lighting": ([ + "custom", + "soft window light", + "warm practical lamps", + "noir desk lamp", + "moonlight through window", + "rainy night reflections", + "low-key cinematic lighting", + ], {"default": "rainy night reflections"}), + "continuity_note": ("STRING", { + "default": "keep coherent eyelines and stable character identity", + "multiline": True, + }), + }, + "optional": { + "prompt_override": ("STRING", { + "default": "", + "multiline": True, + "tooltip": "If connected or filled, this replaces the dropdown-composed shot prompt.", + }), + "extra_prompt": ("STRING", {"default": "", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("shot_line", "shot_prompt", "reference_token", "report") + FUNCTION = "build" + CATEGORY = "IAMCCS/LTX-2/Cinematic/User Friendly" + + @staticmethod + def _reference_token(primary: int, mode: str, secondary: int) -> str: + primary = max(1, min(8, int(primary))) + secondary = max(0, min(8, int(secondary))) + if str(mode) == "start_to_end" and secondary > 0: + return f"{primary}>{secondary}" + if str(mode) == "dual" and secondary > 0: + return f"{primary}+{secondary}" + return str(primary) + + def build( + self, + duration_seconds, + cut_mode, + primary_reference_index, + reference_mode, + secondary_reference_index, + audio_index, + label, + framing, + subject, + action_prompt, + dialogue_text, + voice_direction, + camera_movement, + lighting, + continuity_note, + prompt_override="", + extra_prompt="", + ): + ref_token = self._reference_token(primary_reference_index, reference_mode, secondary_reference_index) + safe_label = _cin_slug(label, f"shot_ref_{ref_token}") + prompt = _cin_clean(prompt_override) + if not prompt: + subject_text = _cin_clean(subject) or "the subject" + action = _cin_clean(action_prompt) + if action and subject_text.lower() not in action.lower(): + action = f"{subject_text} {action}" + prompt = _cin_join_prompt_parts( + framing, + action, + camera_movement, + lighting, + continuity_note, + extra_prompt, + ) + line = " | ".join([ + f"{max(0.01, _safe_float(duration_seconds, 4.0)):.2f}".rstrip("0").rstrip("."), + str(cut_mode), + ref_token, + str(max(0, min(8, _safe_int(audio_index, 0)))), + safe_label, + prompt, + _cin_clean(dialogue_text), + _cin_clean(voice_direction), + ]) + report = f"shot_line label={safe_label} ref={ref_token} audio={audio_index} cut={cut_mode}" + return (line, prompt, ref_token, report) + + +class IAMCCS_LTX2_CinematicV2VTimelineLineBuilder: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "duration_seconds": ("FLOAT", {"default": 4.0, "min": 0.01, "max": 3600.0, "step": 0.01}), + "cut_mode": (["hard_cut", "continuity_cut", "soft_cut", "match_cut"], {"default": "hard_cut"}), + "source_video_index": ("INT", {"default": 1, "min": 0, "max": 8, "step": 1}), + "source_range_mode": (["all", "start_plus_count", "frame_range", "tail", "custom"], {"default": "start_plus_count"}), + "source_start_frame": ("INT", {"default": 0, "min": 0, "max": 10000000, "step": 1}), + "source_frame_count": ("INT", {"default": 96, "min": 1, "max": 10000000, "step": 1}), + "source_end_frame": ("INT", {"default": 96, "min": 1, "max": 10000000, "step": 1}), + "custom_source_range": ("STRING", {"default": "0+96", "multiline": False}), + "primary_reference_index": ("INT", {"default": 1, "min": 1, "max": 8, "step": 1}), + "reference_mode": (["single", "start_to_end", "dual"], {"default": "single"}), + "secondary_reference_index": ("INT", {"default": 0, "min": 0, "max": 8, "step": 1}), + "audio_index": ("INT", {"default": 1, "min": 0, "max": 8, "step": 1}), + "label": ("STRING", {"default": "camera_A_mara", "multiline": False}), + "framing": ([ + "custom", + "V2V close-up", + "V2V medium close-up", + "V2V reverse angle", + "V2V over-the-shoulder", + "V2V wide shot", + "V2V insert detail", + "V2V tracking shot", + ], {"default": "V2V close-up"}), + "subject": ("STRING", {"default": "Mara", "multiline": False}), + "action_prompt": ("STRING", { + "default": "speaking, preserve the original head movement and natural eye line", + "multiline": True, + }), + "dialogue_text": ("STRING", { + "default": "You left the city without saying goodbye.", + "multiline": True, + }), + "voice_direction": ("STRING", { + "default": "controlled hurt voice", + "multiline": True, + }), + "v2v_mode": ([ + "v2v_source_plus_reference", + "v2v_source_context", + "i2v_from_reference", + "two_segments_if_long", + "loop_if_long", + ], {"default": "v2v_source_plus_reference"}), + "continuity_note": ("STRING", { + "default": "keep source performance, improve cinematic lighting, stable identity", + "multiline": True, + }), + }, + "optional": { + "prompt_override": ("STRING", {"default": "", "multiline": True}), + "extra_prompt": ("STRING", {"default": "", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("timeline_line", "shot_prompt", "source_range", "reference_token", "report") + FUNCTION = "build" + CATEGORY = "IAMCCS/LTX-2/Cinematic/User Friendly" + + @staticmethod + def _source_range(mode: str, start: int, count: int, end: int, custom: str) -> str: + mode = str(mode or "all") + start = max(0, int(start)) + count = max(1, int(count)) + end = max(start + 1, int(end)) + if mode == "start_plus_count": + return f"{start}+{count}" + if mode == "frame_range": + return f"{start}-{end}" + if mode == "tail": + return f"tail{count}" + if mode == "custom": + return _cin_clean(custom) or f"{start}+{count}" + return "all" + + def build( + self, + duration_seconds, + cut_mode, + source_video_index, + source_range_mode, + source_start_frame, + source_frame_count, + source_end_frame, + custom_source_range, + primary_reference_index, + reference_mode, + secondary_reference_index, + audio_index, + label, + framing, + subject, + action_prompt, + dialogue_text, + voice_direction, + v2v_mode, + continuity_note, + prompt_override="", + extra_prompt="", + ): + ref_token = IAMCCS_LTX2_CinematicShotLineBuilder._reference_token( + primary_reference_index, + reference_mode, + secondary_reference_index, + ) + source_range = self._source_range( + source_range_mode, + _safe_int(source_start_frame, 0), + _safe_int(source_frame_count, 96), + _safe_int(source_end_frame, 96), + custom_source_range, + ) + safe_label = _cin_slug(label, f"v2v_source_{source_video_index}") + prompt = _cin_clean(prompt_override) + if not prompt: + subject_text = _cin_clean(subject) or "the subject" + action = _cin_clean(action_prompt) + if action and subject_text.lower() not in action.lower(): + action = f"{subject_text} {action}" + prompt = _cin_join_prompt_parts(framing, action, continuity_note, extra_prompt) + line = " | ".join([ + f"{max(0.01, _safe_float(duration_seconds, 4.0)):.2f}".rstrip("0").rstrip("."), + str(cut_mode), + str(max(0, min(8, _safe_int(source_video_index, 1)))), + source_range, + ref_token, + str(max(0, min(8, _safe_int(audio_index, 0)))), + safe_label, + prompt, + _cin_clean(dialogue_text), + _cin_clean(voice_direction), + str(v2v_mode), + ]) + report = ( + f"v2v_line label={safe_label} source={source_video_index} " + f"range={source_range} ref={ref_token} audio={audio_index}" + ) + return (line, prompt, source_range, ref_token, report) + + +class IAMCCS_LTX2_CinematicLineStacker: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mode": (["multigen_shot_lines", "v2v_timeline_lines", "generic_lines"], {"default": "multigen_shot_lines"}), + "skip_empty": (["yes", "no"], {"default": "yes"}), + "comment_header": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "line_1": ("STRING", {"default": "", "multiline": True}), + "line_2": ("STRING", {"default": "", "multiline": True}), + "line_3": ("STRING", {"default": "", "multiline": True}), + "line_4": ("STRING", {"default": "", "multiline": True}), + "line_5": ("STRING", {"default": "", "multiline": True}), + "line_6": ("STRING", {"default": "", "multiline": True}), + "line_7": ("STRING", {"default": "", "multiline": True}), + "line_8": ("STRING", {"default": "", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "INT", "STRING") + RETURN_NAMES = ("lines", "line_count", "report") + FUNCTION = "stack" + CATEGORY = "IAMCCS/LTX-2/Cinematic/User Friendly" + + def stack( + self, + mode, + skip_empty, + comment_header, + line_1="", + line_2="", + line_3="", + line_4="", + line_5="", + line_6="", + line_7="", + line_8="", + ): + lines: List[str] = [] + header = str(comment_header or "").strip() + if header: + for raw in header.splitlines(): + raw = raw.strip() + if raw: + lines.append(raw if raw.startswith("#") else f"# {raw}") + for value in (line_1, line_2, line_3, line_4, line_5, line_6, line_7, line_8): + for raw in str(value or "").splitlines(): + text = raw.strip() + if not text and str(skip_empty) == "yes": + continue + if text: + lines.append(text) + output = "\n".join(lines) + real_count = sum(1 for line in lines if line and not line.lstrip().startswith("#")) + report = f"line_stacker mode={mode} line_count={real_count}" + return (output, int(real_count), report) + + +class IAMCCS_LTX2_CinematicMultiGenPlanner: + DEFAULT_SHOTS = ( + "4.0 | hard_cut | 1 | 1 | field_A | Medium close-up on character A, he speaks with restrained emotion | I knew you would come back. | tired intimate low voice\n" + "3.5 | hard_cut | 2 | 2 | reverse_B | Reverse angle on character B, she listens then answers | I never really left. | calm low voice\n" + "2.5 | hard_cut | 3 | 0 | insert_photo | Close-up insert of the old photograph on the table | | \n" + "4.0 | continuity_cut | 1>2 | 1 | return_A_pushin | Return to character A, slow push-in, visible hesitation | Then why does it feel different? | quiet broken voice" + ) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "shot_index": ("INT", {"default": 1, "min": 1, "max": 100000, "step": 1}), + "render_id": ("STRING", {"default": "scene01"}), + "scene_prompt": ("STRING", { + "default": "A cinematic dialogue scene with coherent eyelines, natural acting, film lighting.", + "multiline": True, + }), + "shot_lines": ("STRING", { + "default": cls.DEFAULT_SHOTS, + "multiline": True, + "tooltip": "One shot per line: seconds | cut_mode | ref or ref>ref | audio_index | label | shot prompt | dialogue | voice direction", + }), + "fps": ("FLOAT", {"default": 24.0, "min": 0.001, "max": 240.0, "step": 0.01}), + "ltx_round_mode": (["up", "nearest", "down", "none"], {"default": "up"}), + "duration_mode": (["line_seconds", "audio_when_available", "max_line_audio"], {"default": "max_line_audio"}), + "audio_padding_seconds": ("FLOAT", {"default": 0.20, "min": 0.0, "max": 30.0, "step": 0.01}), + "default_duration_seconds": ("FLOAT", {"default": 4.0, "min": 0.01, "max": 3600.0, "step": 0.01}), + "default_cut_mode": (["hard_cut", "continuity_cut", "soft_cut", "match_cut"], {"default": "hard_cut"}), + "default_audio_assignment": (["shot_number", "none"], {"default": "shot_number"}), + "prompt_mode": (["scene_plus_shot", "shot_only"], {"default": "scene_plus_shot"}), + "default_reference_strength": ("FLOAT", {"default": 0.92, "min": 0.0, "max": 1.0, "step": 0.01}), + "tail_reference_strength": ("FLOAT", {"default": 0.82, "min": 0.0, "max": 1.0, "step": 0.01}), + "tail_anchor_mode": (["auto", "same_reference", "secondary_only", "none"], {"default": "auto"}), + "hard_cut_overlap_frames": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}), + "continuity_overlap_frames": ("INT", {"default": 9, "min": 0, "max": 4096, "step": 1}), + }, + "optional": { + "audio_1": ("AUDIO",), + "audio_2": ("AUDIO",), + "audio_3": ("AUDIO",), + "audio_4": ("AUDIO",), + "audio_5": ("AUDIO",), + "audio_6": ("AUDIO",), + "audio_7": ("AUDIO",), + "audio_8": ("AUDIO",), + }, + } + + RETURN_TYPES = ( + "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", + "INT", "INT", "FLOAT", "FLOAT", "FLOAT", "INT", "INT", "INT", "INT", + "FLOAT", "FLOAT", "STRING", "BOOLEAN", "INT", "STRING", + ) + RETURN_NAMES = ( + "multigen_plan_json", + "shot_plan_json", + "shot_prompt", + "dialogue_text", + "voice_direction", + "shot_label", + "shot_render_id", + "shot_number", + "shot_count", + "total_scene_seconds", + "shot_start_seconds", + "shot_duration_seconds", + "shot_total_frames", + "primary_reference_index", + "secondary_reference_index", + "audio_index", + "audio_start_seconds", + "audio_duration_seconds", + "cut_mode", + "use_context_bridge", + "recommended_overlap_frames", + "report", + ) + FUNCTION = "plan" + CATEGORY = "IAMCCS/LTX-2/Cinematic" + + @staticmethod + def _clean(text: Any) -> str: + return re.sub(r"\s+", " ", str(text or "").strip()) + + @staticmethod + def _safe_label(text: str, default: str) -> str: + raw = re.sub(r"[^A-Za-z0-9_-]+", "_", str(text or "").strip()).strip("_") + return raw[:64] or default + + @staticmethod + def _normalize_cut_mode(value: Any, default: str) -> str: + token = str(value or default or "hard_cut").strip().lower().replace("-", "_").replace(" ", "_") + if token in {"hard", "cut", "hardcut", "hard_cut", "none", "new_shot"}: + return "hard_cut" + if token in {"continuity", "cont", "bridge", "context", "continuity_cut", "continue"}: + return "continuity_cut" + if token in {"soft", "soft_cut", "softcut", "dissolve"}: + return "soft_cut" + if token in {"match", "match_cut", "matchcut", "graphic_match"}: + return "match_cut" + return str(default or "hard_cut") + + @staticmethod + def _parse_reference_token(token: Any, default_ref: int) -> Tuple[int, int, str]: + raw = str(token or "").strip().lower() + values = [max(1, _safe_int(v, 1)) for v in re.findall(r"\d+", raw)] + if not values: + return max(1, int(default_ref)), 0, "single" + primary = values[0] + secondary = values[1] if len(values) > 1 else 0 + if ">" in raw or "to" in raw: + mode = "start_to_end" + elif "+" in raw or "," in raw or "/" in raw: + mode = "dual" + else: + mode = "single" + return primary, secondary, mode + + @staticmethod + def _parse_audio_index(token: Any, fallback: int) -> int: + raw = str(token or "").strip().lower() + if raw in {"", "-", "none", "off", "no", "0"}: + return 0 + found = re.findall(r"\d+", raw) + if found: + return max(0, min(8, _safe_int(found[0], fallback))) + return max(0, min(8, int(fallback))) + + @staticmethod + def _audio_map(**kwargs) -> Dict[int, Any]: + return {idx: kwargs.get(f"audio_{idx}") for idx in range(1, 9)} + + def _parse_shot_lines( + self, + shot_lines: str, + default_duration_seconds: float, + default_cut_mode: str, + default_audio_assignment: str, + ) -> List[Dict[str, Any]]: + lines = [line.strip() for line in str(shot_lines or "").splitlines() if line.strip() and not line.strip().startswith("#")] + if not lines: + lines = [line.strip() for line in self.DEFAULT_SHOTS.splitlines() if line.strip()] + shots: List[Dict[str, Any]] = [] + for idx, line in enumerate(lines): + parts = [part.strip() for part in line.split("|")] + while len(parts) < 8: + parts.append("") + number = idx + 1 + duration = max(0.01, _safe_float(parts[0], default_duration_seconds)) + cut_mode = self._normalize_cut_mode(parts[1], default_cut_mode) + primary_ref, secondary_ref, ref_mode = self._parse_reference_token(parts[2], number) + fallback_audio = number if str(default_audio_assignment) == "shot_number" else 0 + audio_index = self._parse_audio_index(parts[3], fallback_audio) + label = self._clean(parts[4]) or f"shot_{number}" + shot_prompt = self._clean(parts[5]) or label + dialogue = self._clean(parts[6]) + voice = self._clean(parts[7]) + shots.append({ + "shot_number": number, + "line": line, + "requested_seconds": float(duration), + "cut_mode": cut_mode, + "primary_reference_index": int(primary_ref), + "secondary_reference_index": int(secondary_ref), + "reference_mode": ref_mode, + "audio_index": int(audio_index), + "label": label, + "shot_prompt": shot_prompt, + "dialogue_text": dialogue, + "voice_direction": voice, + }) + return shots + + def _resolve_durations( + self, + shots: List[Dict[str, Any]], + audio_inputs: Dict[int, Any], + fps: float, + ltx_round_mode: str, + duration_mode: str, + audio_padding_seconds: float, + ) -> List[Dict[str, Any]]: + cursor_frame = 0 + fps = max(0.001, float(fps)) + for shot in shots: + audio_idx = int(shot.get("audio_index", 0)) + audio_seconds_raw = _audio_duration_seconds(audio_inputs.get(audio_idx)) if audio_idx > 0 else None + audio_seconds = float(audio_seconds_raw or 0.0) + requested = max(0.01, float(shot.get("requested_seconds", 0.01))) + if str(duration_mode) == "audio_when_available" and audio_seconds_raw is not None: + target_seconds = max(0.01, audio_seconds + float(audio_padding_seconds)) + elif str(duration_mode) == "max_line_audio" and audio_seconds_raw is not None: + target_seconds = max(requested, audio_seconds + float(audio_padding_seconds)) + else: + target_seconds = requested + + raw_frames = max(1, int(round(target_seconds * fps))) + total_frames = _fix_ltx_frames(raw_frames, str(ltx_round_mode)) + duration_seconds = float(total_frames) / fps + shot["audio_seconds"] = audio_seconds + shot["duration_seconds"] = duration_seconds + shot["total_frames"] = int(total_frames) + shot["start_frame"] = int(cursor_frame) + shot["end_frame"] = int(cursor_frame + total_frames) + shot["start_seconds"] = float(cursor_frame) / fps + shot["end_seconds"] = float(cursor_frame + total_frames) / fps + shot["audio_start_seconds"] = shot["start_seconds"] + shot["audio_duration_seconds"] = audio_seconds if audio_seconds_raw is not None else duration_seconds + cursor_frame += int(total_frames) + return shots + + @staticmethod + def _recommended_overlap(cut_mode: str, hard_cut_overlap_frames: int, continuity_overlap_frames: int) -> Tuple[bool, int]: + cut_mode = str(cut_mode or "hard_cut") + if cut_mode == "hard_cut": + overlap = max(0, int(hard_cut_overlap_frames)) + return (overlap > 0, overlap) + overlap = max(0, int(continuity_overlap_frames)) + return (overlap > 0, overlap) + + def _build_shot_plan( + self, + shot: Dict[str, Any], + fps: float, + default_reference_strength: float, + tail_reference_strength: float, + tail_anchor_mode: str, + ) -> Dict[str, Any]: + total_frames = max(1, int(shot.get("total_frames", 1))) + label = str(shot.get("label", "shot")) + cut_mode = str(shot.get("cut_mode", "hard_cut")) + primary = max(1, int(shot.get("primary_reference_index", 1))) + secondary = max(0, int(shot.get("secondary_reference_index", 0))) + entries: List[Dict[str, Any]] = [] + entries.append({ + "frame": 0, + "position": 0.0, + "reference_index": primary, + "strength": _clamp(default_reference_strength, 0.0, 1.0), + "label": f"{label}_start", + "cut_mode": cut_mode, + }) + + should_tail = False + tail_ref = 0 + mode = str(tail_anchor_mode or "auto") + if mode == "same_reference": + should_tail = True + tail_ref = primary + elif mode == "secondary_only": + should_tail = secondary > 0 + tail_ref = secondary + elif mode == "auto": + if secondary > 0: + should_tail = True + tail_ref = secondary + elif cut_mode in {"continuity_cut", "soft_cut", "match_cut"}: + should_tail = True + tail_ref = primary + + if should_tail and tail_ref > 0 and total_frames > 1: + entries.append({ + "frame": total_frames - 1, + "position": 1.0, + "reference_index": int(tail_ref), + "strength": _clamp(tail_reference_strength, 0.0, 1.0), + "label": f"{label}_tail", + "cut_mode": cut_mode, + }) + + return { + "kind": "iamccs_ltx2_cinematic_shot_plan", + "version": 1, + "source": "IAMCCS_LTX2_CinematicMultiGenPlanner", + "shot_number": int(shot.get("shot_number", 1)), + "label": label, + "cut_mode": cut_mode, + "fps": float(fps), + "total_frames": int(total_frames), + "total_seconds": float(shot.get("duration_seconds", total_frames / max(0.001, float(fps)))), + "reference_mode": str(shot.get("reference_mode", "single")), + "entries": entries, + } + + def _build_prompt(self, scene_prompt: str, shot_prompt: str, prompt_mode: str) -> str: + scene = self._clean(scene_prompt) + shot = self._clean(shot_prompt) + if str(prompt_mode) == "shot_only": + return shot or scene or "A cinematic shot." + parts = [part for part in [scene, shot] if part] + return ". ".join(part.rstrip(".") for part in parts).strip() or "A cinematic shot." + + def plan( + self, + shot_index, + render_id, + scene_prompt, + shot_lines, + fps, + ltx_round_mode, + duration_mode, + audio_padding_seconds, + default_duration_seconds, + default_cut_mode, + default_audio_assignment, + prompt_mode, + default_reference_strength, + tail_reference_strength, + tail_anchor_mode, + hard_cut_overlap_frames, + continuity_overlap_frames, + audio_1=None, + audio_2=None, + audio_3=None, + audio_4=None, + audio_5=None, + audio_6=None, + audio_7=None, + audio_8=None, + ): + audio_inputs = self._audio_map( + audio_1=audio_1, + audio_2=audio_2, + audio_3=audio_3, + audio_4=audio_4, + audio_5=audio_5, + audio_6=audio_6, + audio_7=audio_7, + audio_8=audio_8, + ) + shots = self._parse_shot_lines( + shot_lines, + float(default_duration_seconds), + str(default_cut_mode), + str(default_audio_assignment), + ) + shots = self._resolve_durations( + shots, + audio_inputs, + float(fps), + str(ltx_round_mode), + str(duration_mode), + float(audio_padding_seconds), + ) + shot_count = max(1, len(shots)) + selected_idx = max(0, min(int(shot_index) - 1, shot_count - 1)) + selected = shots[selected_idx] + use_context, overlap = self._recommended_overlap( + str(selected.get("cut_mode", "hard_cut")), + int(hard_cut_overlap_frames), + int(continuity_overlap_frames), + ) + shot_plan = self._build_shot_plan( + selected, + float(fps), + float(default_reference_strength), + float(tail_reference_strength), + str(tail_anchor_mode), + ) + prompt = self._build_prompt(scene_prompt, str(selected.get("shot_prompt", "")), str(prompt_mode)) + total_scene_frames = int(max(shot.get("end_frame", 0) for shot in shots)) if shots else 0 + total_scene_seconds = float(total_scene_frames) / max(0.001, float(fps)) + safe_render = self._safe_label(render_id, "scene") + safe_label = self._safe_label(str(selected.get("label", "")), f"shot_{selected_idx + 1}") + shot_render_id = f"{safe_render}_shot_{selected_idx + 1:03d}_{safe_label}" + + plan = { + "kind": "iamccs_ltx2_cinematic_multigen_plan", + "version": 1, + "fps": float(fps), + "ltx_round_mode": str(ltx_round_mode), + "duration_mode": str(duration_mode), + "audio_padding_seconds": float(audio_padding_seconds), + "render_id": str(render_id or ""), + "shot_count": int(shot_count), + "selected_shot_index": int(selected_idx), + "total_scene_frames": int(total_scene_frames), + "total_scene_seconds": float(total_scene_seconds), + "shots": shots, + } + + report_lines = [ + f"multigen shots={shot_count} selected={selected_idx + 1}/{shot_count}", + f"cut={selected.get('cut_mode')} context={bool(use_context)} overlap={overlap}", + f"frames={selected.get('total_frames')} duration={float(selected.get('duration_seconds', 0.0)):.3f}s", + f"audio_index={selected.get('audio_index')} audio={float(selected.get('audio_seconds', 0.0)):.3f}s", + ] + return ( + _json_dumps(plan), + _json_dumps(shot_plan), + prompt, + str(selected.get("dialogue_text", "")), + str(selected.get("voice_direction", "")), + str(selected.get("label", "")), + shot_render_id, + int(selected_idx + 1), + int(shot_count), + float(total_scene_seconds), + float(selected.get("start_seconds", 0.0)), + float(selected.get("duration_seconds", 0.0)), + int(selected.get("total_frames", 1)), + int(selected.get("primary_reference_index", 1)), + int(selected.get("secondary_reference_index", 0)), + int(selected.get("audio_index", 0)), + float(selected.get("audio_start_seconds", 0.0)), + float(selected.get("audio_duration_seconds", 0.0)), + str(selected.get("cut_mode", "hard_cut")), + bool(use_context), + int(overlap), + " | ".join(report_lines), + ) + + +class IAMCCS_LTX2_CinematicShotAudioSelector: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio_index": ("INT", {"default": 1, "min": 0, "max": 8, "step": 1}), + "target_duration_seconds": ("FLOAT", {"default": 4.0, "min": 0.0, "max": 36000.0, "step": 0.01}), + "fallback": (["silent", "first_connected", "error"], {"default": "silent"}), + "trim_or_pad": (["pad_or_trim", "as_is"], {"default": "pad_or_trim"}), + "silent_sample_rate": ("INT", {"default": 44100, "min": 8000, "max": 192000, "step": 1}), + }, + "optional": { + "audio_1": ("AUDIO",), + "audio_2": ("AUDIO",), + "audio_3": ("AUDIO",), + "audio_4": ("AUDIO",), + "audio_5": ("AUDIO",), + "audio_6": ("AUDIO",), + "audio_7": ("AUDIO",), + "audio_8": ("AUDIO",), + }, + } + + RETURN_TYPES = ("AUDIO", "FLOAT", "STRING") + RETURN_NAMES = ("audio", "audio_seconds", "report") + FUNCTION = "select" + CATEGORY = "IAMCCS/LTX-2/Cinematic" + + @staticmethod + def _unwrap_audio(audio: Any) -> Optional[Dict[str, Any]]: + current = audio + for _ in range(6): + if current is None: + return None + if isinstance(current, dict): + if "waveform" in current and "sample_rate" in current: + return current + if "audio" in current: + current = current.get("audio") + continue + if len(current) == 1: + current = next(iter(current.values())) + continue + if isinstance(current, (list, tuple)) and current: + current = current[0] + continue + break + return None + + @staticmethod + def _normalize_waveform(waveform: torch.Tensor) -> torch.Tensor: + if waveform.ndim == 3: + return waveform + if waveform.ndim == 2: + return waveform.unsqueeze(0) + if waveform.ndim == 1: + return waveform.view(1, 1, -1) + raise ValueError(f"Unsupported AUDIO waveform rank: {waveform.ndim}") + + def _prepare_audio(self, audio: Dict[str, Any], target_duration_seconds: float, trim_or_pad: str) -> Dict[str, Any]: + waveform = audio.get("waveform") + sample_rate = _safe_int(audio.get("sample_rate"), 0) + if sample_rate <= 0 or not torch.is_tensor(waveform): + raise ValueError("Invalid AUDIO: expected waveform tensor and sample_rate") + waveform = self._normalize_waveform(waveform).detach().clone() + out = dict(audio) + if str(trim_or_pad) == "pad_or_trim": + target_samples = max(0, int(round(float(target_duration_seconds) * float(sample_rate)))) + current_samples = int(waveform.shape[-1]) + if target_samples <= 0: + target_samples = current_samples + if current_samples > target_samples: + waveform = waveform[:, :, :target_samples] + elif current_samples < target_samples: + pad = torch.zeros( + (waveform.shape[0], waveform.shape[1], target_samples - current_samples), + dtype=waveform.dtype, + device=waveform.device, + ) + waveform = torch.cat([waveform, pad], dim=-1) + out["waveform"] = waveform + out["sample_rate"] = int(sample_rate) + return out + + @staticmethod + def _silent_audio(target_duration_seconds: float, sample_rate: int) -> Dict[str, Any]: + samples = max(1, int(round(max(0.01, float(target_duration_seconds)) * float(sample_rate)))) + return { + "waveform": torch.zeros((1, 1, samples), dtype=torch.float32), + "sample_rate": int(sample_rate), + } + + def select( + self, + audio_index, + target_duration_seconds, + fallback, + trim_or_pad, + silent_sample_rate, + audio_1=None, + audio_2=None, + audio_3=None, + audio_4=None, + audio_5=None, + audio_6=None, + audio_7=None, + audio_8=None, + ): + audios = { + 1: audio_1, + 2: audio_2, + 3: audio_3, + 4: audio_4, + 5: audio_5, + 6: audio_6, + 7: audio_7, + 8: audio_8, + } + selected_index = max(0, min(8, int(audio_index))) + selected = self._unwrap_audio(audios.get(selected_index)) if selected_index > 0 else None + source = f"audio_{selected_index}" if selected is not None else "none" + if selected is None and str(fallback) == "first_connected": + for idx in range(1, 9): + selected = self._unwrap_audio(audios.get(idx)) + if selected is not None: + selected_index = idx + source = f"audio_{idx}" + break + if selected is None: + if str(fallback) == "error": + raise ValueError(f"No audio connected for audio_index={selected_index}") + selected = self._silent_audio(float(target_duration_seconds), int(silent_sample_rate)) + source = "silent" + + out = self._prepare_audio(selected, float(target_duration_seconds), str(trim_or_pad)) + seconds = _audio_duration_seconds(out) or 0.0 + report = f"cinematic_audio_selector source={source} index={selected_index} seconds={seconds:.3f} mode={trim_or_pad}" + return (out, float(seconds), report) + + +class IAMCCS_LTX2_CinematicV2VTimelinePlanner: + DEFAULT_TIMELINE = ( + "4.0 | hard_cut | 1 | all | 1 | 1 | field_A | V2V close-up on character A speaking, keep source motion | I knew you would come back. | tired low voice | v2v_source_plus_reference\n" + "3.5 | hard_cut | 2 | all | 2 | 2 | reverse_B | V2V reverse angle on character B answering | I never really left. | calm low voice | v2v_source_plus_reference\n" + "6.0 | continuity_cut | 1 | 48+145 | 1>2 | 0 | return_A | Continue from source video 1, slow push-in and reaction | | | two_segments_if_long" + ) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "segment_index": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}), + "render_id": ("STRING", {"default": "v2v_scene01"}), + "scene_prompt": ("STRING", { + "default": "A cinematic V2V sequence with coherent continuity, controlled cuts, natural motion.", + "multiline": True, + }), + "timeline_lines": ("STRING", { + "default": cls.DEFAULT_TIMELINE, + "multiline": True, + "tooltip": "seconds | cut_mode | source_video | source_range | reference | audio | label | prompt | dialogue | voice | v2v_mode", + }), + "fps": ("FLOAT", {"default": 24.0, "min": 0.001, "max": 240.0, "step": 0.01}), + "ltx_round_mode": (["up", "nearest", "down", "none"], {"default": "up"}), + "duration_mode": (["line_seconds", "source_range_when_known", "audio_when_available", "max_line_audio_source"], {"default": "max_line_audio_source"}), + "audio_padding_seconds": ("FLOAT", {"default": 0.20, "min": 0.0, "max": 30.0, "step": 0.01}), + "default_duration_seconds": ("FLOAT", {"default": 4.0, "min": 0.01, "max": 3600.0, "step": 0.01}), + "default_cut_mode": (["hard_cut", "continuity_cut", "soft_cut", "match_cut"], {"default": "hard_cut"}), + "default_v2v_mode": (["v2v_source_plus_reference", "v2v_source_context", "i2v_from_reference", "two_segments_if_long", "loop_if_long"], {"default": "v2v_source_plus_reference"}), + "prompt_mode": (["scene_plus_shot", "shot_only"], {"default": "scene_plus_shot"}), + "default_reference_strength": ("FLOAT", {"default": 0.88, "min": 0.0, "max": 1.0, "step": 0.01}), + "tail_reference_strength": ("FLOAT", {"default": 0.72, "min": 0.0, "max": 1.0, "step": 0.01}), + "tail_anchor_mode": (["auto", "same_reference", "secondary_only", "none"], {"default": "auto"}), + "hard_cut_overlap_frames": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}), + "continuity_overlap_frames": ("INT", {"default": 9, "min": 0, "max": 4096, "step": 1}), + "max_single_v2v_seconds": ("FLOAT", {"default": 10.0, "min": 0.1, "max": 3600.0, "step": 0.1}), + }, + "optional": { + "source_video_1": ("IMAGE",), + "source_video_2": ("IMAGE",), + "source_video_3": ("IMAGE",), + "source_video_4": ("IMAGE",), + "source_video_5": ("IMAGE",), + "source_video_6": ("IMAGE",), + "source_video_7": ("IMAGE",), + "source_video_8": ("IMAGE",), + "audio_1": ("AUDIO",), + "audio_2": ("AUDIO",), + "audio_3": ("AUDIO",), + "audio_4": ("AUDIO",), + "audio_5": ("AUDIO",), + "audio_6": ("AUDIO",), + "audio_7": ("AUDIO",), + "audio_8": ("AUDIO",), + }, + } + + RETURN_TYPES = ( + "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", + "INT", "INT", "INT", + "FLOAT", "FLOAT", "FLOAT", + "INT", "INT", "INT", "INT", "INT", "INT", "INT", "INT", + "FLOAT", "STRING", "STRING", "BOOLEAN", "INT", "STRING", + ) + RETURN_NAMES = ( + "timeline_plan_json", + "shot_plan_json", + "shot_prompt", + "dialogue_text", + "voice_direction", + "shot_label", + "shot_render_id", + "current_segment", + "total_segments", + "shot_number", + "total_scene_seconds", + "shot_start_seconds", + "shot_duration_seconds", + "shot_total_frames", + "source_video_index", + "source_start_frame", + "source_end_frame", + "source_frame_count", + "primary_reference_index", + "secondary_reference_index", + "audio_index", + "audio_duration_seconds", + "cut_mode", + "recommended_generation_mode", + "use_context_bridge", + "recommended_overlap_frames", + "report", + ) + FUNCTION = "plan" + CATEGORY = "IAMCCS/LTX-2/Cinematic" + + @staticmethod + def _clean(text: Any) -> str: + return re.sub(r"\s+", " ", str(text or "").strip()) + + @staticmethod + def _safe_label(text: str, default: str) -> str: + raw = re.sub(r"[^A-Za-z0-9_-]+", "_", str(text or "").strip()).strip("_") + return raw[:64] or default + + @staticmethod + def _image_count(images: Any) -> Optional[int]: + if torch.is_tensor(images) and images.ndim >= 4: + return int(images.shape[0]) + return None + + @staticmethod + def _normalize_v2v_mode(value: Any, default: str) -> str: + token = str(value or default or "v2v_source_plus_reference").strip().lower().replace("-", "_").replace(" ", "_") + aliases = { + "v2v": "v2v_source_plus_reference", + "source": "v2v_source_context", + "context": "v2v_source_context", + "i2v": "i2v_from_reference", + "two": "two_segments_if_long", + "2seg": "two_segments_if_long", + "two_segments": "two_segments_if_long", + "loop": "loop_if_long", + "extend": "loop_if_long", + } + token = aliases.get(token, token) + valid = {"v2v_source_plus_reference", "v2v_source_context", "i2v_from_reference", "two_segments_if_long", "loop_if_long"} + return token if token in valid else str(default or "v2v_source_plus_reference") + + @staticmethod + def _parse_video_index(token: Any, default: int) -> int: + raw = str(token or "").strip().lower() + if raw in {"", "-", "none", "off", "0"}: + return 0 + found = re.findall(r"\d+", raw) + if found: + return max(0, min(8, _safe_int(found[0], default))) + return max(0, min(8, int(default))) + + @staticmethod + def _parse_range(range_text: Any, source_total: Optional[int], fallback_frames: int) -> Tuple[int, int, int, str]: + raw = str(range_text or "").strip().lower() + total = int(source_total) if source_total is not None and int(source_total) > 0 else None + fallback = max(1, int(fallback_frames)) + + if raw in {"", "auto", "all", "*"}: + if total is not None: + return 0, total, total, "all" + return 0, fallback, fallback, "duration" + + if raw.startswith("tail") or raw.startswith("last"): + nums = re.findall(r"\d+", raw) + count = max(1, _safe_int(nums[0], fallback) if nums else fallback) + if total is None: + return 0, count, count, "tail_no_total" + end = total + start = max(0, end - count) + return start, end, max(1, end - start), "tail" + + plus_match = re.match(r"^\s*(\d+)\s*\+\s*(\d+)\s*$", raw) + if plus_match: + start = max(0, _safe_int(plus_match.group(1), 0)) + count = max(1, _safe_int(plus_match.group(2), fallback)) + end = start + count + if total is not None: + end = min(total, end) + return start, max(start + 1, end), max(1, end - start), "start_plus_count" + + range_match = re.match(r"^\s*(\d+)\s*[:-]\s*(\d+)\s*$", raw) + if range_match: + start = max(0, _safe_int(range_match.group(1), 0)) + end = max(start + 1, _safe_int(range_match.group(2), start + fallback)) + if total is not None: + end = min(total, end) + return start, max(start + 1, end), max(1, end - start), "range" + + nums = re.findall(r"\d+", raw) + if len(nums) == 1: + count = max(1, _safe_int(nums[0], fallback)) + end = count if total is None else min(total, count) + return 0, max(1, end), max(1, end), "count" + + return 0, fallback, fallback, "fallback" + + def _parse_lines(self, timeline_lines: str, default_duration_seconds: float, default_cut_mode: str, default_v2v_mode: str) -> List[Dict[str, Any]]: + lines = [line.strip() for line in str(timeline_lines or "").splitlines() if line.strip() and not line.strip().startswith("#")] + if not lines: + lines = [line.strip() for line in self.DEFAULT_TIMELINE.splitlines() if line.strip()] + shots: List[Dict[str, Any]] = [] + for idx, line in enumerate(lines): + parts = [part.strip() for part in line.split("|")] + while len(parts) < 11: + parts.append("") + number = idx + 1 + duration = max(0.01, _safe_float(parts[0], default_duration_seconds)) + cut_mode = IAMCCS_LTX2_CinematicMultiGenPlanner._normalize_cut_mode(parts[1], default_cut_mode) + source_video_index = self._parse_video_index(parts[2], number) + range_text = parts[3] + primary, secondary, ref_mode = IAMCCS_LTX2_CinematicMultiGenPlanner._parse_reference_token(parts[4], number) + audio_index = IAMCCS_LTX2_CinematicMultiGenPlanner._parse_audio_index(parts[5], number) + label = self._clean(parts[6]) or f"shot_{number}" + shot_prompt = self._clean(parts[7]) or label + dialogue = self._clean(parts[8]) + voice = self._clean(parts[9]) + v2v_mode = self._normalize_v2v_mode(parts[10], default_v2v_mode) + shots.append({ + "shot_number": number, + "line": line, + "requested_seconds": float(duration), + "cut_mode": cut_mode, + "source_video_index": int(source_video_index), + "source_range_text": range_text, + "primary_reference_index": int(primary), + "secondary_reference_index": int(secondary), + "reference_mode": ref_mode, + "audio_index": int(audio_index), + "label": label, + "shot_prompt": shot_prompt, + "dialogue_text": dialogue, + "voice_direction": voice, + "v2v_mode": v2v_mode, + }) + return shots + + def _resolve_shots( + self, + shots: List[Dict[str, Any]], + source_counts: Dict[int, Optional[int]], + audio_inputs: Dict[int, Any], + fps: float, + ltx_round_mode: str, + duration_mode: str, + audio_padding_seconds: float, + max_single_v2v_seconds: float, + ) -> List[Dict[str, Any]]: + cursor_frame = 0 + fps = max(0.001, float(fps)) + for shot in shots: + source_idx = int(shot.get("source_video_index", 0)) + source_total = source_counts.get(source_idx) + requested = max(0.01, float(shot.get("requested_seconds", 0.01))) + requested_frames = max(1, int(round(requested * fps))) + start, end, source_count, range_mode = self._parse_range( + shot.get("source_range_text", ""), + source_total, + requested_frames, + ) + + audio_idx = int(shot.get("audio_index", 0)) + audio_seconds_raw = _audio_duration_seconds(audio_inputs.get(audio_idx)) if audio_idx > 0 else None + audio_seconds = float(audio_seconds_raw or 0.0) + source_seconds = float(source_count) / fps + + if str(duration_mode) == "source_range_when_known" and source_total is not None: + target_seconds = max(0.01, source_seconds) + elif str(duration_mode) == "audio_when_available" and audio_seconds_raw is not None: + target_seconds = max(0.01, audio_seconds + float(audio_padding_seconds)) + elif str(duration_mode) == "max_line_audio_source": + candidates = [requested] + if audio_seconds_raw is not None: + candidates.append(audio_seconds + float(audio_padding_seconds)) + if source_total is not None: + candidates.append(source_seconds) + target_seconds = max(candidates) + else: + target_seconds = requested + + total_frames = _fix_ltx_frames(max(1, int(round(target_seconds * fps))), str(ltx_round_mode)) + shot["source_start_frame"] = int(start) + shot["source_end_frame"] = int(end) + shot["source_frame_count"] = int(max(1, source_count)) + shot["source_range_mode"] = range_mode + shot["source_total_frames"] = int(source_total or 0) + shot["audio_seconds"] = audio_seconds + shot["duration_seconds"] = float(total_frames) / fps + shot["total_frames"] = int(total_frames) + shot["start_frame"] = int(cursor_frame) + shot["end_frame"] = int(cursor_frame + total_frames) + shot["start_seconds"] = float(cursor_frame) / fps + shot["end_seconds"] = float(cursor_frame + total_frames) / fps + base_mode = str(shot.get("v2v_mode", "v2v_source_plus_reference")) + if float(shot["duration_seconds"]) > float(max_single_v2v_seconds): + if base_mode == "loop_if_long": + rec_mode = "loop_extend" + elif base_mode in {"two_segments_if_long", "v2v_source_plus_reference", "v2v_source_context"}: + rec_mode = "two_segments" + else: + rec_mode = "single_v2v" + else: + rec_mode = "single_v2v" if base_mode != "i2v_from_reference" else "single_i2v" + shot["recommended_generation_mode"] = rec_mode + cursor_frame += int(total_frames) + return shots + + def _build_shot_plan(self, shot: Dict[str, Any], fps: float, default_reference_strength: float, tail_reference_strength: float, tail_anchor_mode: str) -> Dict[str, Any]: + return IAMCCS_LTX2_CinematicMultiGenPlanner()._build_shot_plan( + shot, + fps, + default_reference_strength, + tail_reference_strength, + tail_anchor_mode, + ) + + def _build_prompt(self, scene_prompt: str, shot_prompt: str, prompt_mode: str) -> str: + return IAMCCS_LTX2_CinematicMultiGenPlanner()._build_prompt(scene_prompt, shot_prompt, prompt_mode) + + @staticmethod + def _recommended_overlap(cut_mode: str, hard_cut_overlap_frames: int, continuity_overlap_frames: int) -> Tuple[bool, int]: + return IAMCCS_LTX2_CinematicMultiGenPlanner._recommended_overlap(cut_mode, hard_cut_overlap_frames, continuity_overlap_frames) + + def plan( + self, + segment_index, + render_id, + scene_prompt, + timeline_lines, + fps, + ltx_round_mode, + duration_mode, + audio_padding_seconds, + default_duration_seconds, + default_cut_mode, + default_v2v_mode, + prompt_mode, + default_reference_strength, + tail_reference_strength, + tail_anchor_mode, + hard_cut_overlap_frames, + continuity_overlap_frames, + max_single_v2v_seconds, + source_video_1=None, + source_video_2=None, + source_video_3=None, + source_video_4=None, + source_video_5=None, + source_video_6=None, + source_video_7=None, + source_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, + ): + source_counts = { + 1: self._image_count(source_video_1), + 2: self._image_count(source_video_2), + 3: self._image_count(source_video_3), + 4: self._image_count(source_video_4), + 5: self._image_count(source_video_5), + 6: self._image_count(source_video_6), + 7: self._image_count(source_video_7), + 8: self._image_count(source_video_8), + } + audio_inputs = { + 1: audio_1, + 2: audio_2, + 3: audio_3, + 4: audio_4, + 5: audio_5, + 6: audio_6, + 7: audio_7, + 8: audio_8, + } + shots = self._parse_lines(timeline_lines, float(default_duration_seconds), str(default_cut_mode), str(default_v2v_mode)) + shots = self._resolve_shots( + shots, + source_counts, + audio_inputs, + float(fps), + str(ltx_round_mode), + str(duration_mode), + float(audio_padding_seconds), + float(max_single_v2v_seconds), + ) + total_segments = max(1, len(shots)) + current_segment = max(0, min(int(segment_index), total_segments - 1)) + selected = shots[current_segment] + use_context, overlap = self._recommended_overlap(str(selected.get("cut_mode", "hard_cut")), int(hard_cut_overlap_frames), int(continuity_overlap_frames)) + shot_plan = self._build_shot_plan(selected, float(fps), float(default_reference_strength), float(tail_reference_strength), str(tail_anchor_mode)) + prompt = self._build_prompt(scene_prompt, str(selected.get("shot_prompt", "")), str(prompt_mode)) + total_scene_frames = int(max(shot.get("end_frame", 0) for shot in shots)) if shots else 0 + total_scene_seconds = float(total_scene_frames) / max(0.001, float(fps)) + safe_render = self._safe_label(render_id, "v2v_scene") + safe_label = self._safe_label(str(selected.get("label", "")), f"shot_{current_segment + 1}") + shot_render_id = f"{safe_render}_seg_{current_segment + 1:03d}_{safe_label}" + + plan = { + "kind": "iamccs_ltx2_cinematic_v2v_timeline_plan", + "version": 1, + "fps": float(fps), + "render_id": str(render_id or ""), + "duration_mode": str(duration_mode), + "ltx_round_mode": str(ltx_round_mode), + "segment_index": int(current_segment), + "total_segments": int(total_segments), + "total_scene_frames": int(total_scene_frames), + "total_scene_seconds": float(total_scene_seconds), + "shots": shots, + } + report = ( + f"v2v_timeline segment={current_segment + 1}/{total_segments} " + f"src={selected.get('source_video_index')} range={selected.get('source_start_frame')}-{selected.get('source_end_frame')} " + f"ref={selected.get('primary_reference_index')} audio={selected.get('audio_index')} " + f"cut={selected.get('cut_mode')} mode={selected.get('recommended_generation_mode')} overlap={overlap}" + ) + return ( + _json_dumps(plan), + _json_dumps(shot_plan), + prompt, + str(selected.get("dialogue_text", "")), + str(selected.get("voice_direction", "")), + str(selected.get("label", "")), + shot_render_id, + int(current_segment), + int(total_segments), + int(current_segment + 1), + float(total_scene_seconds), + float(selected.get("start_seconds", 0.0)), + float(selected.get("duration_seconds", 0.0)), + int(selected.get("total_frames", 1)), + int(selected.get("source_video_index", 0)), + int(selected.get("source_start_frame", 0)), + int(selected.get("source_end_frame", 1)), + int(selected.get("source_frame_count", 1)), + int(selected.get("primary_reference_index", 1)), + int(selected.get("secondary_reference_index", 0)), + int(selected.get("audio_index", 0)), + float(selected.get("audio_seconds", 0.0)), + str(selected.get("cut_mode", "hard_cut")), + str(selected.get("recommended_generation_mode", "single_v2v")), + bool(use_context), + int(overlap), + report, + ) + + +class IAMCCS_LTX2_CinematicV2VAssetSelector: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "source_video_index": ("INT", {"default": 1, "min": 0, "max": 8, "step": 1}), + "primary_reference_index": ("INT", {"default": 1, "min": 1, "max": 8, "step": 1}), + "secondary_reference_index": ("INT", {"default": 0, "min": 0, "max": 8, "step": 1}), + "fallback": (["first_connected", "primary_reference_as_video", "error"], {"default": "first_connected"}), + }, + "optional": { + "source_video_1": ("IMAGE",), + "source_video_2": ("IMAGE",), + "source_video_3": ("IMAGE",), + "source_video_4": ("IMAGE",), + "source_video_5": ("IMAGE",), + "source_video_6": ("IMAGE",), + "source_video_7": ("IMAGE",), + "source_video_8": ("IMAGE",), + "reference_1": ("IMAGE",), + "reference_2": ("IMAGE",), + "reference_3": ("IMAGE",), + "reference_4": ("IMAGE",), + "reference_5": ("IMAGE",), + "reference_6": ("IMAGE",), + "reference_7": ("IMAGE",), + "reference_8": ("IMAGE",), + }, + } + + RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "INT", "STRING") + RETURN_NAMES = ("source_video", "primary_reference", "secondary_reference", "source_video_frames", "report") + FUNCTION = "select" + CATEGORY = "IAMCCS/LTX-2/Cinematic" + + @staticmethod + def _first_connected(items: Dict[int, Any]) -> Tuple[int, Any]: + for idx in range(1, 9): + value = items.get(idx) + if value is not None: + return idx, value + return 0, None + + @staticmethod + def _count(images: Any) -> int: + if torch.is_tensor(images) and images.ndim >= 4: + return int(images.shape[0]) + return 0 + + def select( + self, + source_video_index, + primary_reference_index, + secondary_reference_index, + fallback, + source_video_1=None, + source_video_2=None, + source_video_3=None, + source_video_4=None, + source_video_5=None, + source_video_6=None, + source_video_7=None, + source_video_8=None, + reference_1=None, + reference_2=None, + reference_3=None, + reference_4=None, + reference_5=None, + reference_6=None, + reference_7=None, + reference_8=None, + ): + videos = { + 1: source_video_1, + 2: source_video_2, + 3: source_video_3, + 4: source_video_4, + 5: source_video_5, + 6: source_video_6, + 7: source_video_7, + 8: source_video_8, + } + refs = { + 1: reference_1, + 2: reference_2, + 3: reference_3, + 4: reference_4, + 5: reference_5, + 6: reference_6, + 7: reference_7, + 8: reference_8, + } + src_idx = max(0, min(8, int(source_video_index))) + primary_idx = max(1, min(8, int(primary_reference_index))) + secondary_idx = max(0, min(8, int(secondary_reference_index))) + source = videos.get(src_idx) if src_idx > 0 else None + primary = refs.get(primary_idx) + secondary = refs.get(secondary_idx) if secondary_idx > 0 else None + + source_label = f"source_video_{src_idx}" if source is not None else "none" + if primary is None: + fallback_ref_idx, primary = self._first_connected(refs) + primary_idx = fallback_ref_idx + if primary is None: + raise ValueError("CinematicV2VAssetSelector: no primary reference connected") + if secondary is None: + secondary = primary + secondary_idx = primary_idx + + if source is None and str(fallback) == "first_connected": + fallback_src_idx, source = self._first_connected(videos) + src_idx = fallback_src_idx + source_label = f"source_video_{src_idx}" if source is not None else "none" + if source is None and str(fallback) == "primary_reference_as_video": + source = primary + source_label = "primary_reference_as_video" + if source is None: + raise ValueError(f"CinematicV2VAssetSelector: no source video connected for index={src_idx}") + + count = self._count(source) + report = f"v2v_asset_selector source={source_label} frames={count} primary_ref={primary_idx} secondary_ref={secondary_idx}" + return (source, primary, secondary, int(count), report) + + +NODE_CLASS_MAPPINGS = { + "IAMCCS_LTX2_CinematicShotPlanner": IAMCCS_LTX2_CinematicShotPlanner, + "IAMCCS_LTX2_CinematicRefLatentControl": IAMCCS_LTX2_CinematicRefLatentControl, + "IAMCCS_LTX2_AudioPromptDirector": IAMCCS_LTX2_AudioPromptDirector, + "IAMCCS_LTX2_CinematicPromptRelayAdapter": IAMCCS_LTX2_CinematicPromptRelayAdapter, + "IAMCCS_LTX2_CinematicPromptComposer": IAMCCS_LTX2_CinematicPromptComposer, + "IAMCCS_LTX2_CinematicShotLineBuilder": IAMCCS_LTX2_CinematicShotLineBuilder, + "IAMCCS_LTX2_CinematicV2VTimelineLineBuilder": IAMCCS_LTX2_CinematicV2VTimelineLineBuilder, + "IAMCCS_LTX2_CinematicLineStacker": IAMCCS_LTX2_CinematicLineStacker, + "IAMCCS_LTX2_CinematicMultiGenPlanner": IAMCCS_LTX2_CinematicMultiGenPlanner, + "IAMCCS_LTX2_CinematicShotAudioSelector": IAMCCS_LTX2_CinematicShotAudioSelector, + "IAMCCS_LTX2_CinematicV2VTimelinePlanner": IAMCCS_LTX2_CinematicV2VTimelinePlanner, + "IAMCCS_LTX2_CinematicV2VAssetSelector": IAMCCS_LTX2_CinematicV2VAssetSelector, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "IAMCCS_LTX2_CinematicShotPlanner": "LTX-2 Cinematic Shot Planner", + "IAMCCS_LTX2_CinematicRefLatentControl": "LTX-2 Cinematic Ref -> Latent", + "IAMCCS_LTX2_AudioPromptDirector": "LTX-2 Audio Prompt Director", + "IAMCCS_LTX2_CinematicPromptRelayAdapter": "LTX-2 Cinematic PromptRelay Adapter", + "IAMCCS_LTX2_CinematicPromptComposer": "LTX-2 Cinematic Prompt Composer", + "IAMCCS_LTX2_CinematicShotLineBuilder": "LTX-2 Cinematic Shot Line Builder", + "IAMCCS_LTX2_CinematicV2VTimelineLineBuilder": "LTX-2 Cinematic V2V Line Builder", + "IAMCCS_LTX2_CinematicLineStacker": "LTX-2 Cinematic Line Stacker", + "IAMCCS_LTX2_CinematicMultiGenPlanner": "LTX-2 Cinematic MultiGen Planner", + "IAMCCS_LTX2_CinematicShotAudioSelector": "LTX-2 Cinematic Shot Audio Selector", + "IAMCCS_LTX2_CinematicV2VTimelinePlanner": "LTX-2 Cinematic V2V Timeline Planner", + "IAMCCS_LTX2_CinematicV2VAssetSelector": "LTX-2 Cinematic V2V Asset Selector", +} diff --git a/iamccs_ltx2_extension_module.py b/iamccs_ltx2_extension_module.py index c8f806d..5407da4 100644 --- a/iamccs_ltx2_extension_module.py +++ b/iamccs_ltx2_extension_module.py @@ -107,8 +107,35 @@ def _promote_staged_directory(staged_dir: str, target_dir: str): shutil.rmtree(staged_dir, ignore_errors=True) +def _canonical_image_ext(path: str) -> str: + ext = os.path.splitext(path)[1].lower() + if ext == ".jpeg": + return ".jpg" + return ext + + +def _save_pil_image(image, out_path: str): + ext = _canonical_image_ext(out_path) + if ext == ".jpg": + image.convert("RGB").save(out_path, format="JPEG", quality=95) + return + if ext == ".webp": + image.convert("RGB").save(out_path, format="WEBP", quality=95) + return + image.save(out_path, format="PNG") + + def _copy_frame(src_path: str, dst_path: str): - shutil.copy2(src_path, dst_path) + src_ext = _canonical_image_ext(src_path) + dst_ext = _canonical_image_ext(dst_path) + if src_ext == dst_ext: + shutil.copy2(src_path, dst_path) + return + + from PIL import Image # type: ignore + + with Image.open(src_path) as image: + _save_pil_image(image, dst_path) def _blend_frame_pair(src_path: str, dst_path: str, out_path: str, mode: str, alpha: float): @@ -135,12 +162,12 @@ def _blend_frame_pair(src_path: str, dst_path: str, out_path: str, mode: str, al blended = (1.0 - a) * src_np + a * dst_np out = (np.clip(blended, 0.0, 1.0) * 255.0).round().astype(np.uint8) - Image.fromarray(out).save(out_path) + _save_pil_image(Image.fromarray(out), out_path) def _build_ext(path_a: str, path_b: str) -> str: ext = os.path.splitext(path_a)[1] or os.path.splitext(path_b)[1] - ext = ext.lower() + ext = _canonical_image_ext(ext) if ext not in (".png", ".jpg", ".jpeg", ".webp"): ext = ".png" return ext @@ -1599,7 +1626,7 @@ class IAMCCS_VideoCombineFromDir: seq = self._sequence_pattern(files) if seq is not None: pattern, start_number = seq - cmd = [ffmpeg, "-y", "-framerate", f"{frame_rate:.6f}", "-start_number", str(start_number), "-i", pattern] + cmd = [ffmpeg, "-nostdin", "-y", "-framerate", f"{frame_rate:.6f}", "-start_number", str(start_number), "-i", pattern] else: list_path = os.path.join(temp_dir, "frames.txt") with open(list_path, "w", encoding="utf-8") as f: @@ -1609,7 +1636,7 @@ class IAMCCS_VideoCombineFromDir: f.write(f"duration {1.0 / frame_rate:.12f}\n") escaped = files[-1].replace("'", "'\\''") f.write(f"file '{escaped}'\n") - cmd = [ffmpeg, "-y", "-f", "concat", "-safe", "0", "-i", list_path] + cmd = [ffmpeg, "-nostdin", "-y", "-f", "concat", "-safe", "0", "-i", list_path] if wav_path: cmd += ["-i", wav_path] @@ -1621,7 +1648,7 @@ class IAMCCS_VideoCombineFromDir: cmd += ["-shortest"] cmd += [out_path] - result = subprocess.run(cmd, capture_output=True, text=True) + result = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL) if result.returncode != 0: raise RuntimeError(f"ffmpeg failed: {result.stderr.strip() or result.stdout.strip()}") diff --git a/iamccs_ltx2_segment_queue.py b/iamccs_ltx2_segment_queue.py index 68753ab..24df4a0 100644 --- a/iamccs_ltx2_segment_queue.py +++ b/iamccs_ltx2_segment_queue.py @@ -108,6 +108,14 @@ def _save_latent_bridge( if torch.is_tensor(latent_reference) else None ) + if full_cpu is not None and reference_cpu is not None: + same_storage = False + try: + same_storage = full_cpu.untyped_storage().data_ptr() == reference_cpu.untyped_storage().data_ptr() + except Exception: + same_storage = full_cpu.data_ptr() == reference_cpu.data_ptr() + if same_storage: + reference_cpu = reference_cpu.clone() seed_offset_cpu = torch.tensor([int(seed_offset_latent_frames or 0)], dtype=torch.int64) overlap_latent_cpu = torch.tensor([int(overlap_latent_frames or 0)], dtype=torch.int64) if _safetensors_save_file is not None: @@ -355,6 +363,7 @@ def _concat_segments(segment_paths, output_path): cmd = [ ffmpeg_path, + "-nostdin", "-y", "-f", "concat", @@ -378,7 +387,7 @@ def _concat_segments(segment_paths, output_path): "+faststart", output_path, ] - result = subprocess.run(cmd, capture_output=True, text=True) + result = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL) try: os.remove(list_path) except Exception: @@ -486,6 +495,7 @@ def _save_segment_from_images(images, audio, frame_rate, output_path): cmd = [ ffmpeg_path, + "-nostdin", "-y", "-framerate", f"{frame_rate:.6f}", @@ -508,7 +518,7 @@ def _save_segment_from_images(images, audio, frame_rate, output_path): cmd += ["-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-shortest"] cmd += ["-movflags", "+faststart", output_path] - result = subprocess.run(cmd, capture_output=True, text=True) + result = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL) if result.returncode != 0: stderr = result.stderr.strip() or result.stdout.strip() raise RuntimeError(f"ffmpeg segment encode failed: {stderr}") diff --git a/iamccs_ltx2_tools.py b/iamccs_ltx2_tools.py index ff40179..6e5a55d 100644 --- a/iamccs_ltx2_tools.py +++ b/iamccs_ltx2_tools.py @@ -634,8 +634,8 @@ class IAMCCS_SegmentPlanner: "song_duration_s": ("FLOAT", {"default": 180.0, "min": 0.01, "max": 36000.0, "step": 0.01}), "fps": ("FLOAT", {"default": 24.0, "min": 0.001, "max": 240.0, "step": 0.01}), "segment_duration_s": ("FLOAT", {"default": 10.0, "min": 0.01, "max": 3600.0, "step": 0.01}), - "planning_mode": (["manual_segment_seconds", "auto_profile"], {"default": "manual_segment_seconds"}), - "content_profile": (["videoclip", "monologue"], {"default": "videoclip"}), + "planning_mode": (["manual_segment_seconds", "explicit_preset_seconds"], {"default": "manual_segment_seconds"}), + "segment_preset": (["5sec", "10sec", "15sec", "20sec", "videoclip", "monologue"], {"default": "15sec"}), "overlap_frames": ("INT", {"default": 9, "min": 0, "max": 4096, "step": 1}), "ltx_round_mode": (["up", "nearest", "down"], {"default": "up"}), "segment_index": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}), @@ -690,44 +690,79 @@ class IAMCCS_SegmentPlanner: return up if (up - frames) <= (frames - down) else down return up - def _recommend_profile(self, content_profile: str): - profile = str(content_profile or "videoclip") - if profile == "monologue": + def _normalize_segment_preset(self, segment_preset: str) -> str: + value = str(segment_preset or "15sec") + if value == "videoclip": + return "10sec" + if value == "monologue": + return "15sec" + if value in {"5sec", "10sec", "15sec", "20sec"}: + return value + return "15sec" + + def _normalize_planning_mode(self, planning_mode: str) -> str: + value = str(planning_mode or "manual_segment_seconds") + if value == "auto_profile": + return "explicit_preset_seconds" + if value in {"manual_segment_seconds", "explicit_preset_seconds"}: + return value + return "manual_segment_seconds" + + def _recommend_profile(self, segment_preset: str): + preset = self._normalize_segment_preset(segment_preset) + if preset == "5sec": return { - "base_target_s": 15.0, - "min_segment_s": 8.0, - "max_segment_s": 15.0, - "overlap_frames": 13, + "segment_seconds": 5.0, + "overlap_frames": 9, + "audio_left_context_s": 0.25, + "extension_preset": "videoclip_audio_24fps", + } + if preset == "20sec": + return { + "segment_seconds": 20.0, + "overlap_frames": 9, + "audio_left_context_s": 1.0, + "extension_preset": "monologue_audio_24fps", + } + if preset == "15sec": + return { + "segment_seconds": 15.0, + "overlap_frames": 9, "audio_left_context_s": 0.75, "extension_preset": "monologue_audio_24fps", } return { - "base_target_s": 10.0, - "min_segment_s": 5.0, - "max_segment_s": 10.0, + "segment_seconds": 10.0, "overlap_frames": 9, "audio_left_context_s": 0.5, "extension_preset": "videoclip_audio_24fps", } - def plan(self, song_duration_s: float, fps: float, segment_duration_s: float, planning_mode: str, content_profile: str, overlap_frames: int, ltx_round_mode: str, segment_index: int): + def _recommend_profile_for_segment_seconds(self, segment_duration_s: float): + duration = float(segment_duration_s) + for preset, target_seconds in (("5sec", 5.0), ("10sec", 10.0), ("15sec", 15.0), ("20sec", 20.0)): + if abs(duration - target_seconds) <= 0.1: + return preset, self._recommend_profile(preset) + return None, None + + def plan(self, song_duration_s: float, fps: float, segment_duration_s: float, planning_mode: str, segment_preset: str, overlap_frames: int, ltx_round_mode: str, segment_index: int): song_duration_s = max(0.01, float(song_duration_s)) fps = max(0.001, float(fps)) segment_duration_s = max(0.01, float(segment_duration_s)) overlap_frames = max(0, int(overlap_frames)) segment_index = max(0, int(segment_index)) - planning_mode = str(planning_mode or "manual_segment_seconds") - content_profile = str(content_profile or "videoclip") + planning_mode = self._normalize_planning_mode(planning_mode) + segment_preset = self._normalize_segment_preset(segment_preset) - rec = self._recommend_profile(content_profile) + rec = self._recommend_profile(segment_preset) + auto_duration_profile = None effective_planning_mode = planning_mode - if planning_mode == "auto_profile": - target_s = float(rec["base_target_s"]) - estimated_segments_auto = max(1, int(math.ceil(song_duration_s / max(0.01, target_s)))) - auto_segment_s = song_duration_s / float(estimated_segments_auto) - auto_segment_s = max(float(rec["min_segment_s"]), min(float(rec["max_segment_s"]), auto_segment_s)) - segment_duration_s = max(0.01, float(auto_segment_s)) - overlap_frames = int(rec["overlap_frames"]) + if planning_mode == "explicit_preset_seconds": + segment_duration_s = max(0.01, float(rec["segment_seconds"])) + else: + auto_duration_profile, auto_rec = self._recommend_profile_for_segment_seconds(segment_duration_s) + if auto_rec is not None: + rec = auto_rec total_frames = max(1, int(round(song_duration_s * fps))) unique_segment_frames = max(1, int(round(segment_duration_s * fps))) @@ -761,7 +796,7 @@ class IAMCCS_SegmentPlanner: f"overlap={overlap_frames}f | first_raw={first_segment_raw_frames}f | " f"continuation_raw={continuation_raw_frames}f | segments={estimated_segments} | " f"loops={continuation_loops} | last_unique={last_segment_unique_frames}f | ltx_round={ltx_round_mode} | " - f"planning={effective_planning_mode} | profile={content_profile}" + f"planning={effective_planning_mode} | segment_preset={segment_preset}" ) current_segment_report = ( @@ -771,9 +806,9 @@ class IAMCCS_SegmentPlanner: ) planning_profile_report = ( - f"profile={content_profile} | planning_mode={effective_planning_mode} | segment_duration_s={segment_duration_s:.3f} | " + f"segment_preset={segment_preset} | planning_mode={effective_planning_mode} | segment_duration_s={segment_duration_s:.3f} | " f"recommended_overlap={recommended_overlap_frames}f | recommended_left_context={recommended_audio_left_context_s:.2f}s | " - f"recommended_extension_preset={recommended_extension_preset}" + f"recommended_extension_preset={recommended_extension_preset} | auto_duration_profile={auto_duration_profile or 'none'}" ) return ( @@ -803,6 +838,99 @@ class IAMCCS_SegmentPlanner: ) +class IAMCCS_SegmentPlannerSettings(IAMCCS_SegmentPlanner): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "segment_duration_s": ("FLOAT", {"default": 10.0, "min": 0.01, "max": 3600.0, "step": 0.01}), + "planning_mode": (["manual_segment_seconds", "explicit_preset_seconds"], {"default": "manual_segment_seconds"}), + "segment_preset": (["5sec", "10sec", "15sec", "20sec", "videoclip", "monologue"], {"default": "10sec"}), + "overlap_frames": ("INT", {"default": 9, "min": 0, "max": 4096, "step": 1}), + "auto_sync_overlap": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("FLOAT", "STRING", "STRING", "INT", "STRING") + RETURN_NAMES = ("segment_duration_s", "planning_mode", "segment_preset", "overlap_frames", "report") + FUNCTION = "build" + CATEGORY = "IAMCCS/LTX-2" + + def build(self, segment_duration_s: float, planning_mode: str, segment_preset: str, overlap_frames: int, auto_sync_overlap: bool): + segment_duration_s = max(0.01, float(segment_duration_s)) + overlap_frames = max(0, int(overlap_frames)) + planning_mode = self._normalize_planning_mode(planning_mode) + segment_preset = self._normalize_segment_preset(segment_preset) + + rec = self._recommend_profile(segment_preset) + auto_duration_profile = None + if planning_mode == "explicit_preset_seconds": + segment_duration_s = max(0.01, float(rec["segment_seconds"])) + elif auto_sync_overlap: + auto_duration_profile, auto_rec = self._recommend_profile_for_segment_seconds(segment_duration_s) + if auto_rec is not None: + rec = auto_rec + + report = ( + f"segment_duration_s={segment_duration_s:.3f} | planning_mode={planning_mode} | " + f"segment_preset={segment_preset} | overlap_frames={overlap_frames} | " + f"auto_sync_overlap={bool(auto_sync_overlap)} | auto_duration_profile={auto_duration_profile or 'none'} | " + "overlap_policy=manual/default_9" + ) + + return ( + float(segment_duration_s), + str(planning_mode), + str(segment_preset), + int(overlap_frames), + report, + ) + + +class IAMCCS_SegmentPlannerLinked(IAMCCS_SegmentPlanner): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "song_duration_s": ("FLOAT", {"default": 180.0, "min": 0.01, "max": 36000.0, "step": 0.01}), + "fps": ("FLOAT", {"default": 24.0, "min": 0.001, "max": 240.0, "step": 0.01}), + "segment_duration_s": ("FLOAT", {"default": 10.0, "min": 0.01, "max": 3600.0, "step": 0.01, "forceInput": True}), + "planning_mode_in": ("STRING", {"default": "manual_segment_seconds", "forceInput": True}), + "segment_preset_in": ("STRING", {"default": "10sec", "forceInput": True}), + "overlap_frames_in": ("INT", {"default": 9, "min": 0, "max": 4096, "step": 1, "forceInput": True}), + "ltx_round_mode": (["up", "nearest", "down"], {"default": "up"}), + "segment_index": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}), + } + } + + RETURN_TYPES = IAMCCS_SegmentPlanner.RETURN_TYPES + RETURN_NAMES = IAMCCS_SegmentPlanner.RETURN_NAMES + FUNCTION = "plan_linked" + CATEGORY = "IAMCCS/LTX-2" + + def plan_linked( + self, + song_duration_s: float, + fps: float, + segment_duration_s: float, + planning_mode_in: str, + segment_preset_in: str, + overlap_frames_in: int, + ltx_round_mode: str, + segment_index: int, + ): + return self.plan( + song_duration_s, + fps, + segment_duration_s, + str(planning_mode_in or "manual_segment_seconds"), + str(segment_preset_in or "10sec"), + int(overlap_frames_in), + ltx_round_mode, + segment_index, + ) + + class IAMCCS_SegmentPlanFromPlanner: @classmethod def INPUT_TYPES(cls): diff --git a/iamccs_supernode_modular.py b/iamccs_supernode_modular.py new file mode 100644 index 0000000..7fbd19c --- /dev/null +++ b/iamccs_supernode_modular.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from typing import Any + +try: + from .iamccs_flexible_inputs import FlexibleOptionalInputType, any_type +except ImportError: + from iamccs_flexible_inputs import FlexibleOptionalInputType, any_type + + +_SUPERNODE_CONTRACT_TYPE = "IAMCCS_SUPERNODE_CONTRACT" +_SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX" +_MODULE_INPUT_SLOTS = 6 + + +def _parse_contract(payload: Any) -> dict[str, Any]: + if isinstance(payload, dict): + return dict(payload) + + parsed: dict[str, Any] = {} + if payload is None: + return parsed + + for item in str(payload).split(";"): + item = item.strip() + if not item or "=" not in item: + continue + key, value = item.split("=", 1) + parsed[key.strip()] = value.strip() + return parsed + + +def _build_dynamic_optional_slots(type_name: str) -> FlexibleOptionalInputType: + data = {f"module_{index:02d}": (type_name,) for index in range(1, _MODULE_INPUT_SLOTS + 1)} + return FlexibleOptionalInputType(type_name, data=data) + + +def _collect_named_inputs(kwargs: dict[str, Any], prefix: str) -> dict[str, Any]: + collected: dict[str, Any] = {} + for key, value in kwargs.items(): + if not key.startswith(prefix): + continue + if value is None: + continue + collected[key] = value + return collected + + +def _build_linx_payload( + existing_linx: Any, + *, + pipeline_kind: str, + node_role: str, + node_label: str, + unique_id: Any, + upstream_contract: dict[str, Any], + module_inputs: dict[str, Any], +) -> dict[str, Any]: + if isinstance(existing_linx, dict): + linx_payload = dict(existing_linx) + nodes = list(existing_linx.get("nodes") or []) + else: + linx_payload = {} + nodes = [] + + linx_payload["type"] = _SUPERNODE_LINX_TYPE + linx_payload["pipeline_kind"] = pipeline_kind + linx_payload["root_contract"] = upstream_contract.get("pipeline_key", pipeline_kind) + linx_payload["nodes"] = nodes + linx_payload["nodes"].append( + { + "id": str(unique_id or node_label or node_role), + "role": node_role, + "label": node_label, + "module_keys": sorted(module_inputs.keys()), + } + ) + return linx_payload + + +class IAMCCS_SupernodeBase: + @classmethod + def INPUT_TYPES(cls): + optional = { + "upstream_contract": (_SUPERNODE_CONTRACT_TYPE,), + "linx": (_SUPERNODE_LINX_TYPE,), + "planner_payload": ("STRING",), + "backend_payload": ("STRING",), + "continuity_payload": ("STRING",), + "second_stage_payload": ("STRING",), + } + optional.update(_build_dynamic_optional_slots(_SUPERNODE_CONTRACT_TYPE)) + return { + "required": { + "pipeline_kind": (["v2v", "i2v_flf", "au_img2vid", "audio_concat", "wan_flf", "wan_continuity"],), + "surface_profile": (["compact", "progressive", "debug_surface"], {"default": "compact"}), + "backend_binding": (["ltx_v2v_disk", "ltx_audio_guided_lowram", "ltx_i2v_flf", "wan_continuity", "wan_flf", "custom"], {"default": "custom"}), + "node_label": ("STRING", {"default": "Base Supernode"}), + "notes": ("STRING", {"default": "", "multiline": True}), + }, + "optional": optional, + "hidden": { + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = (_SUPERNODE_CONTRACT_TYPE, _SUPERNODE_LINX_TYPE, "STRING") + RETURN_NAMES = ("contract", "linx", "report") + FUNCTION = "build" + CATEGORY = "IAMCCS/Supernodes" + + def build( + self, + pipeline_kind, + surface_profile, + backend_binding, + node_label, + notes="", + upstream_contract=None, + linx=None, + planner_payload=None, + backend_payload=None, + continuity_payload=None, + second_stage_payload=None, + unique_id=None, + **kwargs, + ): + upstream = _parse_contract(upstream_contract) + module_inputs = _collect_named_inputs(kwargs, "module_") + contract = { + "type": _SUPERNODE_CONTRACT_TYPE, + "pipeline_key": f"{pipeline_kind}:{node_label}", + "pipeline_kind": pipeline_kind, + "surface_profile": surface_profile, + "backend_binding": backend_binding, + "role": "base", + "label": str(node_label or "Base Supernode"), + "notes": str(notes or ""), + "payloads": { + "planner_payload": planner_payload, + "backend_payload": backend_payload, + "continuity_payload": continuity_payload, + "second_stage_payload": second_stage_payload, + }, + "module_inputs": sorted(module_inputs.keys()), + "upstream": upstream, + } + linx_payload = _build_linx_payload( + linx, + pipeline_kind=pipeline_kind, + node_role="base", + node_label=str(node_label or "Base Supernode"), + unique_id=unique_id, + upstream_contract=contract, + module_inputs=module_inputs, + ) + report = ( + f"supernode_base pipeline={pipeline_kind} | surface={surface_profile} | backend_binding={backend_binding} | " + f"linked_modules={','.join(sorted(module_inputs.keys())) or 'none'} | upstream={upstream.get('pipeline_key', 'none')}" + ) + return (contract, linx_payload, report) + + +class IAMCCS_SupernodeModule: + @classmethod + def INPUT_TYPES(cls): + optional = { + "parent_contract": (_SUPERNODE_CONTRACT_TYPE,), + "linx": (_SUPERNODE_LINX_TYPE,), + "payload": ("STRING",), + } + optional.update(_build_dynamic_optional_slots(any_type)) + return { + "required": { + "pipeline_kind": (["v2v", "i2v_flf", "au_img2vid", "audio_concat", "wan_flf", "wan_continuity"],), + "module_role": (["planner", "backend", "continuity", "audio", "concat", "loop", "second_stage", "output", "custom"],), + "module_mode": (["consume", "augment", "branch"], {"default": "augment"}), + "node_label": ("STRING", {"default": "Module"}), + "module_key": ("STRING", {"default": "module"}), + }, + "optional": optional, + "hidden": { + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = (_SUPERNODE_CONTRACT_TYPE, _SUPERNODE_LINX_TYPE, "STRING") + RETURN_NAMES = ("contract", "linx", "report") + FUNCTION = "build" + CATEGORY = "IAMCCS/Supernodes" + + def build( + self, + pipeline_kind, + module_role, + module_mode, + node_label, + module_key, + parent_contract=None, + linx=None, + payload=None, + unique_id=None, + **kwargs, + ): + parent = _parse_contract(parent_contract) + module_inputs = _collect_named_inputs(kwargs, "module_") + contract = { + "type": _SUPERNODE_CONTRACT_TYPE, + "pipeline_key": parent.get("pipeline_key", pipeline_kind), + "pipeline_kind": pipeline_kind, + "role": str(module_role or "custom"), + "mode": str(module_mode or "augment"), + "label": str(node_label or module_role or "Module"), + "module_key": str(module_key or "module"), + "payload": payload, + "module_inputs": sorted(module_inputs.keys()), + "parent": parent, + } + linx_payload = _build_linx_payload( + linx, + pipeline_kind=pipeline_kind, + node_role=str(module_role or "custom"), + node_label=str(node_label or module_role or "Module"), + unique_id=unique_id, + upstream_contract=contract, + module_inputs=module_inputs, + ) + report = ( + f"supernode_module pipeline={pipeline_kind} | role={module_role} | mode={module_mode} | key={module_key} | " + f"linked_inputs={','.join(sorted(module_inputs.keys())) or 'none'} | parent={parent.get('label', 'none')}" + ) + return (contract, linx_payload, report) \ No newline at end of file diff --git a/iamccs_supernodes_backend_panels.py b/iamccs_supernodes_backend_panels.py new file mode 100644 index 0000000..4b41dcb --- /dev/null +++ b/iamccs_supernodes_backend_panels.py @@ -0,0 +1,71 @@ +def backend_panel_input_types(): + return { + "backend_panel_mode": (["compact", "show_backend_settings"],), + "backend_profile_preset": ( + ["inherit", "quality_first", "balanced", "speed_first", "low_vram_safe", "audio_sync_strict"], + ), + "backend_memory_mode": ( + ["inherit", "auto", "normal_vram", "low_ram_disk", "cpu_offload_safe"], + ), + "backend_quality_mode": ( + ["inherit", "draft", "balanced", "high_detail", "identity_strict", "temporal_stable"], + ), + "backend_settings_override": ("STRING", {"default": "", "multiline": True}), + } + + +def merge_backend_panel_inputs(required_inputs): + merged = dict(required_inputs) + merged.update(backend_panel_input_types()) + return merged + + +def _sanitize_override(value): + return str(value or "").replace(";", ",").strip() or "none" + + +def build_backend_panel_payload( + backend_panel_mode, + backend_profile_preset, + backend_memory_mode, + backend_quality_mode, + backend_settings_override, +): + sanitized_override = _sanitize_override(backend_settings_override) + payload = ( + f"backend_panel_mode={backend_panel_mode}; backend_profile_preset={backend_profile_preset}; " + f"backend_memory_mode={backend_memory_mode}; backend_quality_mode={backend_quality_mode}; " + f"backend_settings_override={sanitized_override}" + ) + summary = ( + f"backend_panel={backend_panel_mode} | preset={backend_profile_preset} | " + f"memory={backend_memory_mode} | quality={backend_quality_mode}" + ) + return payload, summary + + +def append_backend_panel_to_payload_and_report( + payload, + report, + backend_panel_mode, + backend_profile_preset, + backend_memory_mode, + backend_quality_mode, + backend_settings_override, +): + panel_payload, panel_summary = build_backend_panel_payload( + backend_panel_mode, + backend_profile_preset, + backend_memory_mode, + backend_quality_mode, + backend_settings_override, + ) + if payload: + payload = f"{payload}; {panel_payload}" + else: + payload = panel_payload + if report: + report = f"{report} | {panel_summary}" + else: + report = panel_summary + return payload, report \ No newline at end of file diff --git a/iamccs_supernodes_exec.py b/iamccs_supernodes_exec.py new file mode 100644 index 0000000..54ec233 --- /dev/null +++ b/iamccs_supernodes_exec.py @@ -0,0 +1,25 @@ +from .iamccs_supernodes_auimg2vid_exec_backend import ( + IAMCCS_GC_AUIMG2VIDExecutableFinalize as _GCFinalize, + IAMCCS_GC_AUIMG2VIDExecutablePlanner as _GCPlanner, + IAMCCS_GC_AUIMG2VIDExecutableRender as _GCRender, + IAMCCS_GC_AUIMG2VIDExecutableVAE as _GCVAE, +) + + +class IAMCCS_SuperNodes_AUIMG2VIDExecutablePlanner(_GCPlanner): + CATEGORY = "IAMCCS/SuperNodes" + + +class IAMCCS_SuperNodes_AUIMG2VIDExecutableRender(_GCRender): + CATEGORY = "IAMCCS/SuperNodes" + + +class IAMCCS_SuperNodes_AUIMG2VIDExecutableVAE(_GCVAE): + CATEGORY = "IAMCCS/SuperNodes" + + +if _GCFinalize is not None: + class IAMCCS_SuperNodes_AUIMG2VIDExecutableFinalize(_GCFinalize): + CATEGORY = "IAMCCS/SuperNodes" +else: + IAMCCS_SuperNodes_AUIMG2VIDExecutableFinalize = None \ No newline at end of file diff --git a/iamccs_supernodes_linx.py b/iamccs_supernodes_linx.py new file mode 100644 index 0000000..25a05f3 --- /dev/null +++ b/iamccs_supernodes_linx.py @@ -0,0 +1,136 @@ +SUPERNODE_LINX_TYPE = "IAMCCS_SUPERNODE_LINX" + + +def _clone_chain(existing_linx): + if not isinstance(existing_linx, dict): + return [] + chain = existing_linx.get("chain") or [] + return [dict(item) for item in chain if isinstance(item, dict)] + + +def _clone_dict(existing_linx, key): + if not isinstance(existing_linx, dict): + return {} + value = existing_linx.get(key) or {} + if not isinstance(value, dict): + return {} + return dict(value) + + +def _clone_resources(existing_linx): + if not isinstance(existing_linx, dict): + return {} + value = existing_linx.get("resources") or {} + if not isinstance(value, dict): + return {} + return dict(value) + + +def linx_resource(existing_linx, key, default=None): + if not isinstance(existing_linx, dict): + return default + resources = existing_linx.get("resources") or {} + if not isinstance(resources, dict): + return default + return resources.get(key, default) + + +def linx_output(existing_linx, key, default=None): + if not isinstance(existing_linx, dict): + return default + outputs = existing_linx.get("outputs") or {} + if not isinstance(outputs, dict): + return default + return outputs.get(key, default) + + +def linx_policy(existing_linx, key, default=None): + if not isinstance(existing_linx, dict): + return default + policies = existing_linx.get("policies") or {} + if not isinstance(policies, dict): + return default + return policies.get(key, default) + + +def build_supernode_linx_payload(existing_linx, node_role, payload, report, unique_id=None): + if isinstance(existing_linx, dict): + linx_payload = dict(existing_linx) + chain = _clone_chain(existing_linx) + else: + linx_payload = {} + chain = [] + + linx_payload["type"] = SUPERNODE_LINX_TYPE + linx_payload["chain"] = chain + linx_payload["chain"].append( + { + "id": str(unique_id or node_role), + "role": str(node_role), + "payload_preview": str(payload or "")[:240], + "report_preview": str(report or "")[:240], + } + ) + return linx_payload + + +def build_stage_linx_payload( + existing_linx, + stage_name, + stage_kind, + payload, + report, + unique_id=None, + slot_map=None, + downstream_stages=None, + policies=None, + outputs=None, + resources=None, +): + linx_payload = build_supernode_linx_payload(existing_linx, stage_name, payload, report, unique_id=unique_id) + stage_entry = { + "id": str(unique_id or stage_name), + "name": str(stage_name), + "kind": str(stage_kind), + "payload": dict(payload) if isinstance(payload, dict) else {"preview": str(payload or "")[:240]}, + "report_preview": str(report or "")[:240], + } + + stages = list(linx_payload.get("stages") or []) + stages.append(stage_entry) + linx_payload["stages"] = stages + linx_payload["stage_count"] = len(stages) + + merged_slot_map = _clone_dict(linx_payload, "slot_map") + if isinstance(slot_map, dict): + merged_slot_map.update(slot_map) + if merged_slot_map: + linx_payload["slot_map"] = merged_slot_map + + merged_policies = _clone_dict(linx_payload, "policies") + if isinstance(policies, dict): + merged_policies.update(policies) + if merged_policies: + linx_payload["policies"] = merged_policies + + merged_outputs = _clone_dict(linx_payload, "outputs") + if isinstance(outputs, dict): + merged_outputs.update(outputs) + if merged_outputs: + linx_payload["outputs"] = merged_outputs + + merged_resources = _clone_resources(linx_payload) + if isinstance(resources, dict): + for resource_key, resource_value in resources.items(): + if resource_value is not None: + merged_resources[str(resource_key)] = resource_value + if merged_resources: + linx_payload["resources"] = merged_resources + linx_payload["resource_keys"] = sorted(str(item) for item in merged_resources.keys()) + + if downstream_stages is not None: + linx_payload["downstream_stages"] = [str(item) for item in downstream_stages if str(item or "").strip()] + + linx_payload["active_stage"] = str(stage_name) + linx_payload["active_stage_kind"] = str(stage_kind) + return linx_payload \ No newline at end of file diff --git a/iamccs_supernodes_second_stage.py b/iamccs_supernodes_second_stage.py new file mode 100644 index 0000000..aaa8300 --- /dev/null +++ b/iamccs_supernodes_second_stage.py @@ -0,0 +1,214 @@ +import logging + +from .iamccs_supernodes_backend_panels import append_backend_panel_to_payload_and_report, merge_backend_panel_inputs +from .iamccs_supernodes_linx import SUPERNODE_LINX_TYPE, build_stage_linx_payload, linx_resource + +_log = logging.getLogger("IAMCCS.SuperNodes.SecondStage") + +try: + import folder_paths # type: ignore + + _LATENT_UPSCALE_MODEL_NAMES = tuple(folder_paths.get_filename_list("latent_upscale_models")) +except Exception: + _LATENT_UPSCALE_MODEL_NAMES = ( + "ltx-2.3-spatial-upscaler-x2-1.1.safetensors", + "ltx-2.3-spatial-upscaler-x2-1.0.safetensors", + ) + + +def _count_sigma_steps(sigmas_text): + values = [] + for item in str(sigmas_text or "").replace("\n", ",").split(","): + try: + values.append(float(item.strip())) + except Exception: + pass + return max(0, len(values) - 1) + + +class IAMCCS_SuperNodes_SecondStage: + CATEGORY = "IAMCCS/SuperNodes" + FUNCTION = "build_second_stage" + RETURN_TYPES = (SUPERNODE_LINX_TYPE, "STRING") + RETURN_NAMES = ("linx", "report") + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + **merge_backend_panel_inputs({ + "second_stage_mode": (["off", "latent_upscale_refine", "latent_upscale_refine_x2_beta"],), + "stage2_model_policy": (["replace_stage1_if_connected", "keep_stage1_model", "prefer_stage2_else_primary"],), + "second_stage_upscale_model": (_LATENT_UPSCALE_MODEL_NAMES, {"default": _LATENT_UPSCALE_MODEL_NAMES[0]}), + "second_stage_reinject_strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "second_stage_cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 30.0, "step": 0.1}), + "second_stage_manual_sigmas": ("STRING", {"default": "0.909375, 0.725, 0.421875, 0.0"}), + }), + }, + "optional": { + "stage2_model": ("MODEL", {"lazy": True}), + "linx": (SUPERNODE_LINX_TYPE,), + "model": ("MODEL", {"lazy": True}), + }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, + } + + def check_lazy_status(self, second_stage_mode, stage2_model=None, model=None, **kwargs): + if str(second_stage_mode) == "off": + return [] + needed = [] + if stage2_model is None: + needed.append("stage2_model") + return needed + + def build_second_stage( + self, + second_stage_mode, + stage2_model_policy, + second_stage_upscale_model, + second_stage_reinject_strength, + second_stage_cfg, + second_stage_manual_sigmas, + backend_panel_mode="compact", + backend_profile_preset="inherit", + backend_memory_mode="inherit", + backend_quality_mode="inherit", + backend_settings_override="", + stage2_model=None, + linx=None, + model=None, + unique_id=None, + ): + second_stage_enabled = str(second_stage_mode) != "off" + if not second_stage_enabled: + second_stage_payload = "second_stage_mode=off; second_stage_enabled=false" + report = "Second stage. mode=off | inactive | no model/resource exported | stage1 render path unchanged." + second_stage_payload, report = append_backend_panel_to_payload_and_report( + second_stage_payload, + report, + backend_panel_mode, + backend_profile_preset, + backend_memory_mode, + backend_quality_mode, + backend_settings_override, + ) + linx_payload = build_stage_linx_payload( + linx, + "second_stage", + "second_stage", + { + "second_stage_mode": "off", + "second_stage_enabled": False, + "second_stage_payload": second_stage_payload, + }, + report, + unique_id=unique_id, + slot_map={ + "linx": {"type": SUPERNODE_LINX_TYPE, "role": "stage_linx"}, + }, + downstream_stages=["render", "vae"], + policies={ + "second_stage_enabled": False, + "stage2_model_policy": str(stage2_model_policy), + "second_stage_model_source": "disabled", + "second_stage_scale_mode": "off", + }, + outputs={ + "second_stage_payload": second_stage_payload, + "second_stage_enabled": False, + "second_stage_steps": 0, + "second_stage_scale_mode": "off", + "stage2_model_connected": False, + "second_stage_model_source": "disabled", + "anchor_refresh_requires_second_stage": False, + }, + ) + _log.info("[IAMCCS Second Stage] %s", report) + return (linx_payload, report) + + primary_model = linx_resource(linx, "model") + connected_stage2_model = model if model is not None else stage2_model + selected_model = primary_model + policy = str(stage2_model_policy) + if connected_stage2_model is not None: + if policy in {"replace_stage1_if_connected", "prefer_stage2_else_primary"}: + selected_model = connected_stage2_model + elif selected_model is None: + selected_model = connected_stage2_model + + stage2_steps = _count_sigma_steps(second_stage_manual_sigmas) + model_source = "external_detailer_model" if connected_stage2_model is not None and selected_model is connected_stage2_model else "stage1_model" + scale_mode = "x2_latent_upscale_beta" if str(second_stage_mode) == "latent_upscale_refine_x2_beta" else "same_resolution_refine" + + second_stage_payload = ( + f"second_stage_mode={second_stage_mode}; stage2_model_policy={stage2_model_policy}; " + f"second_stage_upscale_model={second_stage_upscale_model}; second_stage_scale_mode={scale_mode}; " + f"second_stage_reinject_strength={second_stage_reinject_strength}; " + f"second_stage_cfg={second_stage_cfg}; second_stage_manual_sigmas={second_stage_manual_sigmas}; " + f"second_stage_steps={int(stage2_steps)}; second_stage_model_source={model_source}" + ) + report = ( + f"Second stage. mode={second_stage_mode} | scale={scale_mode} | policy={stage2_model_policy} | stage2_steps={int(stage2_steps)} | " + f"stage1_model_in_linx={'yes' if primary_model is not None else 'no'} | detailer_model_connected={'yes' if connected_stage2_model is not None else 'no'} | " + f"model_source={model_source} | anchor_refresh_note=render anchor refresh affects the per-segment second-stage reinjection path." + ) + _log.info("[IAMCCS Second Stage] %s", report) + second_stage_payload, report = append_backend_panel_to_payload_and_report( + second_stage_payload, + report, + backend_panel_mode, + backend_profile_preset, + backend_memory_mode, + backend_quality_mode, + backend_settings_override, + ) + linx_payload = build_stage_linx_payload( + linx, + "second_stage", + "second_stage", + { + "second_stage_mode": str(second_stage_mode), + "stage2_model_policy": str(stage2_model_policy), + "second_stage_upscale_model": str(second_stage_upscale_model), + "second_stage_scale_mode": str(scale_mode), + "second_stage_reinject_strength": float(second_stage_reinject_strength), + "second_stage_cfg": float(second_stage_cfg), + "second_stage_manual_sigmas": str(second_stage_manual_sigmas), + "second_stage_payload": second_stage_payload, + "second_stage_steps": int(stage2_steps), + "second_stage_model_source": model_source, + "anchor_refresh_requires_second_stage": False, + }, + report, + unique_id=unique_id, + slot_map={ + "linx": {"type": SUPERNODE_LINX_TYPE, "role": "stage_linx"}, + }, + downstream_stages=["render", "vae"], + policies={ + "second_stage_enabled": str(second_stage_mode) != "off", + "stage2_model_policy": str(stage2_model_policy), + "second_stage_model_source": model_source, + "second_stage_scale_mode": str(scale_mode), + }, + outputs={ + "second_stage_payload": second_stage_payload, + "second_stage_enabled": str(second_stage_mode) != "off", + "second_stage_steps": int(stage2_steps), + "second_stage_scale_mode": str(scale_mode), + "stage2_model_connected": bool(connected_stage2_model is not None), + "second_stage_model_source": model_source, + "anchor_refresh_requires_second_stage": False, + }, + resources={ + "second_stage_payload": second_stage_payload, + "second_stage_model": selected_model, + }, + ) + return (linx_payload, report) diff --git a/iamccs_wan_long_length.py b/iamccs_wan_long_length.py index e8108f2..4334389 100644 --- a/iamccs_wan_long_length.py +++ b/iamccs_wan_long_length.py @@ -2,6 +2,8 @@ import logging import math import re +import node_helpers + log = logging.getLogger("IAMCCS.WanLongLength") @@ -376,7 +378,7 @@ class IAMCCS_WanIndexedPromptEncode: "required": { "clip": ("CLIP",), "text": ("STRING", {"multiline": True, "default": ""}), - "segment_index": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}), + "segment_index": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1, "lazy": True}), "index_offset": ("INT", {"default": 0, "min": -100000, "max": 100000, "step": 1}), "selection_mode": (["direct_index", "three_phase", "cyclic_3", "single"], {"default": "direct_index"}), "phase1_last_segment": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}), @@ -405,6 +407,13 @@ class IAMCCS_WanIndexedPromptEncode: FUNCTION = "encode" CATEGORY = "IAMCCS/Wan" + def check_lazy_status(self, segment_index=None, **kwargs): + # Break static validation cycles when segment_index is wired to an easy-use + # loop index, but force the executor to resolve it before encode() runs. + if segment_index is None: + return ["segment_index"] + return [] + def _pick_index(self, prompts: list[str], segment_index: int, selection_mode: str, phase1_last_segment: int, phase2_last_segment: int, fallback_mode: str) -> int: count = len(prompts) if count <= 0: @@ -467,6 +476,8 @@ class IAMCCS_WanIndexedPromptEncode: fps=16.0, bootstrap_outside_loop=True, ): + if segment_index is None: + segment_index = 0 effective_index = int(segment_index) + int(index_offset) prompts = _split_prompt_bank(text, separator_mode) selected_index = self._pick_index(prompts, effective_index, selection_mode, int(phase1_last_segment), int(phase2_last_segment), str(fallback_mode)) @@ -485,6 +496,17 @@ class IAMCCS_WanIndexedPromptEncode: fps, bool(bootstrap_outside_loop), ) + conditioning = node_helpers.conditioning_set_values( + conditioning, + { + "_iamccs_generation_index": int(effective_index), + "_iamccs_prompt_index": int(max(selected_index, 0)), + "_iamccs_prompt_count": int(len(prompts)), + "_iamccs_total_generations": int(total_generations), + "_iamccs_continuation_generations": int(continuation_generations), + "_iamccs_prompt_selection_mode": str(selection_mode or "direct_index"), + }, + ) report = ( f"segment={int(segment_index)} | effective_index={effective_index} | mode={selection_mode} | prompt_index={selected_index} | " f"prompt_count={len(prompts)} | continuations={continuation_generations} | selected={'yes' if selected_prompt else 'empty'}" diff --git a/iamccs_wan_lora_hooks.py b/iamccs_wan_lora_hooks.py new file mode 100644 index 0000000..544304b --- /dev/null +++ b/iamccs_wan_lora_hooks.py @@ -0,0 +1,734 @@ +import logging +import sys +from collections import OrderedDict + +import comfy.sd +import comfy.hooks + +from .iamccs_wan_lora_schedule import IAMCCS_WanLoRASchedule + + +_log = logging.getLogger("IAMCCS.LoRA.Hooks") + +_ANSI_GREEN = "\033[92m" +_ANSI_YELLOW = "\033[93m" +_ANSI_RESET = "\033[0m" +_MODEL_BANK_TYPE = "IAMCCS_WAN_MODEL_BANK" + +# Cache: (id(model_patcher), lora_fingerprint) β†’ patched_model_patcher +# Avoids re-cloning and re-patching when the LoRA stack is identical across loop iterations, +# which ensures model management reuses the same loaded model (no unload/reload overhead). +_MODEL_CACHE: OrderedDict = OrderedDict() +_MAX_MODEL_CACHE = 8 + + +def _flush_log_handlers(): + seen_handlers = set() + manager = getattr(logging.Logger, "manager", None) + logger_dict = getattr(manager, "loggerDict", {}) if manager is not None else {} + + for logger_name in ("IAMCCS.LoRA.Hooks", None): + logger = logging.getLogger(logger_name) if logger_name else logging.getLogger() + for handler in getattr(logger, "handlers", []): + seen_handlers.add(handler) + + for logger in logger_dict.values(): + if isinstance(logger, logging.Logger): + for handler in getattr(logger, "handlers", []): + seen_handlers.add(handler) + + for handler in seen_handlers: + try: + handler.flush() + except Exception: + pass + + for stream in (getattr(sys, "stdout", None), getattr(sys, "stderr", None)): + if stream is None: + continue + try: + stream.flush() + except Exception: + pass + + +def _lora_fingerprint(lora_stack): + """Hashable fingerprint of a lora stack: (name, strength) pairs sorted.""" + return tuple(sorted( + (str(e.get("name") or ""), float(e.get("strength") or 0.0)) + for e in (lora_stack or []) + )) + +# ─── helpers shared with stack ──────────────────────────────────────────────── + +def _entry_summary(entry): + name = entry.get("name", "unnamed") + strength = entry.get("strength", 0.0) + origin = entry.get("origin", "manual") + gen = entry.get("generation_index") + slot = entry.get("slot") + rule = entry.get("rule") or "" + extras = [origin] + if gen is not None: + extras.append(f"gen={gen}") + if slot is not None: + extras.append(f"slot={int(slot):02d}") + if rule: + extras.append(rule) + return f"{name}({strength}) [{ ' | '.join(extras) }]" + + +def _stack_summary(lora_stack): + if not lora_stack: + return "none" + parts = [] + for entry in lora_stack: + name = entry.get("name", "unnamed") + strength = entry.get("strength", 0.0) + origin = entry.get("_iamccs_lora_origin", "manual") + gen = entry.get("_iamccs_generation_index") + slot = entry.get("_iamccs_schedule_slot") + rule = entry.get("_iamccs_schedule_rule") or "" + extras = [origin] + if gen is not None: + extras.append(f"gen={gen}") + if slot is not None: + extras.append(f"slot={int(slot):02d}") + if rule: + extras.append(rule) + parts.append(f"{name}({strength}) [{' | '.join(extras)}]") + return "; ".join(parts) + + +def _origin_names(lora_stack, origin): + names = [] + for entry in lora_stack or []: + if str(entry.get("_iamccs_lora_origin") or "manual") != origin: + continue + name = entry.get("name", "unnamed") + strength = entry.get("strength", 0.0) + slot = entry.get("_iamccs_schedule_slot") + rule = entry.get("_iamccs_schedule_rule") or origin + if slot is not None: + names.append(f"slot={int(slot):02d}:{name}({strength}) [{rule}]") + else: + names.append(f"{name}({strength})") + return "; ".join(names) if names else "none" + + +def _origin_count(lora_stack, origin): + return sum( + 1 + for entry in (lora_stack or []) + if str(entry.get("_iamccs_lora_origin") or "manual") == origin + ) + + +def _inject_debug_report(lora_stack): + total_count = len(lora_stack or []) + default_summary = _origin_names(lora_stack, "default") + scheduled_summary = _origin_names(lora_stack, "scheduled") + manual_summary = _origin_names(lora_stack, "manual") + return { + "total_count": total_count, + "default_count": _origin_count(lora_stack, "default"), + "scheduled_count": _origin_count(lora_stack, "scheduled"), + "manual_count": _origin_count(lora_stack, "manual"), + "default_summary": default_summary, + "scheduled_summary": scheduled_summary, + "manual_summary": manual_summary, + "stack_summary": _stack_summary(lora_stack), + "has_default": default_summary != "none", + "has_scheduled": scheduled_summary != "none", + } + + +def _stack_context(lora_stack): + if not lora_stack: + return "" + prompt_ids = sorted({str(e.get("_iamccs_prompt_id") or "") for e in lora_stack if e.get("_iamccs_prompt_id")}) + node_ids = sorted({str(e.get("_iamccs_schedule_node_id") or "") for e in lora_stack if e.get("_iamccs_schedule_node_id")}) + log_prefixes = sorted({str(e.get("_iamccs_schedule_log_prefix") or "") for e in lora_stack if e.get("_iamccs_schedule_log_prefix")}) + gen_idxs = sorted({int(e["_iamccs_generation_index"]) for e in lora_stack if e.get("_iamccs_generation_index") is not None}) + parts = [] + if prompt_ids: + parts.append(f"prompt={','.join(prompt_ids)}") + if node_ids: + parts.append(f"schedule_node={','.join(node_ids)}") + if log_prefixes: + parts.append(f"schedule={','.join(log_prefixes)}") + if gen_idxs: + parts.append(f"generation={','.join(str(g) for g in gen_idxs)}") + return " | ".join(parts) + + +def _prompt_scope_from_lora_stack(lora_stack): + prompt_ids = sorted({ + str(entry.get("_iamccs_prompt_id") or "") + for entry in (lora_stack or []) + if entry.get("_iamccs_prompt_id") + }) + return ",".join(prompt_ids) if prompt_ids else "no-prompt" + + +def _extract_generation_index_from_conditioning(*conditioning_values): + for conditioning in conditioning_values: + if not isinstance(conditioning, list): + continue + for item in conditioning: + if not isinstance(item, (list, tuple)) or len(item) < 2: + continue + meta = item[1] + if not isinstance(meta, dict): + continue + for key in ( + "_iamccs_generation_index", + "_iamccs_segment_index", + "generation_index", + "segment_index", + ): + value = meta.get(key) + if value is None: + continue + try: + return max(0, int(value)), key + except Exception: + continue + return 0, "default:0" + + +def _conditioning_flags(*conditioning_values): + flags = { + "clip_vision": False, + "reference_latents": False, + "concat_latent_image": False, + "concat_mask": False, + } + for conditioning in conditioning_values: + if not isinstance(conditioning, list): + continue + for item in conditioning: + if not isinstance(item, (list, tuple)) or len(item) < 2: + continue + meta = item[1] + if not isinstance(meta, dict): + continue + flags["clip_vision"] = flags["clip_vision"] or (meta.get("clip_vision_output") is not None) + flags["reference_latents"] = flags["reference_latents"] or bool(meta.get("reference_latents")) + flags["concat_latent_image"] = flags["concat_latent_image"] or (meta.get("concat_latent_image") is not None) + flags["concat_mask"] = flags["concat_mask"] or (meta.get("concat_mask") is not None) + return flags + + +def _select_model_from_bank(model_bank, generation_index, log_prefix): + generation_count = int((model_bank or {}).get("generation_count") or 1) + generation_index = max(0, min(int(generation_index), generation_count - 1)) + generation_to_fingerprint = (model_bank or {}).get("generation_to_fingerprint") or {} + fingerprints_to_model = (model_bank or {}).get("fingerprints_to_model") or {} + reports = (model_bank or {}).get("reports") or {} + + fingerprint = generation_to_fingerprint.get(generation_index) + if fingerprint is None: + error = ( + f"[{log_prefix}][selector-check] generation={generation_index} missing fingerprint in model bank" + ) + _log.error(error) + _flush_log_handlers() + raise RuntimeError(error) + + model_out = fingerprints_to_model.get(fingerprint) + if model_out is None: + error = ( + f"[{log_prefix}][selector-check] generation={generation_index} missing model variant for fingerprint={fingerprint}" + ) + _log.error(error) + _flush_log_handlers() + raise RuntimeError(error) + + return generation_index, model_out, reports.get(generation_index, {}) + + +def _apply_lora_stack_to_model(model, lora_stack): + prompt_scope = _prompt_scope_from_lora_stack(lora_stack) + cache_key = (prompt_scope, id(model), _lora_fingerprint(lora_stack)) + inject_report = _inject_debug_report(lora_stack) + cached = _MODEL_CACHE.get(cache_key) + if cached is not None: + _log.info( + "[IAMCCS_ApplyLoRAHooksToConditioning] cache hit β€” reusing patched model (prompt=%s, stack unchanged)", + prompt_scope, + ) + _log.info( + "[IAMCCS_LoRAInjectCheck] mode=model_bank_cache_hit | prompt=%s | total=%s | always_on_present=%s | always_on=%s | scheduled_present=%s | scheduled=%s | stack=%s | backend=load_lora_for_models", + prompt_scope, + inject_report["total_count"], + "YES" if inject_report["has_default"] else "NO", + inject_report["default_summary"], + "YES" if inject_report["has_scheduled"] else "NO", + inject_report["scheduled_summary"], + inject_report["stack_summary"], + ) + _flush_log_handlers() + return cached, True + + model_out = model + patched_names = [] + for entry in lora_stack or []: + sd = entry.get("state_dict") + if not isinstance(sd, dict) or not sd: + _log.warning("[IAMCCS_ApplyLoRAHooksToConditioning] skipped '%s': no state_dict", entry.get("name")) + continue + strength = float(entry.get("strength", 1.0) or 1.0) + model_out, _ = comfy.sd.load_lora_for_models(model_out, None, sd, strength, 0.0) + patched_names.append(f"{entry.get('name')}({strength:.3f})") + + if patched_names: + _log.info( + "[IAMCCS_ApplyLoRAHooksToConditioning] patched %d LoRA(s) for prompt=%s: %s", + len(patched_names), + prompt_scope, + "; ".join(patched_names), + ) + _log.info( + "[IAMCCS_LoRAInjectCheck] mode=model_patch_apply | prompt=%s | total=%s | always_on_present=%s | always_on=%s | scheduled_present=%s | scheduled=%s | stack=%s | backend=load_lora_for_models", + prompt_scope, + inject_report["total_count"], + "YES" if inject_report["has_default"] else "NO", + inject_report["default_summary"], + "YES" if inject_report["has_scheduled"] else "NO", + inject_report["scheduled_summary"], + inject_report["stack_summary"], + ) + else: + _log.warning("[IAMCCS_ApplyLoRAHooksToConditioning] no LoRAs were patched (empty/invalid stack)") + _flush_log_handlers() + + _MODEL_CACHE[cache_key] = model_out + _MODEL_CACHE.move_to_end(cache_key) + while len(_MODEL_CACHE) > _MAX_MODEL_CACHE: + _MODEL_CACHE.popitem(last=False) + return model_out, False + + +# ─── Schedule node (outputs LORA stack) ─────────────────────────────────────── + +class IAMCCS_WanLoRAHookSchedule: + """Schedule wrapper that outputs a LORA stack (same as IAMCCS_WanLoRASchedule). + Kept as a separate node type so JS UI / workflows can reference it by name.""" + + @classmethod + def INPUT_TYPES(cls): + return IAMCCS_WanLoRASchedule.INPUT_TYPES() + + RETURN_TYPES = ("LORA", "INT", "STRING", "IAMCCS_WAN_LORA_LINX") + RETURN_NAMES = ("lora", "active_slots", "report", "linx") + FUNCTION = "schedule" + CATEGORY = "IAMCCS/LoRA" + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + def check_lazy_status(self, generation_index, **kwargs): + # Two-pass lazy protocol: lazy=True in INPUT_TYPES breaks the static + # validation cycle (execution.py L878). This method forces the executor + # to evaluate the upstream (forLoopStart) before calling schedule(), + # so generation_index is always the real iteration index, never None. + if generation_index is None: + return ["generation_index"] + return [] + + def schedule(self, generation_index, log_prefix, model_type="flow", default_lora=None, linx=None, unique_id=None, **kwargs): + return IAMCCS_WanLoRASchedule().schedule( + generation_index=generation_index, + log_prefix=log_prefix, + model_type=model_type, + default_lora=default_lora, + linx=linx, + unique_id=unique_id, + **kwargs, + ) + + +# ─── Apply node (MODEL + LORA β†’ MODEL + CONDITIONING passthrough) ───────────── + +class IAMCCS_ApplyLoRAHooksToConditioning: + """Apply LoRA schedule to MODEL (add_patches style, not hooks) and pass through conditioning. + + Uses comfy.sd.load_lora_for_models β€” identical to the LoRA Loader node in ComfyUI core. + This avoids the per-step hook weight patching that causes ~15 min stalls on 14B offloaded models. + The MODEL output should go to the KSampler model input; positive/negative are passed through unchanged. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model": ("MODEL",), + "positive": ("CONDITIONING",), + "negative": ("CONDITIONING",), + "lora": ("LORA",), + }, + } + + RETURN_TYPES = ("MODEL", "CONDITIONING", "CONDITIONING", "STRING") + RETURN_NAMES = ("model", "positive", "negative", "report") + FUNCTION = "apply" + CATEGORY = "IAMCCS/LoRA" + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + def apply(self, model, positive, negative, lora): + stack_summary = _stack_summary(lora) + scheduled_active = "YES" if any( + str(e.get("_iamccs_lora_origin") or "manual") == "scheduled" for e in (lora or []) + ) else "NO" + default_summary = _origin_names(lora, "default") + scheduled_summary = _origin_names(lora, "scheduled") + ctx = _stack_context(lora) + + if ctx: + _log.info("[IAMCCS_ApplyLoRAHooksToConditioning] apply request | %s", ctx) + _log.info("[IAMCCS_ApplyLoRAHooksToConditioning] active_stack=%s", stack_summary) + _log.info("[IAMCCS_ApplyLoRAHooksToConditioning] scheduled_active=%s", scheduled_active) + _log.info("[IAMCCS_ApplyLoRAHooksToConditioning] applied_default=%s", default_summary) + _log.info("[IAMCCS_ApplyLoRAHooksToConditioning] applied_scheduled=%s", scheduled_summary) + _flush_log_handlers() + + model_out, _cached = _apply_lora_stack_to_model(model, lora) + + report = ( + f"scheduled_active={scheduled_active} | " + f"applied_default={default_summary} | " + f"applied_scheduled={scheduled_summary}" + ) + return (model_out, positive, negative, report) + + +class IAMCCS_BuildScheduledWanModelBank: + """Build all scheduled WAN model variants outside the loop. + + The loop then selects a prebuilt model by generation metadata instead of + patching or scheduling LoRAs inside the model branch. + """ + + @classmethod + def INPUT_TYPES(cls): + base = IAMCCS_WanLoRASchedule.INPUT_TYPES() + base_required = dict(base.get("required", {})) + base_required.pop("generation_index", None) + + required = { + "model": ("MODEL",), + "generation_count": ("INT", {"default": 4, "min": 1, "max": 512, "step": 1}), + } + required.update(base_required) + + optional = dict(base.get("optional", {})) + hidden = {"unique_id": "UNIQUE_ID"} + return {"required": required, "optional": optional, "hidden": hidden} + + RETURN_TYPES = (_MODEL_BANK_TYPE, "STRING", "IAMCCS_WAN_LORA_LINX") + RETURN_NAMES = ("model_bank", "report", "linx") + FUNCTION = "build" + CATEGORY = "IAMCCS/LoRA" + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + def build(self, model, generation_count, log_prefix, model_type="flow", default_lora=None, linx=None, unique_id=None, **kwargs): + generation_count = max(1, int(generation_count or 1)) + fingerprints_to_model = {} + generation_to_fingerprint = {} + generation_reports = {} + linx_payload_out = None + + for generation_index in range(generation_count): + lora_stack, _active_slots, schedule_report, linx_payload = IAMCCS_WanLoRASchedule().schedule( + generation_index=generation_index, + log_prefix=log_prefix, + model_type=model_type, + default_lora=default_lora, + linx=linx, + unique_id=unique_id, + **kwargs, + ) + linx_payload_out = linx_payload + fingerprint = _lora_fingerprint(lora_stack) + inject_report = _inject_debug_report(lora_stack) + generation_to_fingerprint[generation_index] = fingerprint + generation_reports[generation_index] = { + "schedule": schedule_report, + "default": inject_report["default_summary"], + "scheduled": _origin_names(lora_stack, "scheduled"), + "stack": _stack_summary(lora_stack), + "always_on_present": inject_report["has_default"], + "scheduled_present": inject_report["has_scheduled"], + "fingerprint": repr(fingerprint), + } + _log.info( + "[IAMCCS_BuildScheduledWanModelBank][inject-check] generation=%s/%s | log_prefix=%s | always_on_present=%s | always_on=%s | scheduled_present=%s | scheduled=%s | stack=%s | fingerprint=%s", + generation_index, + generation_count - 1, + log_prefix, + "YES" if inject_report["has_default"] else "NO", + inject_report["default_summary"], + "YES" if inject_report["has_scheduled"] else "NO", + inject_report["scheduled_summary"], + inject_report["stack_summary"], + repr(fingerprint), + ) + if fingerprint not in fingerprints_to_model: + model_variant, _cached = _apply_lora_stack_to_model(model, lora_stack) + fingerprints_to_model[fingerprint] = model_variant + + bank = { + "generation_count": generation_count, + "generation_to_fingerprint": generation_to_fingerprint, + "fingerprints_to_model": fingerprints_to_model, + "reports": generation_reports, + "log_prefix": str(log_prefix or "WAN LoRA schedule"), + } + + summary_parts = [] + for generation_index in range(generation_count): + info = generation_reports[generation_index] + summary_parts.append( + f"g{generation_index}:always_on={info['default']} | scheduled={info['scheduled'] if info['scheduled'] != 'none' else 'default_only'}" + ) + report = ( + f"generation_count={generation_count} | unique_variants={len(fingerprints_to_model)} | " + f"schedule_map={' ; '.join(summary_parts)}" + ) + _log.info("[IAMCCS_BuildScheduledWanModelBank] %s", report) + _flush_log_handlers() + return (bank, report, linx_payload_out) + + +class IAMCCS_SelectScheduledWanModelFromConditioning: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model_bank": (_MODEL_BANK_TYPE,), + "positive": ("CONDITIONING", {"lazy": True}), + "negative": ("CONDITIONING", {"lazy": True}), + } + } + + RETURN_TYPES = ("MODEL", "CONDITIONING", "CONDITIONING", "INT", "STRING") + RETURN_NAMES = ("model", "positive", "negative", "generation_index", "report") + FUNCTION = "select" + CATEGORY = "IAMCCS/LoRA" + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + def check_lazy_status(self, positive=None, negative=None, **kwargs): + needed = [] + if positive is None: + needed.append("positive") + if negative is None: + needed.append("negative") + return needed + + def select(self, model_bank, positive, negative): + generation_index, generation_source = _extract_generation_index_from_conditioning(positive, negative) + log_prefix = str((model_bank or {}).get("log_prefix") or "WAN LoRA schedule") + generation_index, model_out, info = _select_model_from_bank(model_bank, generation_index, log_prefix) + cond_flags = _conditioning_flags(positive, negative) + scheduled = info.get("scheduled", "none") + always_on = info.get("default", "none") + color = _ANSI_GREEN if scheduled != "none" else _ANSI_YELLOW + report = ( + f"generation_index={generation_index} | source={generation_source} | " + f"always_on={always_on} | scheduled={scheduled} | schedule={info.get('schedule', 'unknown')}" + ) + _log.info( + "%s[IAMCCS_SelectScheduledWanModelFromConditioning] generation=%s source=%s | clip_vision=%s reference_latents=%s concat_latent=%s concat_mask=%s | always_on=%s | scheduled=%s%s", + color, + generation_index, + generation_source, + "YES" if cond_flags["clip_vision"] else "NO", + "YES" if cond_flags["reference_latents"] else "NO", + "YES" if cond_flags["concat_latent_image"] else "NO", + "YES" if cond_flags["concat_mask"] else "NO", + always_on, + scheduled, + _ANSI_RESET, + ) + _flush_log_handlers() + return (model_out, positive, negative, generation_index, report) + + +class IAMCCS_SelectScheduledWanModelPairFromConditioning: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "high_model_bank": (_MODEL_BANK_TYPE,), + "low_model_bank": (_MODEL_BANK_TYPE,), + "positive": ("CONDITIONING", {"lazy": True}), + "negative": ("CONDITIONING", {"lazy": True}), + } + } + + RETURN_TYPES = ("MODEL", "MODEL", "CONDITIONING", "CONDITIONING", "INT", "STRING") + RETURN_NAMES = ("high_model", "low_model", "positive", "negative", "generation_index", "report") + FUNCTION = "select_pair" + CATEGORY = "IAMCCS/LoRA" + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + def check_lazy_status(self, positive=None, negative=None, **kwargs): + needed = [] + if positive is None: + needed.append("positive") + if negative is None: + needed.append("negative") + return needed + + def select_pair(self, high_model_bank, low_model_bank, positive, negative): + generation_index, generation_source = _extract_generation_index_from_conditioning(positive, negative) + high_log_prefix = str((high_model_bank or {}).get("log_prefix") or "WAN LoRA schedule HIGH") + low_log_prefix = str((low_model_bank or {}).get("log_prefix") or "WAN LoRA schedule LOW") + generation_index, high_model_out, high_info = _select_model_from_bank( + high_model_bank, + generation_index, + high_log_prefix, + ) + generation_index, low_model_out, low_info = _select_model_from_bank( + low_model_bank, + generation_index, + low_log_prefix, + ) + + cond_flags = _conditioning_flags(positive, negative) + high_default = high_info.get("default", "none") + low_default = low_info.get("default", "none") + high_scheduled = high_info.get("scheduled", "none") + low_scheduled = low_info.get("scheduled", "none") + report = ( + f"generation_index={generation_index} | source={generation_source} | " + f"high_always_on={high_default} | low_always_on={low_default} | " + f"high_scheduled={high_scheduled} | low_scheduled={low_scheduled}" + ) + _log.info( + "%s[IAMCCS_SelectScheduledWanModelPairFromConditioning] generation=%s source=%s | clip_vision=%s reference_latents=%s concat_latent=%s concat_mask=%s | high_always_on=%s | low_always_on=%s | high_scheduled=%s | low_scheduled=%s%s", + _ANSI_GREEN if (high_scheduled != "none" or low_scheduled != "none") else _ANSI_YELLOW, + generation_index, + generation_source, + "YES" if cond_flags["clip_vision"] else "NO", + "YES" if cond_flags["reference_latents"] else "NO", + "YES" if cond_flags["concat_latent_image"] else "NO", + "YES" if cond_flags["concat_mask"] else "NO", + high_default, + low_default, + high_scheduled, + low_scheduled, + _ANSI_RESET, + ) + _flush_log_handlers() + return (high_model_out, low_model_out, positive, negative, generation_index, report) + + +class IAMCCS_ApplyScheduledWanLoRAFromConditioning: + """Resolve generation index from conditioning metadata, then apply scheduled WAN LoRAs. + + This avoids wiring easy forLoopStart.index into the MODEL branch entirely. + The effective generation index comes from conditioning metadata emitted by + IAMCCS_WanIndexedPromptEncode and therefore stays synchronized with the + prompt/segment that actually reaches the sampler. + """ + + @classmethod + def INPUT_TYPES(cls): + base = IAMCCS_WanLoRASchedule.INPUT_TYPES() + base_required = dict(base.get("required", {})) + base_required.pop("generation_index", None) + + required = { + "model": ("MODEL",), + "positive": ("CONDITIONING",), + "negative": ("CONDITIONING",), + } + required.update(base_required) + + optional = dict(base.get("optional", {})) + hidden = {"unique_id": "UNIQUE_ID"} + return {"required": required, "optional": optional, "hidden": hidden} + + RETURN_TYPES = ("MODEL", "CONDITIONING", "CONDITIONING", "INT", "STRING", "IAMCCS_WAN_LORA_LINX") + RETURN_NAMES = ("model", "positive", "negative", "generation_index", "report", "linx") + FUNCTION = "apply_scheduled" + CATEGORY = "IAMCCS/LoRA" + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + def apply_scheduled(self, model, positive, negative, log_prefix, model_type="flow", default_lora=None, linx=None, unique_id=None, **kwargs): + generation_index, generation_source = _extract_generation_index_from_conditioning(positive, negative) + cond_flags = _conditioning_flags(positive, negative) + lora, _active_slots, schedule_report, linx_payload = IAMCCS_WanLoRASchedule().schedule( + generation_index=generation_index, + log_prefix=log_prefix, + model_type=model_type, + default_lora=default_lora, + linx=linx, + unique_id=unique_id, + **kwargs, + ) + model_out, positive_out, negative_out, apply_report = IAMCCS_ApplyLoRAHooksToConditioning().apply( + model=model, + positive=positive, + negative=negative, + lora=lora, + ) + report = ( + f"generation_index={int(generation_index)} | " + f"schedule={schedule_report} | " + f"apply={apply_report}" + ) + scheduled_summary = _origin_names(lora, "scheduled") + color = _ANSI_GREEN if scheduled_summary != "none" else _ANSI_YELLOW + _log.info( + "%s[IAMCCS_ApplyScheduledWanLoRAFromConditioning] generation=%s source=%s | clip_vision=%s reference_latents=%s concat_latent=%s concat_mask=%s | scheduled=%s%s", + color, + int(generation_index), + generation_source, + "YES" if cond_flags["clip_vision"] else "NO", + "YES" if cond_flags["reference_latents"] else "NO", + "YES" if cond_flags["concat_latent_image"] else "NO", + "YES" if cond_flags["concat_mask"] else "NO", + scheduled_summary, + _ANSI_RESET, + ) + _flush_log_handlers() + return (model_out, positive_out, negative_out, int(generation_index), report, linx_payload) + + +NODE_CLASS_MAPPINGS = { + "IAMCCS_WanLoRAHookSchedule": IAMCCS_WanLoRAHookSchedule, + "IAMCCS_ApplyLoRAHooksToConditioning": IAMCCS_ApplyLoRAHooksToConditioning, + "IAMCCS_ApplyScheduledWanLoRAFromConditioning": IAMCCS_ApplyScheduledWanLoRAFromConditioning, + "IAMCCS_BuildScheduledWanModelBank": IAMCCS_BuildScheduledWanModelBank, + "IAMCCS_SelectScheduledWanModelFromConditioning": IAMCCS_SelectScheduledWanModelFromConditioning, + "IAMCCS_SelectScheduledWanModelPairFromConditioning": IAMCCS_SelectScheduledWanModelPairFromConditioning, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "IAMCCS_WanLoRAHookSchedule": "LoRA Schedule (WAN, hooks)", + "IAMCCS_ApplyLoRAHooksToConditioning": "Apply LoRA Hooks to Conditioning", + "IAMCCS_ApplyScheduledWanLoRAFromConditioning": "Apply Scheduled WAN LoRA From Conditioning", + "IAMCCS_BuildScheduledWanModelBank": "Build Scheduled WAN Model Bank", + "IAMCCS_SelectScheduledWanModelFromConditioning": "Select Scheduled WAN Model From Conditioning", + "IAMCCS_SelectScheduledWanModelPairFromConditioning": "Select Scheduled WAN Model Pair From Conditioning", +} + diff --git a/iamccs_wan_lora_runtime_bridge.py b/iamccs_wan_lora_runtime_bridge.py new file mode 100644 index 0000000..72b8c74 --- /dev/null +++ b/iamccs_wan_lora_runtime_bridge.py @@ -0,0 +1,216 @@ +import logging + +import folder_paths + +from .iamccs_wan_lora_schedule import ( + _DEFAULT_MODEL_TYPE, + _LINX_TYPE, + _annotate_lora_entry, + _coerce_lora_stack, + _load_internal_lora_entry, + _preset_matches, + _resolve_linx_name, + _stack_names, +) +from .iamccs_wan_lora_stack import ( + IAMCCS_ModelWithLoRA, + _lora_stack_debug_context, + _lora_stack_debug_summary, +) + + +_log = logging.getLogger("IAMCCS.LoRA.RuntimeBridge") + + +def _runtime_slot_items(linx_payload): + if not isinstance(linx_payload, dict): + return [] + slot_map = linx_payload.get("slot_map") or {} + if not isinstance(slot_map, dict): + return [] + return sorted(slot_map.items()) + + +def _coerce_generation_index(value) -> int: + try: + return max(0, int(value)) + except Exception: + return 0 + + +def _resolved_model_type(model_type_override: str, linx_payload) -> str: + if model_type_override and model_type_override != "inherit": + return str(model_type_override) + if isinstance(linx_payload, dict): + linx_model_type = str(linx_payload.get("model_type") or "").strip() + if linx_model_type: + return linx_model_type + return _DEFAULT_MODEL_TYPE + + +class IAMCCS_WanLoRARuntimeBridge: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "generation_index": ("INT", {"default": 0, "min": 0, "max": 1000000, "step": 1}), + "inject_tag": ("STRING", {"default": "inject"}), + "log_prefix": ("STRING", {"default": "WAN LoRA runtime bridge"}), + "model_type_override": (["inherit", "wan2x", "flow", "standard"], {"default": "inherit"}), + }, + "optional": { + "default_lora": ("LORA",), + "linx": (_LINX_TYPE,), + }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = ("LORA", "INT", "STRING", "INT") + RETURN_NAMES = ("lora", "active_slots", "report", "generation_index") + FUNCTION = "resolve" + CATEGORY = "IAMCCS/LoRA" + + def resolve(self, generation_index, inject_tag, log_prefix, model_type_override="inherit", default_lora=None, linx=None, unique_id=None): + generation_index = _coerce_generation_index(generation_index) + inject_tag = str(inject_tag or "inject") + log_prefix = str(log_prefix or "WAN LoRA runtime bridge") + model_type = _resolved_model_type(model_type_override, linx) + available_loras = set(folder_paths.get_filename_list("loras")) + + resolved_stack = [] + active_slot_labels = [] + load_cache = {} + + for entry in _coerce_lora_stack(default_lora): + resolved_stack.append( + _annotate_lora_entry( + entry, + origin=str(entry.get("_iamccs_lora_origin") or "default"), + generation_index=generation_index, + log_prefix=log_prefix, + prompt_id=entry.get("_iamccs_prompt_id"), + unique_id=unique_id, + slot=entry.get("_iamccs_schedule_slot"), + rule=entry.get("_iamccs_schedule_rule"), + ) + ) + + for slot_key, slot_data in _runtime_slot_items(linx): + if not isinstance(slot_data, dict): + continue + + try: + slot = int(str(slot_key).split("_")[-1]) + except Exception: + continue + + local_name = str(slot_data.get("name") or "no") + resolved_name = _resolve_linx_name(local_name, slot, linx, available_loras) + matched, preset_report = _preset_matches( + generation_index, + str(slot_data.get("preset") or "custom range"), + int(slot_data.get("start", slot - 1) or 0), + int(slot_data.get("end", slot - 1) or 0), + ) + if not matched: + continue + + lora_entry = _load_internal_lora_entry( + resolved_name, + float(slot_data.get("strength") or 0.0), + model_type, + load_cache, + ) + if not lora_entry: + continue + + resolved_stack.append( + _annotate_lora_entry( + lora_entry, + origin="runtime scheduled", + generation_index=generation_index, + log_prefix=log_prefix, + prompt_id="", + unique_id=unique_id, + slot=slot, + rule=preset_report, + ) + ) + active_slot_labels.append(f"{slot:02d}:{resolved_name}") + + report = ( + f"{inject_tag}: generation_index={generation_index}" + f" | active_slots={len(active_slot_labels)}" + f" | model_type={model_type}" + f" | entries={len(resolved_stack)}" + f" | active={', '.join(active_slot_labels) if active_slot_labels else 'default_only'}" + ) + + if resolved_stack: + _log.info( + "[%s][%s] generation=%s resolved_stack=%s | %s", + log_prefix, + inject_tag, + generation_index, + _lora_stack_debug_summary(resolved_stack), + _lora_stack_debug_context(resolved_stack), + ) + else: + _log.warning( + "[%s][%s] generation=%s resolved no active LoRA entries", + log_prefix, + inject_tag, + generation_index, + ) + + return (resolved_stack, len(active_slot_labels), report, generation_index) + + +class IAMCCS_ModelWithLoRA_RuntimeBridge: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model": ("MODEL",), + "lora": ("LORA",), + "generation_index": ("INT", {"default": 0, "min": 0, "max": 1000000, "step": 1}), + "inject_tag": ("STRING", {"default": "inject"}), + "log_prefix": ("STRING", {"default": "WAN LoRA runtime apply"}), + } + } + + RETURN_TYPES = ("MODEL", "STRING") + RETURN_NAMES = ("model", "report") + FUNCTION = "apply" + CATEGORY = "IAMCCS/LoRA" + + def apply(self, model, lora, generation_index, inject_tag, log_prefix): + generation_index = _coerce_generation_index(generation_index) + inject_tag = str(inject_tag or "inject") + log_prefix = str(log_prefix or "WAN LoRA runtime apply") + report = ( + f"{inject_tag}: generation_index={generation_index}" + f" | entries={len(lora or [])}" + f" | stack={_stack_names(lora or [])}" + ) + + if lora: + _log.info( + "[%s][%s] generation=%s apply request | %s", + log_prefix, + inject_tag, + generation_index, + _lora_stack_debug_summary(lora), + ) + else: + _log.warning( + "[%s][%s] generation=%s apply request with empty LoRA stack", + log_prefix, + inject_tag, + generation_index, + ) + + model_out = IAMCCS_ModelWithLoRA().apply(model, lora)[0] + return (model_out, report) \ No newline at end of file diff --git a/iamccs_wan_lora_schedule.py b/iamccs_wan_lora_schedule.py index 039a0e9..b8a5c22 100644 --- a/iamccs_wan_lora_schedule.py +++ b/iamccs_wan_lora_schedule.py @@ -1,4 +1,5 @@ import logging +import sys from collections import OrderedDict import comfy.utils @@ -26,6 +27,18 @@ _AUTO_INDEX_STATE: OrderedDict[tuple[str, str], int] = OrderedDict() _MAX_AUTO_INDEX_STATE = 64 +def _is_ignored_lora_name(name: str) -> bool: + normalized = str(name or "").replace("\\", "/").strip() + if not normalized: + return True + leaf = normalized.rsplit("/", 1)[-1] + return normalized.startswith("__MACOSX/") or "/__MACOSX/" in normalized or leaf.startswith("._") + + +def _get_valid_lora_list() -> list[str]: + return [name for name in folder_paths.get_filename_list("loras") if not _is_ignored_lora_name(name)] + + def _annotate_lora_entry(entry: dict, *, origin: str, generation_index: int, log_prefix: str, prompt_id: str | None, unique_id, slot: int | None = None, rule: str | None = None) -> dict: annotated = dict(entry) annotated["_iamccs_lora_origin"] = str(origin or "unknown") @@ -129,6 +142,123 @@ def _stack_names(stack: list[dict]) -> str: return ", ".join(names) if names else "empty" +def _stack_debug_entries(stack: list[dict]) -> str: + if not stack: + return "empty" + parts = [] + for entry in stack: + parts.append( + f"{entry.get('name', 'unnamed')}" + f"(strength={float(entry.get('strength', 0.0) or 0.0):.3f}," + f" origin={entry.get('_iamccs_lora_origin', 'unknown')}," + f" gen={entry.get('_iamccs_generation_index', 'n/a')}," + f" slot={entry.get('_iamccs_schedule_slot', 'n/a')}," + f" rule={entry.get('_iamccs_schedule_rule', 'n/a')})" + ) + return "; ".join(parts) + + +def _diag_value(value) -> str: + text = str(value or "").strip() + return text if text else "-" + + +def _infer_branch_from_name(name: str) -> str | None: + normalized = str(name or "").replace("\\", "/").lower() + if not normalized or normalized == "no": + return None + low_tokens = ("_low", "-low", "/low", "_ln_", "-ln_", "low_") + high_tokens = ("_high", "-high", "/high", "_hn_", "-hn_", "high_") + if any(token in normalized for token in low_tokens): + return "LOW" + if any(token in normalized for token in high_tokens): + return "HIGH" + return None + + +def _expected_branch_from_prefix(log_prefix: str) -> str | None: + normalized = f" {str(log_prefix or '').lower()} " + if " low " in normalized or normalized.endswith(" low "): + return "LOW" + if " high " in normalized or normalized.endswith(" high "): + return "HIGH" + return None + + +def _branch_names(stack: list[dict], branch: str) -> list[str]: + names = [] + for entry in stack or []: + name = str(entry.get("name") or "") + if _infer_branch_from_name(name) == branch: + names.append(name) + return names + + +def _validate_branch_consistency(log_prefix: str, generation_index: int, stack: list[dict]) -> str | None: + expected_branch = _expected_branch_from_prefix(log_prefix) + high_names = _branch_names(stack, "HIGH") + low_names = _branch_names(stack, "LOW") + + if high_names and low_names: + return ( + f"[{log_prefix}][branch-check] generation={generation_index} MIXED stack detected | " + f"HIGH={', '.join(high_names)} | LOW={', '.join(low_names)}" + ) + + if expected_branch == "LOW" and high_names: + return ( + f"[{log_prefix}][branch-check] generation={generation_index} EXPECTED_LOW but found HIGH entries=" + f"{', '.join(high_names)}" + ) + + if expected_branch == "HIGH" and low_names: + return ( + f"[{log_prefix}][branch-check] generation={generation_index} EXPECTED_HIGH but found LOW entries=" + f"{', '.join(low_names)}" + ) + + if expected_branch: + matched = high_names if expected_branch == "HIGH" else low_names + _log.info( + "[%s][branch-check] generation=%s OK expected=%s entries=%s", + log_prefix, + generation_index, + expected_branch, + ", ".join(matched) if matched else "none-tagged", + ) + return None + + +def _flush_log_handlers(): + seen_handlers = set() + manager = getattr(logging.Logger, "manager", None) + logger_dict = getattr(manager, "loggerDict", {}) if manager is not None else {} + + for logger_name in ("IAMCCS.LoRA.Schedule", None): + logger = logging.getLogger(logger_name) if logger_name else logging.getLogger() + for handler in getattr(logger, "handlers", []): + seen_handlers.add(handler) + + for logger in logger_dict.values(): + if isinstance(logger, logging.Logger): + for handler in getattr(logger, "handlers", []): + seen_handlers.add(handler) + + for handler in seen_handlers: + try: + handler.flush() + except Exception: + pass + + for stream in (getattr(sys, "stdout", None), getattr(sys, "stderr", None)): + if stream is None: + continue + try: + stream.flush() + except Exception: + pass + + def _candidate_low_names(name: str) -> list[str]: source = str(name or "") if not source or source == "no": @@ -162,24 +292,41 @@ def _candidate_low_names(name: str) -> list[str]: return out -def _resolve_linx_name(local_name: str, slot: int, linx_payload, available_loras: set[str]) -> str: +def _resolve_linx_name_debug(local_name: str, slot: int, linx_payload, available_loras: set[str]) -> tuple[str, str, dict]: local = str(local_name or "no") if local != "no": - return local + return local, "local", {"local_name": local, "suggested_low_name": "", "source_name": ""} if not isinstance(linx_payload, dict): - return local + return local, "local:no-linx", {"local_name": local, "suggested_low_name": "", "source_name": ""} slot_map = linx_payload.get("slot_map") or {} slot_data = slot_map.get(f"slot_{slot:02d}") or {} suggested = str(slot_data.get("suggested_low_name") or "") if suggested and suggested in available_loras: - return suggested + return suggested, "linx:suggested_low_name", { + "local_name": local, + "suggested_low_name": suggested, + "source_name": str(slot_data.get("name") or ""), + } source_name = str(slot_data.get("name") or "") for candidate in _candidate_low_names(source_name): if candidate in available_loras: - return candidate - return local + return candidate, "linx:derived_low_candidate", { + "local_name": local, + "suggested_low_name": suggested, + "source_name": source_name, + } + return local, "local:no-match", { + "local_name": local, + "suggested_low_name": suggested, + "source_name": source_name, + } + + +def _resolve_linx_name(local_name: str, slot: int, linx_payload, available_loras: set[str]) -> str: + resolved, _source, _details = _resolve_linx_name_debug(local_name, slot, linx_payload, available_loras) + return resolved def _build_linx_payload(model_type: str, kwargs: dict, available_loras: set[str]) -> dict: @@ -210,6 +357,10 @@ def _load_internal_lora_entry(name: str, strength: float, model_type: str, cache if not name or name == "no": return None + if _is_ignored_lora_name(name): + _log.warning("[WAN LoRA schedule] ignored invalid LoRA entry: %s", name) + return None + strength_f = float(strength) if strength_f == 0.0: return None @@ -219,7 +370,17 @@ def _load_internal_lora_entry(name: str, strength: float, model_type: str, cache state_dict = cache[cache_key] else: path = folder_paths.get_full_path_or_raise("loras", name) - state_dict = comfy.utils.load_torch_file(path, safe_load=True) + try: + state_dict = comfy.utils.load_torch_file(path, safe_load=True) + except UnicodeDecodeError: + _log.warning( + "[WAN LoRA schedule] skipped unreadable LoRA file '%s' (invalid safetensors header / likely resource-fork or corrupted file)", + name, + ) + return None + except Exception: + _log.exception("[WAN LoRA schedule] failed loading LoRA '%s'", name) + return None if str(model_type or _DEFAULT_MODEL_TYPE) != "standard": state_dict = standardize_wan_lora_keys(state_dict) cache[cache_key] = state_dict @@ -234,13 +395,14 @@ def _load_internal_lora_entry(name: str, strength: float, model_type: str, cache class IAMCCS_WanLoRASchedule: @classmethod def INPUT_TYPES(cls): - lora_list = folder_paths.get_filename_list("loras") + ["no"] + lora_list = _get_valid_lora_list() + ["no"] required = { - # NOTE: Must NOT use lazy:True here. With lazy:True, ComfyUI caches the - # node output after the first call (gen_idx=Noneβ†’0) and skips re-execution - # on subsequent loop iterations when only generation_index changes, causing - # the schedule to always behave as if generation_index==0. - "generation_index": ("INT", {"default": 0, "min": 0, "max": 1000000, "step": 1}), + # lazy=True breaks the static validation cycle created by wiring forLoopStart.index + # here. It only affects validate_inputs (execution.py L878 skips cycle traversal). + # At runtime, since check_lazy_status is NOT defined on this node, ComfyUI evaluates + # all inputs normally before calling schedule() β€” no caching side-effect. + # IS_CHANGED=nan ensures re-execution every iteration regardless. + "generation_index": ("INT", {"default": 0, "min": 0, "max": 1000000, "step": 1, "lazy": True}), "log_prefix": ("STRING", {"default": "WAN LoRA schedule"}), "model_type": (["wan2x", "flow", "standard"], {"default": _DEFAULT_MODEL_TYPE}), } @@ -283,24 +445,32 @@ class IAMCCS_WanLoRASchedule: # would freeze the LoRA selection on the first iteration. return float("nan") + def check_lazy_status(self, generation_index, **kwargs): + # Two-pass lazy protocol: lazy=True in INPUT_TYPES breaks the static + # validation cycle (execution.py L878). This method forces the executor + # to evaluate the upstream (forLoopStart) before calling schedule(), + # so generation_index is always the real iteration index, never None. + if generation_index is None: + return ["generation_index"] + return [] + def schedule(self, generation_index, log_prefix, model_type=_DEFAULT_MODEL_TYPE, default_lora=None, linx=None, unique_id=None, **kwargs): - # Loop-safe mode: when generation_index is left at its hidden default 0, - # use a per-prompt internal counter instead of wiring the easy-use loop - # index into the MODEL branch, which creates a prompt-validation cycle. + # With check_lazy_status in place, generation_index is always the real + # iteration index when a wire is connected (never None at execution time). + # None only occurs in truly unwired / fallback contexts. generation_source = "input" if generation_index is None: - generation_index = _next_auto_generation_index(unique_id) + # No upstream link or upstream not resolvable β€” use literal 0. + # (check_lazy_status would have forced evaluation if a wire existed.) + generation_index = 0 generation_source = "auto:none" else: generation_index = int(generation_index) - if generation_index == 0: - generation_index = _next_auto_generation_index(unique_id) - generation_source = "auto:zero" generation_index = max(0, generation_index) log_prefix = str(log_prefix or "WAN LoRA schedule") model_type = str(model_type or _DEFAULT_MODEL_TYPE) prompt_id = _get_current_prompt_id() - available_loras = set(folder_paths.get_filename_list("loras")) + available_loras = set(_get_valid_lora_list()) out = [ _annotate_lora_entry( entry, @@ -315,6 +485,7 @@ class IAMCCS_WanLoRASchedule: active_slots = [] cache: dict = {} linx_payload = _build_linx_payload(model_type, kwargs, available_loras) + scheduled_names = [] if out: _log.info( @@ -326,6 +497,15 @@ class IAMCCS_WanLoRASchedule: generation_source, _stack_names(out), ) + _log.info( + "[%s][diag] prompt=%s node=%s generation=%s | default_stack_debug=%s", + log_prefix, + prompt_id or "unknown", + unique_id if unique_id is not None else "unknown", + generation_index, + _stack_debug_entries(out), + ) + _flush_log_handlers() for slot in range(1, _SLOT_COUNT + 1): preset = kwargs.get(f"slot_{slot:02d}_preset", "custom range") @@ -336,12 +516,32 @@ class IAMCCS_WanLoRASchedule: continue slot_stack = [] - slot_name = _resolve_linx_name( - str(kwargs.get(f"slot_{slot:02d}_lora_name") or "no"), + raw_slot_name = str(kwargs.get(f"slot_{slot:02d}_lora_name") or "no") + slot_name, slot_name_source, slot_name_details = _resolve_linx_name_debug( + raw_slot_name, slot, linx, available_loras, ) + _log.info( + "[%s][diag] prompt=%s node=%s generation=%s slot=%02d matched=%s preset=%s start=%s end=%s raw_name=%s resolved_name=%s resolve_source=%s linx_source_name=%s linx_suggested_low=%s strength=%.3f", + log_prefix, + prompt_id or "unknown", + unique_id if unique_id is not None else "unknown", + generation_index, + slot, + matched, + preset, + start, + end, + _diag_value(raw_slot_name), + _diag_value(slot_name), + slot_name_source, + _diag_value(slot_name_details.get("source_name")), + _diag_value(slot_name_details.get("suggested_low_name")), + float(kwargs.get(f"slot_{slot:02d}_strength", 0.0) or 0.0), + ) + _flush_log_handlers() entry = _load_internal_lora_entry( slot_name, float(kwargs.get(f"slot_{slot:02d}_strength", 0.0) or 0.0), @@ -367,22 +567,43 @@ class IAMCCS_WanLoRASchedule: out.extend(slot_stack) active_slots.append(f"slot_{slot:02d}:{preset_report} => {_stack_names(slot_stack)}") + scheduled_names.extend( + f"slot_{slot:02d}:{entry.get('name') or 'unnamed'}({float(entry.get('strength', 0.0) or 0.0)})" + for entry in slot_stack + ) + scheduled_active = "YES" if scheduled_names else "NO" report = ( f"generation_index={generation_index} | active={' + '.join(active_slots) if active_slots else 'default_only'} | " + f"scheduled_active={scheduled_active} | " + f"scheduled_names={'; '.join(scheduled_names) if scheduled_names else 'none'} | " f"entries={len(out)}" ) if out: _log.info("[%s] %s", log_prefix, report) + branch_error = _validate_branch_consistency(log_prefix, generation_index, out) + if branch_error: + _log.error("%s", branch_error) + _flush_log_handlers() + raise RuntimeError(branch_error) + _log.info( + "[%s] generation=%s scheduled_active=%s | scheduled_names=%s", + log_prefix, + generation_index, + scheduled_active, + "; ".join(scheduled_names) if scheduled_names else "none", + ) _log.info( "[%s] generation=%s resolved_stack=%s", log_prefix, generation_index, _stack_names(out), ) + _flush_log_handlers() else: _log.warning("[%s] No LoRA active | generation_index=%s", log_prefix, generation_index) + _flush_log_handlers() return (out, len(active_slots), report, linx_payload) diff --git a/iamccs_wan_lora_stack.py b/iamccs_wan_lora_stack.py index c07c37e..01177d1 100644 --- a/iamccs_wan_lora_stack.py +++ b/iamccs_wan_lora_stack.py @@ -65,6 +65,35 @@ def _lora_stack_debug_context(lora) -> str: return " | ".join(parts) +def _lora_stack_origin_names(lora, origin: str) -> str: + if not lora: + return "none" + + names = [] + for entry in lora: + entry_origin = str(entry.get("_iamccs_lora_origin") or "manual") + if entry_origin != origin: + continue + name = str(entry.get("name") or "unnamed") + strength = float(entry.get("strength", 0.0) or 0.0) + slot = entry.get("_iamccs_schedule_slot") + rule = str(entry.get("_iamccs_schedule_rule") or "") + if slot is not None: + names.append(f"slot={int(slot):02d}:{name}({strength}) [{rule or origin}]") + else: + names.append(f"{name}({strength})") + return "; ".join(names) if names else "none" + + +def _lora_stack_has_origin(lora, origin: str) -> bool: + if not lora: + return False + for entry in lora: + if str(entry.get("_iamccs_lora_origin") or "manual") == origin: + return True + return False + + def _cache_put(cache: OrderedDict, key, value, max_size: int): cache[key] = value cache.move_to_end(key) @@ -248,6 +277,14 @@ class IAMCCS_ModelWithLoRA: } } + @classmethod + def IS_CHANGED(cls, **kwargs): + # The upstream schedule node may resolve a different per-generation stack + # while the graph wiring itself remains unchanged inside Easy-Use loops. + # Force re-evaluation so the current loop iteration can request fresh LORA + # metadata instead of reusing the first patched MODEL forever. + return float("nan") + RETURN_TYPES = ("MODEL",) FUNCTION = "apply" CATEGORY = "IAMCCS/LoRA" @@ -258,6 +295,9 @@ class IAMCCS_ModelWithLoRA: stack_context = _lora_stack_debug_context(lora) stack_summary = _lora_stack_debug_summary(lora) + default_summary = _lora_stack_origin_names(lora, "default") + scheduled_summary = _lora_stack_origin_names(lora, "scheduled") + scheduled_active = "YES" if _lora_stack_has_origin(lora, "scheduled") else "NO" signature = tuple((str(entry.get("name") or ""), float(entry.get("strength", 0.0) or 0.0)) for entry in lora) cache_key = (id(model), signature) cached_model = _PATCHED_MODEL_CACHE.get(cache_key) @@ -268,6 +308,9 @@ class IAMCCS_ModelWithLoRA: else: logging.info(f"[IAMCCS_ModelWithLoRA] β™» cache hit: {len(signature)} LoRA(s) reused") logging.info(f"[IAMCCS_ModelWithLoRA] active_stack={stack_summary}") + logging.info(f"[IAMCCS_ModelWithLoRA] scheduled_active={scheduled_active}") + logging.info(f"[IAMCCS_ModelWithLoRA] applied_default={default_summary}") + logging.info(f"[IAMCCS_ModelWithLoRA] applied_scheduled={scheduled_summary}") return (cached_model,) model_out = model @@ -275,6 +318,9 @@ class IAMCCS_ModelWithLoRA: if stack_context: logging.info(f"[IAMCCS_ModelWithLoRA] apply request | {stack_context}") logging.info(f"[IAMCCS_ModelWithLoRA] active_stack={stack_summary}") + logging.info(f"[IAMCCS_ModelWithLoRA] scheduled_active={scheduled_active}") + logging.info(f"[IAMCCS_ModelWithLoRA] applied_default={default_summary}") + logging.info(f"[IAMCCS_ModelWithLoRA] applied_scheduled={scheduled_summary}") # Installa filtro per sopprimere spam di chiavi opzionali (img_*, diff_m, ecc.) logger = logging.getLogger() diff --git a/iamccs_wan_svipro_motion.py b/iamccs_wan_svipro_motion.py index 64e9780..aab71ff 100644 --- a/iamccs_wan_svipro_motion.py +++ b/iamccs_wan_svipro_motion.py @@ -217,6 +217,49 @@ def _resolve_prev_tail_profile( return str(tail_pick_mode or "latest"), max(0, int(skip_last_slots)), float(anchor_blend) +def _ensure_motion_tail_matches_anchor( + motion_tail: torch.Tensor | None, + *, + anchor_reference: torch.Tensor, + logger: logging.Logger, + context: str, +) -> tuple[torch.Tensor | None, bool]: + if motion_tail is None: + return None, True + + anchor_shape = tuple(anchor_reference.shape) + motion_shape = tuple(motion_tail.shape) + + if motion_tail.dim() != 5 or anchor_reference.dim() != 5: + logger.warning( + "[%s] prev_samples tail rank mismatch, disabling continuity tail. prev=%s anchor=%s", + context, + motion_shape, + anchor_shape, + ) + return None, False + + if motion_tail.shape[1] != anchor_reference.shape[1]: + logger.warning( + "[%s] prev_samples channel mismatch, disabling continuity tail. prev=%s anchor=%s", + context, + motion_shape, + anchor_shape, + ) + return None, False + + if motion_tail.shape[3] != anchor_reference.shape[3] or motion_tail.shape[4] != anchor_reference.shape[4]: + logger.warning( + "[%s] prev_samples spatial mismatch, disabling continuity tail. prev=%s anchor=%s", + context, + motion_shape, + anchor_shape, + ) + return None, False + + return motion_tail, True + + def _build_inductive_motion_tail( prev_samples, motion_latent_count: int, @@ -821,7 +864,7 @@ class IAMCCS_WanImageMotion: ), }, "optional": { - "prev_samples": ("LATENT",), + "prev_samples": ("LATENT", {"lazy": True}), "clip_vision_output": ("CLIP_VISION_OUTPUT",), }, } @@ -833,6 +876,14 @@ class IAMCCS_WanImageMotion: _log = logging.getLogger("IAMCCS.WanImageMotion") + def check_lazy_status(self, prev_samples=None, use_prev_samples=True, **kwargs): + # Allow prev_samples to participate in loop feedback without tripping + # static prompt validation. If the wire exists but the upstream latent is + # not ready yet, request a second pass after that dependency is executed. + if use_prev_samples and prev_samples is None: + return ["prev_samples"] + return [] + def _pick_empty_latent_dtype(self, anchor_dtype: torch.dtype, latent_precision: str) -> torch.dtype: if latent_precision == "normal": # Backward-compat alias for older workflows. @@ -1147,6 +1198,13 @@ class IAMCCS_WanImageMotion: T_motion, padding_size, ) + self._log.info( + "[WanImageMotion] conditioning flags | clip_vision=%s add_reference_latents=%s use_prev_samples=%s prev_connected=%s", + "YES" if clip_vision_output is not None else "NO", + "YES" if add_reference_latents else "NO", + "YES" if use_prev_samples else "NO", + "YES" if prev_samples is not None else "NO", + ) if prev_samples is not None and not use_prev_samples: self._log.info("[WanImageMotion] prev_samples connected but use_prev_samples=False β€” prev ignored.") if free_vram is not None: @@ -1685,6 +1743,14 @@ class WanImageMotionPro: anchor_reference=anchor_latent, anchor_blend=resolved_anchor_blend, ) + motion_latent, motion_tail_ok = _ensure_motion_tail_matches_anchor( + motion_latent, + anchor_reference=anchor_latent, + logger=self._log, + context="WanImageMotionPro/raw_mode", + ) + if not motion_tail_ok: + motion_count = 0 if apply_motion_to_tail and motion_latent is not None: motion_latent = _apply_motion_amplitude_to_raw_tail( @@ -2196,6 +2262,14 @@ class WanImageMotionPro: anchor_reference=anchor_latent, anchor_blend=resolved_anchor_blend, ) + motion_latent, motion_tail_ok = _ensure_motion_tail_matches_anchor( + motion_latent, + anchor_reference=anchor_latent, + logger=self._log, + context="WanImageMotionPro", + ) + if not motion_tail_ok: + T_motion = 0 has_prev = motion_latent is not None and T_motion > 0 # --- 2. DC Drift Correction --- diff --git a/version.json b/version.json index 3b4c1fc..ea2d1df 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "name": "iamccs-nodes", - "version": "1.4.1", + "version": "1.4.2", "author": "Carmine Cristallo Scalzi (IAMCCS)", "description": "IAMCCS nodes for ComfyUI: IAMCCS echosystem for ComfyUI, nodes 4 LoRA, WAN 2.2, WAN 2.1 and LTX-2 pipelines, WANIMAGEMOTION pro added." } diff --git a/web/iamccs_cinematic_builders_ui.js b/web/iamccs_cinematic_builders_ui.js new file mode 100644 index 0000000..1a478de --- /dev/null +++ b/web/iamccs_cinematic_builders_ui.js @@ -0,0 +1,66 @@ +import { app } from "../../scripts/app.js"; + +const CINEMATIC_BUILDERS = new Set([ + "IAMCCS_LTX2_CinematicPromptComposer", + "IAMCCS_LTX2_CinematicShotLineBuilder", + "IAMCCS_LTX2_CinematicV2VTimelineLineBuilder", + "IAMCCS_LTX2_CinematicLineStacker", +]); + +const SIZE_PRESETS = { + IAMCCS_LTX2_CinematicPromptComposer: [520, 620], + IAMCCS_LTX2_CinematicShotLineBuilder: [560, 760], + IAMCCS_LTX2_CinematicV2VTimelineLineBuilder: [590, 840], + IAMCCS_LTX2_CinematicLineStacker: [520, 520], +}; + +function tuneWidgets(node) { + if (!node?.widgets) return; + for (const widget of node.widgets) { + if (!widget) continue; + const name = String(widget.name || "").toLowerCase(); + if ( + name.includes("prompt") || + name.includes("dialogue") || + name.includes("voice") || + name.includes("continuity") || + name.includes("comment") || + name.startsWith("line_") + ) { + widget.computeSize = function(width) { + return [width, name.startsWith("line_") ? 70 : 96]; + }; + } + } +} + +app.registerExtension({ + name: "iamccs.cinematic.builders.ui", + + async beforeRegisterNodeDef(nodeType, nodeData) { + const name = nodeData?.name || ""; + if (!CINEMATIC_BUILDERS.has(name)) return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + try { + this.color = "#243447"; + this.bgcolor = "#18232f"; + this.boxcolor = "#4f9fd8"; + this.shape = typeof LiteGraph !== "undefined" ? LiteGraph.BOX_SHAPE : 0; + + const preset = SIZE_PRESETS[name]; + if (preset && (!this.size || this.size[0] < preset[0])) { + this.size = [...preset]; + } + + tuneWidgets(this); + this.setDirtyCanvas(true, true); + } catch { + // UI-only enhancement; never block node loading. + } + return result; + }; + }, +}); diff --git a/web/iamccs_cinematic_timeline_editor.js b/web/iamccs_cinematic_timeline_editor.js new file mode 100644 index 0000000..3c27879 --- /dev/null +++ b/web/iamccs_cinematic_timeline_editor.js @@ -0,0 +1,682 @@ +import { app } from "../../scripts/app.js"; + +const EDITOR_VERSION = "2026-04-25-1"; + +const CUT_MODES = ["hard_cut", "continuity_cut", "soft_cut", "match_cut"]; +const AUDIO_OPTIONS = ["0", "1", "2", "3", "4", "5", "6", "7", "8"]; +const REF_OPTIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "1>2", "2>1", "1+2", "2+3", "3+1"]; +const SOURCE_OPTIONS = ["0", "1", "2", "3", "4", "5", "6", "7", "8"]; +const SOURCE_RANGE_OPTIONS = ["all", "0+96", "24+84", "48+120", "tail120", "0-96", "24-108"]; +const V2V_MODES = [ + "v2v_source_plus_reference", + "v2v_source_context", + "i2v_from_reference", + "two_segments_if_long", + "loop_if_long", +]; + +const FRAMING_OPTIONS = [ + "custom", + "wide establishing shot", + "medium shot", + "medium close-up", + "close-up", + "extreme close-up", + "over-the-shoulder", + "reverse angle", + "insert detail", + "two-shot", + "tracking shot", + "V2V close-up", + "V2V reverse angle", + "V2V wide shot", +]; + +const CONFIGS = { + IAMCCS_LTX2_CinematicMultiGenPlanner: { + kind: "multigen", + widgetName: "shot_lines", + title: "IAMCCS MultiGen Timeline Editor", + lineFields: ["seconds", "cut", "ref", "audio", "label", "prompt", "dialogue", "voice"], + }, + IAMCCS_LTX2_CinematicV2VTimelinePlanner: { + kind: "v2v", + widgetName: "timeline_lines", + title: "IAMCCS V2V Timeline Editor", + lineFields: ["seconds", "cut", "source", "sourceRange", "ref", "audio", "label", "prompt", "dialogue", "voice", "v2vMode"], + }, +}; + +function nodeClassName(node) { + return String(node?.comfyClass || node?.type || node?.constructor?.comfyClass || ""); +} + +function getConfigForNodeData(nodeData) { + const name = String(nodeData?.name || ""); + return CONFIGS[name] || null; +} + +function getConfigForNode(node) { + return CONFIGS[nodeClassName(node)] || null; +} + +function getWidget(node, name) { + return node?.widgets?.find((widget) => widget?.name === name || widget?.label === name) || null; +} + +function setWidgetValue(node, widgetName, value) { + const widget = getWidget(node, widgetName); + if (!widget) return false; + widget.value = value; + try { + widget.callback?.(value, app.canvas, node); + } catch { + // ignore UI callback issues + } + try { + node.setDirtyCanvas?.(true, true); + app.graph?.setDirtyCanvas?.(true, true); + } catch { + // ignore + } + return true; +} + +function splitPipeLine(line, expectedCount) { + const parts = String(line || "").split("|").map((part) => part.trim()); + while (parts.length < expectedCount) parts.push(""); + if (parts.length > expectedCount) { + const head = parts.slice(0, expectedCount - 1); + const tail = parts.slice(expectedCount - 1).join(" | "); + return [...head, tail]; + } + return parts; +} + +function cleanLinePart(value) { + return String(value ?? "").replace(/\s+/g, " ").trim(); +} + +function joinLine(values) { + return values.map(cleanLinePart).join(" | "); +} + +function parseRows(text, config) { + const rows = []; + const fields = config.lineFields; + for (const rawLine of String(text || "").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const parts = splitPipeLine(line, fields.length); + const row = {}; + fields.forEach((field, index) => { + row[field] = parts[index] || ""; + }); + row.framing = "custom"; + rows.push(normalizeRow(row, config.kind)); + } + if (!rows.length) { + return config.kind === "v2v" ? presetRows("v2v_two_camera") : presetRows("multigen_dialogue_4"); + } + return rows; +} + +function normalizeRow(row, kind) { + const out = { ...row }; + out.seconds = out.seconds || "4.0"; + out.cut = CUT_MODES.includes(out.cut) ? out.cut : "hard_cut"; + out.ref = out.ref || "1"; + out.audio = AUDIO_OPTIONS.includes(String(out.audio)) ? String(out.audio) : "0"; + out.label = out.label || "shot"; + out.prompt = out.prompt || (kind === "v2v" ? "V2V cinematic shot, keep source motion" : "cinematic shot, natural acting"); + out.dialogue = out.dialogue || ""; + out.voice = out.voice || ""; + out.framing = out.framing || "custom"; + if (kind === "v2v") { + out.source = SOURCE_OPTIONS.includes(String(out.source)) ? String(out.source) : "1"; + out.sourceRange = out.sourceRange || "all"; + out.v2vMode = V2V_MODES.includes(out.v2vMode) ? out.v2vMode : "v2v_source_plus_reference"; + } + return out; +} + +function rowToLine(row, config) { + const prompt = buildPromptWithFraming(row); + if (config.kind === "v2v") { + return joinLine([ + row.seconds, + row.cut, + row.source, + row.sourceRange, + row.ref, + row.audio, + row.label, + prompt, + row.dialogue, + row.voice, + row.v2vMode, + ]); + } + return joinLine([ + row.seconds, + row.cut, + row.ref, + row.audio, + row.label, + prompt, + row.dialogue, + row.voice, + ]); +} + +function rowsToText(rows, config) { + return rows.map((row) => rowToLine(row, config)).join("\n"); +} + +function buildPromptWithFraming(row) { + const framing = cleanLinePart(row.framing); + const prompt = cleanLinePart(row.prompt); + if (!framing || framing === "custom") return prompt; + if (prompt.toLowerCase().startsWith(framing.toLowerCase())) return prompt; + return `${framing}. ${prompt}`; +} + +function presetRows(key) { + const presets = { + multigen_dialogue_4: [ + { seconds: "4.0", cut: "hard_cut", ref: "1", audio: "1", label: "campo_mara", framing: "medium close-up", prompt: "Mara near the kitchen window, she speaks softly but firmly", dialogue: "You left the city without saying goodbye.", voice: "controlled hurt voice" }, + { seconds: "3.5", cut: "hard_cut", ref: "2", audio: "2", label: "controcampo_elio", framing: "reverse angle", prompt: "Elio at the doorway, he answers without stepping in", dialogue: "I thought silence would protect you.", voice: "low ashamed voice" }, + { seconds: "2.5", cut: "hard_cut", ref: "3", audio: "0", label: "insert_key", framing: "insert detail", prompt: "old brass key on the table, rain reflected on metal", dialogue: "", voice: "" }, + { seconds: "4.5", cut: "continuity_cut", ref: "1>2", audio: "1", label: "ritorno_mara", framing: "close-up", prompt: "Mara returns to silence, slow push-in, visible hesitation", dialogue: "It protected only you.", voice: "almost whispered voice" }, + ], + multigen_dialogue_2: [ + { seconds: "4.0", cut: "hard_cut", ref: "1", audio: "1", label: "campo_A", framing: "medium close-up", prompt: "character A speaks with restrained emotion, coherent eyeline", dialogue: "I knew you would come back.", voice: "tired intimate low voice" }, + { seconds: "3.5", cut: "hard_cut", ref: "2", audio: "2", label: "controcampo_B", framing: "reverse angle", prompt: "character B listens, then answers, natural reaction timing", dialogue: "I never really left.", voice: "calm low voice" }, + ], + multigen_monologue: [ + { seconds: "6.0", cut: "hard_cut", ref: "1", audio: "1", label: "monologo_closeup", framing: "close-up", prompt: "single character speaking quietly, subtle facial movement, stable identity", dialogue: "I need you to hear the whole story.", voice: "low confessional voice" }, + ], + v2v_two_camera: [ + { seconds: "4.0", cut: "hard_cut", source: "1", sourceRange: "0+96", ref: "1", audio: "1", label: "camera_A_mara", framing: "V2V close-up", prompt: "Mara speaking, keep source head movement and natural eye line", dialogue: "You left the city without saying goodbye.", voice: "controlled hurt voice", v2vMode: "v2v_source_plus_reference" }, + { seconds: "3.5", cut: "hard_cut", source: "2", sourceRange: "24+84", ref: "2", audio: "2", label: "camera_B_elio", framing: "V2V reverse angle", prompt: "Elio answering, keep source performance and natural eye line", dialogue: "I thought silence would protect you.", voice: "low ashamed voice", v2vMode: "v2v_source_plus_reference" }, + { seconds: "5.0", cut: "continuity_cut", source: "1", sourceRange: "tail120", ref: "1>2", audio: "0", label: "return_camera_A", framing: "V2V close-up", prompt: "continue from camera A, slow push-in, preserve gesture continuity", dialogue: "", voice: "", v2vMode: "two_segments_if_long" }, + ], + v2v_single: [ + { seconds: "4.0", cut: "hard_cut", source: "1", sourceRange: "all", ref: "1", audio: "1", label: "v2v_single_take", framing: "V2V close-up", prompt: "preserve original motion, improve cinematic light and texture", dialogue: "", voice: "", v2vMode: "v2v_source_plus_reference" }, + ], + v2v_long: [ + { seconds: "8.0", cut: "continuity_cut", source: "1", sourceRange: "0+192", ref: "1", audio: "1", label: "long_part_1", framing: "V2V tracking shot", prompt: "continuous movement, preserve direction and speed", dialogue: "", voice: "", v2vMode: "two_segments_if_long" }, + { seconds: "8.0", cut: "continuity_cut", source: "1", sourceRange: "tail192", ref: "1", audio: "1", label: "long_part_2", framing: "V2V tracking shot", prompt: "continue same movement, preserve gesture continuity", dialogue: "", voice: "", v2vMode: "two_segments_if_long" }, + ], + }; + return (presets[key] || presets.multigen_dialogue_4).map((row) => normalizeRow(row, key.startsWith("v2v") ? "v2v" : "multigen")); +} + +function ensureStyles() { + if (document.getElementById("iamccs-cinematic-timeline-editor-style")) return; + const style = document.createElement("style"); + style.id = "iamccs-cinematic-timeline-editor-style"; + style.textContent = ` + .iamccs-cine-overlay { + position: fixed; + inset: 0; + z-index: 100000; + background: rgba(5, 8, 12, 0.72); + display: flex; + align-items: center; + justify-content: center; + font-family: Inter, Arial, sans-serif; + color: #eaf1f8; + } + .iamccs-cine-modal { + width: min(96vw, 1500px); + height: min(92vh, 920px); + background: #101820; + border: 1px solid #375064; + box-shadow: 0 24px 80px rgba(0,0,0,0.55); + display: grid; + grid-template-rows: auto auto 1fr auto; + overflow: hidden; + } + .iamccs-cine-header, + .iamccs-cine-toolbar, + .iamccs-cine-footer { + padding: 12px 16px; + border-bottom: 1px solid #263847; + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + } + .iamccs-cine-footer { + border-top: 1px solid #263847; + border-bottom: 0; + justify-content: space-between; + } + .iamccs-cine-title { + font-size: 18px; + font-weight: 700; + letter-spacing: 0; + margin-right: auto; + } + .iamccs-cine-help { + color: #9eb2c4; + font-size: 12px; + } + .iamccs-cine-btn, + .iamccs-cine-select, + .iamccs-cine-input, + .iamccs-cine-textarea { + background: #172331; + color: #edf6ff; + border: 1px solid #3c5368; + border-radius: 6px; + font-size: 12px; + font-family: Inter, Arial, sans-serif; + } + .iamccs-cine-btn { + padding: 8px 10px; + cursor: pointer; + white-space: nowrap; + } + .iamccs-cine-btn:hover { background: #20344a; } + .iamccs-cine-btn.primary { background: #2d6f9f; border-color: #4d9bd0; } + .iamccs-cine-btn.danger { background: #552734; border-color: #81445a; } + .iamccs-cine-select, + .iamccs-cine-input { + height: 30px; + padding: 4px 6px; + } + .iamccs-cine-textarea { + min-height: 54px; + padding: 6px; + resize: vertical; + line-height: 1.25; + } + .iamccs-cine-body { + overflow: auto; + padding: 12px 16px; + } + .iamccs-cine-table { + border-collapse: separate; + border-spacing: 0; + min-width: 1250px; + width: 100%; + } + .iamccs-cine-table th { + position: sticky; + top: 0; + background: #172331; + z-index: 1; + border-bottom: 1px solid #3c5368; + color: #bcd0df; + font-size: 11px; + text-align: left; + padding: 7px 6px; + } + .iamccs-cine-table td { + border-bottom: 1px solid #253747; + padding: 6px; + vertical-align: top; + } + .iamccs-cine-row-index { + color: #91a9bc; + font-weight: 700; + text-align: center; + padding-top: 12px; + } + .iamccs-cine-row-actions { + display: grid; + grid-template-columns: repeat(2, 30px); + gap: 4px; + } + .iamccs-cine-row-actions button { + height: 28px; + padding: 0; + } + .iamccs-cine-preview { + width: min(720px, 44vw); + min-height: 86px; + max-height: 130px; + color: #c9d8e5; + background: #0b1118; + } + .iamccs-cine-status { + color: #9eb2c4; + font-size: 12px; + } + `; + document.head.appendChild(style); +} + +function makeOption(value, label = value) { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + return option; +} + +function makeSelect(value, options, onChange) { + const select = document.createElement("select"); + select.className = "iamccs-cine-select"; + for (const option of options) select.appendChild(makeOption(option)); + select.value = String(value || options[0] || ""); + if (!options.includes(select.value) && options.length) { + select.appendChild(makeOption(select.value)); + } + select.addEventListener("change", () => onChange(select.value)); + return select; +} + +function makeInput(value, onChange, attrs = {}) { + const input = document.createElement("input"); + input.className = "iamccs-cine-input"; + input.value = String(value ?? ""); + for (const [key, val] of Object.entries(attrs)) input.setAttribute(key, val); + input.addEventListener("input", () => onChange(input.value)); + return input; +} + +function makeTextarea(value, onChange) { + const textarea = document.createElement("textarea"); + textarea.className = "iamccs-cine-textarea"; + textarea.value = String(value ?? ""); + textarea.addEventListener("input", () => onChange(textarea.value)); + return textarea; +} + +function stopCanvasKeys(element) { + const stop = (event) => event.stopPropagation(); + for (const eventName of ["keydown", "keyup", "keypress", "pointerdown", "pointerup", "wheel"]) { + element.addEventListener(eventName, stop, { capture: true }); + } +} + +function createDatalist(id, values) { + let list = document.getElementById(id); + if (list) return; + list = document.createElement("datalist"); + list.id = id; + for (const value of values) list.appendChild(makeOption(value)); + document.body.appendChild(list); +} + +function addCell(rowEl, child, width = null) { + const td = document.createElement("td"); + if (width) td.style.width = width; + td.appendChild(child); + rowEl.appendChild(td); + return td; +} + +function openTimelineEditor(node, config) { + ensureStyles(); + createDatalist("iamccs-cine-ref-options", REF_OPTIONS); + createDatalist("iamccs-cine-range-options", SOURCE_RANGE_OPTIONS); + + const widget = getWidget(node, config.widgetName); + const rows = parseRows(widget?.value || "", config); + let selectedPreset = config.kind === "v2v" ? "v2v_two_camera" : "multigen_dialogue_4"; + + const overlay = document.createElement("div"); + overlay.className = "iamccs-cine-overlay"; + const modal = document.createElement("div"); + modal.className = "iamccs-cine-modal"; + overlay.appendChild(modal); + stopCanvasKeys(overlay); + + const header = document.createElement("div"); + header.className = "iamccs-cine-header"; + const title = document.createElement("div"); + title.className = "iamccs-cine-title"; + title.textContent = config.title; + const help = document.createElement("div"); + help.className = "iamccs-cine-help"; + help.textContent = config.kind === "v2v" + ? "Edit V2V source, range, reference, audio and prompt. Apply writes timeline_lines." + : "Edit shots with dropdowns and prompt boxes. Apply writes shot_lines."; + header.append(title, help); + + const toolbar = document.createElement("div"); + toolbar.className = "iamccs-cine-toolbar"; + + const presetSelect = makeSelect(selectedPreset, config.kind === "v2v" + ? ["v2v_two_camera", "v2v_single", "v2v_long"] + : ["multigen_dialogue_4", "multigen_dialogue_2", "multigen_monologue"], (value) => { + selectedPreset = value; + }); + const loadPreset = button("Load preset", () => { + rows.splice(0, rows.length, ...presetRows(selectedPreset)); + renderRows(); + updatePreview(); + }); + const addRow = button("Add row", () => { + const base = rows[rows.length - 1] || (config.kind === "v2v" ? presetRows("v2v_single")[0] : presetRows("multigen_monologue")[0]); + rows.push({ ...base, label: `${base.label || "shot"}_${rows.length + 1}` }); + renderRows(); + updatePreview(); + }); + const clearRows = button("Clear", () => { + rows.splice(0, rows.length); + renderRows(); + updatePreview(); + }, "danger"); + toolbar.append(labelText("Preset"), presetSelect, loadPreset, addRow, clearRows); + + const body = document.createElement("div"); + body.className = "iamccs-cine-body"; + const table = document.createElement("table"); + table.className = "iamccs-cine-table"; + body.appendChild(table); + + const footer = document.createElement("div"); + footer.className = "iamccs-cine-footer"; + const status = document.createElement("div"); + status.className = "iamccs-cine-status"; + const preview = document.createElement("textarea"); + preview.className = "iamccs-cine-textarea iamccs-cine-preview"; + preview.readOnly = true; + const actions = document.createElement("div"); + actions.style.display = "flex"; + actions.style.gap = "8px"; + actions.style.alignItems = "center"; + actions.append( + button("Copy text", async () => { + try { + await navigator.clipboard?.writeText(preview.value); + status.textContent = "Copied generated lines."; + } catch { + status.textContent = "Copy failed; select preview text manually."; + } + }), + button("Cancel", () => close()), + button("Apply to node", () => { + setWidgetValue(node, config.widgetName, rowsToText(rows, config)); + status.textContent = `Applied ${rows.length} rows to ${config.widgetName}.`; + close(); + }, "primary"), + ); + footer.append(preview, status, actions); + + modal.append(header, toolbar, body, footer); + document.body.appendChild(overlay); + + overlay.addEventListener("pointerdown", (event) => { + if (event.target === overlay) close(); + }); + document.addEventListener("keydown", onDocumentKeydown, { capture: true }); + + function onDocumentKeydown(event) { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + close(); + } + } + + function close() { + document.removeEventListener("keydown", onDocumentKeydown, { capture: true }); + overlay.remove(); + } + + function renderRows() { + table.textContent = ""; + const thead = document.createElement("thead"); + const headerRow = document.createElement("tr"); + const columns = config.kind === "v2v" + ? ["#", "seconds", "cut", "source", "range", "ref", "audio", "label", "framing", "prompt", "dialogue", "voice", "mode", "actions"] + : ["#", "seconds", "cut", "ref", "audio", "label", "framing", "prompt", "dialogue", "voice", "actions"]; + for (const col of columns) { + const th = document.createElement("th"); + th.textContent = col; + headerRow.appendChild(th); + } + thead.appendChild(headerRow); + table.appendChild(thead); + + const tbody = document.createElement("tbody"); + rows.forEach((row, index) => { + const tr = document.createElement("tr"); + addIndexCell(tr, index); + addCell(tr, makeInput(row.seconds, (v) => { row.seconds = v; updatePreview(); }, { type: "number", step: "0.1", min: "0.01" }), "70px"); + addCell(tr, makeSelect(row.cut, CUT_MODES, (v) => { row.cut = v; updatePreview(); }), "135px"); + if (config.kind === "v2v") { + addCell(tr, makeSelect(row.source, SOURCE_OPTIONS, (v) => { row.source = v; updatePreview(); }), "70px"); + addCell(tr, makeInput(row.sourceRange, (v) => { row.sourceRange = v; updatePreview(); }, { list: "iamccs-cine-range-options" }), "115px"); + } + addCell(tr, makeInput(row.ref, (v) => { row.ref = v; updatePreview(); }, { list: "iamccs-cine-ref-options" }), "90px"); + addCell(tr, makeSelect(row.audio, AUDIO_OPTIONS, (v) => { row.audio = v; updatePreview(); }), "70px"); + addCell(tr, makeInput(row.label, (v) => { row.label = v; updatePreview(); }), "140px"); + addCell(tr, makeSelect(row.framing, FRAMING_OPTIONS, (v) => { row.framing = v; updatePreview(); }), "160px"); + addCell(tr, makeTextarea(row.prompt, (v) => { row.prompt = v; updatePreview(); }), "260px"); + addCell(tr, makeTextarea(row.dialogue, (v) => { row.dialogue = v; updatePreview(); }), "220px"); + addCell(tr, makeTextarea(row.voice, (v) => { row.voice = v; updatePreview(); }), "170px"); + if (config.kind === "v2v") { + addCell(tr, makeSelect(row.v2vMode, V2V_MODES, (v) => { row.v2vMode = v; updatePreview(); }), "190px"); + } + addCell(tr, rowActions(index), "74px"); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + updatePreview(); + } + + function addIndexCell(tr, index) { + const div = document.createElement("div"); + div.className = "iamccs-cine-row-index"; + div.textContent = String(index + 1); + addCell(tr, div, "34px"); + } + + function rowActions(index) { + const box = document.createElement("div"); + box.className = "iamccs-cine-row-actions"; + box.append( + button("up", () => { + if (index <= 0) return; + const [row] = rows.splice(index, 1); + rows.splice(index - 1, 0, row); + renderRows(); + }), + button("down", () => { + if (index >= rows.length - 1) return; + const [row] = rows.splice(index, 1); + rows.splice(index + 1, 0, row); + renderRows(); + }), + button("dup", () => { + rows.splice(index + 1, 0, { ...rows[index], label: `${rows[index].label || "shot"}_copy` }); + renderRows(); + }), + button("del", () => { + rows.splice(index, 1); + renderRows(); + }, "danger"), + ); + return box; + } + + function updatePreview() { + const text = rowsToText(rows, config); + preview.value = text; + status.textContent = `${rows.length} row(s). ${config.widgetName} will receive ${text.length} characters.`; + } + + renderRows(); +} + +function button(text, onClick, variant = "") { + const btn = document.createElement("button"); + btn.className = `iamccs-cine-btn ${variant || ""}`.trim(); + btn.type = "button"; + btn.textContent = text; + btn.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + onClick(); + }); + return btn; +} + +function labelText(text) { + const span = document.createElement("span"); + span.className = "iamccs-cine-help"; + span.textContent = text; + return span; +} + +function installEditorButton(node, config) { + if (!node || node.__iamccsTimelineEditorInstalled) return; + node.__iamccsTimelineEditorInstalled = true; + node.properties = node.properties || {}; + + const rawWidget = getWidget(node, config.widgetName); + if (rawWidget) { + rawWidget.label = config.kind === "v2v" ? "timeline_lines raw text" : "shot_lines raw text"; + } + + const editorButton = node.addWidget("button", "Open Timeline Editor", null, () => { + openTimelineEditor(node, config); + }, { serialize: false }); + editorButton.serialize = false; + editorButton.options = { ...(editorButton.options || {}), serialize: false }; + + const presetButton = node.addWidget("button", "Load Default Timeline", null, () => { + const key = config.kind === "v2v" ? "v2v_two_camera" : "multigen_dialogue_4"; + const rows = presetRows(key); + setWidgetValue(node, config.widgetName, rowsToText(rows, config)); + }, { serialize: false }); + presetButton.serialize = false; + presetButton.options = { ...(presetButton.options || {}), serialize: false }; + + try { + const size = node.computeSize?.() || node.size; + node.setSize?.([Math.max(node.size?.[0] || 0, 560), Math.max(node.size?.[1] || 0, size?.[1] || 520)]); + node.setDirtyCanvas?.(true, true); + } catch { + // ignore + } +} + +app.registerExtension({ + name: "iamccs.cinematic.timeline_editor", + + async beforeRegisterNodeDef(nodeType, nodeData) { + const config = getConfigForNodeData(nodeData); + if (!config) return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + installEditorButton(this, config); + return result; + }; + }, + + async loadedGraphNode(node) { + const config = getConfigForNode(node); + if (config) installEditorButton(node, config); + }, +}); + +console.log(`[IAMCCS Cinematic] Timeline editor loaded v=${EDITOR_VERSION}`); diff --git a/web/iamccs_ltx2_time_length_sync.js b/web/iamccs_ltx2_time_length_sync.js index e67f25c..10ea51d 100644 --- a/web/iamccs_ltx2_time_length_sync.js +++ b/web/iamccs_ltx2_time_length_sync.js @@ -12,9 +12,12 @@ const TIMEFRAME_TYPE = "IAMCCS_LTX2_TimeFrameCount"; const VALIDATOR_TYPE = "IAMCCS_LTX2_Validator"; const FRAMERATE_SYNC_TYPE = "IAMCCS_LTX2_FrameRateSync"; const SEGMENT_PLANNER_TYPE = "IAMCCS_SegmentPlanner"; +const SEGMENT_PLANNER_LINKED_TYPE = "IAMCCS_SegmentPlannerLinked"; +const SEGMENT_PLANNER_SETTINGS_TYPE = "IAMCCS_SegmentPlannerSettings"; const SEGMENT_PLANNER_PREVIEW_WIDGET = "iamccs_segmentplanner_live_preview"; const SEGMENT_PLANNER_DETAILS_WIDGET = "iamccs_segmentplanner_live_details"; const SEGMENT_PLANNER_COPY_BUTTON = "iamccs_segmentplanner_copy_button"; +const SEGMENT_PLANNER_SETTINGS_REPORT_WIDGET = "iamccs_segmentplanner_settings_live_report"; console.log("[IAMCCS LTX2] Loading seconds/length sync..."); @@ -23,12 +26,77 @@ function getWidget(node, name) { return node.widgets.find(w => w?.name === name || w?.label === name) || null; } +function getInput(node, name) { + if (!node?.inputs?.length) return null; + return node.inputs.find(i => i?.name === name) || null; +} + +function getNodeById(nodeId) { + if (nodeId == null) return null; + return app?.graph?._nodes_by_id?.[nodeId] || app?.graph?._nodes?.find(n => n?.id === nodeId) || null; +} + +function getLinkedSource(node, inputName) { + const input = getInput(node, inputName); + const linkId = input?.link; + if (linkId == null) return null; + + const link = app?.graph?.links?.[linkId]; + if (!link) return null; + + const originId = Array.isArray(link) ? link[1] : link.origin_id; + const originSlot = Array.isArray(link) ? link[2] : link.origin_slot; + const sourceNode = getNodeById(originId); + if (!sourceNode) return null; + + // Defensive: outputs must exist and be an array, originSlot must be valid + let sourceOutput = null; + if (Array.isArray(sourceNode.outputs) && originSlot != null && originSlot >= 0 && originSlot < sourceNode.outputs.length) { + sourceOutput = sourceNode.outputs[originSlot]; + } + + return { + sourceNode, + sourceOutput, + }; +} + +function getLinkedPrimitiveValue(node, inputName) { + const source = getLinkedSource(node, inputName); + if (!source?.sourceNode) return null; + + const sourceNode = source.sourceNode; + const outputName = String(source.sourceOutput?.name || ""); + const candidateWidgetNames = [outputName, inputName, "value", "float", "int", "string", "text"]; + + // Defensive: try to get value from output if present + if (source.sourceOutput && typeof source.sourceOutput.value !== "undefined") { + return source.sourceOutput.value; + } + + for (const widgetName of candidateWidgetNames) { + const widget = getWidget(sourceNode, widgetName); + if (widget?.value != null) return widget.value; + } + + if (Array.isArray(sourceNode.widgets) && sourceNode.widgets.length > 0) { + const widgetValue = sourceNode.widgets[0]?.value; + if (widgetValue != null) return widgetValue; + } + + return null; +} + function clampNumber(v, min, max) { const n = Number(v); if (!Number.isFinite(n)) return min; return Math.max(min, Math.min(max, n)); } +function numbersClose(a, b, epsilon = 0.0001) { + return Math.abs(Number(a) - Number(b)) <= epsilon; +} + function getGraphFpsOrDefault(defaultFps = 25) { try { const nodes = app?.graph?._nodes || []; @@ -118,44 +186,163 @@ function ensurePlannerCopyButton(node) { return widget; } +function ensurePlannerSettingsReportWidget(node) { + let widget = getWidget(node, SEGMENT_PLANNER_SETTINGS_REPORT_WIDGET); + if (widget) { + widget.label = "Live Preview"; + return widget; + } + + widget = node.addWidget("text", "Live Preview", "", () => {}, { multiline: true }); + widget.name = SEGMENT_PLANNER_SETTINGS_REPORT_WIDGET; + widget.serialize = false; + try { + widget.inputEl?.setAttribute?.("readonly", true); + } catch { + // ignore + } + return widget; +} + function ensurePlannerNodeSize(node) { try { const width = Math.max(460, Number(node.size?.[0] || 0)); - const height = Math.max(520, Number(node.size?.[1] || 0)); + const height = Math.max(node?.type === SEGMENT_PLANNER_SETTINGS_TYPE ? 300 : 520, Number(node.size?.[1] || 0)); node.size = [width, height]; } catch { // ignore } } +function refreshLinkedPlannerPreviews() { + try { + const nodes = app?.graph?._nodes || []; + for (const node of nodes) { + if (node?.type === SEGMENT_PLANNER_LINKED_TYPE) { + updateSegmentPlannerPreview(node); + } + } + } catch { + // ignore + } +} + +function updateSegmentPlannerSettingsReport(node) { + const wSeg = getWidget(node, "segment_duration_s"); + const wPlanning = getWidget(node, "planning_mode"); + const wProfile = getWidget(node, "segment_preset") || getWidget(node, "content_profile"); + const wOverlap = getWidget(node, "overlap_frames"); + const wAutoSync = getWidget(node, "auto_sync_overlap"); + if (!wSeg || !wPlanning || !wProfile || !wOverlap || !wAutoSync) return; + + const segmentDuration = clampNumber(wSeg.value, 0.01, 3600.0); + const planningMode = String(wPlanning.value || "manual_segment_seconds"); + const segmentPreset = String(wProfile.value || "10sec"); + const autoSyncOverlap = Boolean(wAutoSync.value); + + const rec = getPlannerProfile(segmentPreset); + const explicitPresetMode = planningMode === "explicit_preset_seconds" || planningMode === "auto_profile"; + const autoDurationProfile = !explicitPresetMode ? getAutoOverlapProfileForDuration(segmentDuration) : null; + const effectiveSegmentDuration = explicitPresetMode ? rec.segmentSeconds : segmentDuration; + const effectiveOverlapFrames = clampNumber(wOverlap.value, 0, 4096); + + if (explicitPresetMode && !numbersClose(wSeg.value, effectiveSegmentDuration)) { + wSeg.value = effectiveSegmentDuration; + } + + const previewFps = getGraphFpsOrDefault(24); + const uniqueFrames = Math.max(1, Math.round(effectiveSegmentDuration * previewFps)); + const firstRawFrames = snapLengthToLtx2Rule(uniqueFrames, "up"); + const continuationRawFrames = snapLengthToLtx2Rule(uniqueFrames + effectiveOverlapFrames, "up"); + + const report = [ + `segment_duration_s = ${effectiveSegmentDuration.toFixed(3)}`, + `planning_mode = ${planningMode}`, + `segment_preset = ${segmentPreset}`, + `overlap_frames = ${effectiveOverlapFrames}`, + `preview_fps = ${previewFps}`, + `unique_segment_frames = ${uniqueFrames}`, + `first_segment_raw_frames = ${firstRawFrames}`, + `continuation_raw_frames = ${continuationRawFrames}`, + `auto_sync_overlap = ${autoSyncOverlap}`, + `auto_duration_profile = ${autoDurationProfile?.profileLabel || "none"}`, + `overlap_policy = manual/default_9`, + `recommended_extension_preset = ${rec.preset}`, + ].join("\n"); + + const widget = ensurePlannerSettingsReportWidget(node); + widget.value = report; + node.properties = node.properties || {}; + node.properties.iamccs_segmentplanner_settings_live_report = report; + ensurePlannerNodeSize(node); + try { + node.setDirtyCanvas(true, true); + } catch { + // ignore + } +} + +function getPlannerProfile(presetOrLegacyProfile) { + const value = String(presetOrLegacyProfile || "15sec"); + if (value === "20sec") { + return { segmentSeconds: 20.0, overlap: 9, leftContext: 1.0, preset: "monologue_audio_24fps", profileLabel: "20sec" }; + } + if (value === "15sec" || value === "monologue") { + return { segmentSeconds: 15.0, overlap: 9, leftContext: 0.75, preset: "monologue_audio_24fps", profileLabel: "15sec" }; + } + if (value === "5sec") { + return { segmentSeconds: 5.0, overlap: 9, leftContext: 0.25, preset: "videoclip_audio_24fps", profileLabel: "5sec" }; + } + return { segmentSeconds: 10.0, overlap: 9, leftContext: 0.5, preset: "videoclip_audio_24fps", profileLabel: "10sec" }; +} + +function getAutoOverlapProfileForDuration(segmentDuration) { + const duration = Number(segmentDuration); + if (!Number.isFinite(duration)) return null; + const profiles = [ + getPlannerProfile("5sec"), + getPlannerProfile("10sec"), + getPlannerProfile("15sec"), + getPlannerProfile("20sec"), + ]; + return profiles.find(profile => Math.abs(duration - profile.segmentSeconds) <= 0.1) || null; +} + function updateSegmentPlannerPreview(node) { const wSong = getWidget(node, "song_duration_s"); const wFps = getWidget(node, "fps"); const wSeg = getWidget(node, "segment_duration_s"); - const wPlanning = getWidget(node, "planning_mode"); - const wProfile = getWidget(node, "content_profile"); - const wOverlap = getWidget(node, "overlap_frames"); + const wPlanning = getWidget(node, "planning_mode") || getWidget(node, "planning_mode_in"); + const wProfile = getWidget(node, "segment_preset") || getWidget(node, "segment_preset_in") || getWidget(node, "content_profile"); + const wOverlap = getWidget(node, "overlap_frames") || getWidget(node, "overlap_frames_in"); const wRound = getWidget(node, "ltx_round_mode"); const wIndex = getWidget(node, "segment_index"); if (!wSong || !wFps || !wSeg || !wPlanning || !wProfile || !wOverlap || !wRound || !wIndex) return; const songDuration = clampNumber(wSong.value, 0.01, 36000); const fps = clampNumber(wFps.value, 0.001, 240.0); - const segmentDuration = clampNumber(wSeg.value, 0.01, 3600.0); - const planningMode = String(wPlanning.value || "manual_segment_seconds"); - const contentProfile = String(wProfile.value || "videoclip"); - const overlapFrames = clampNumber(wOverlap.value, 0, 4096); + const linkedSegmentDuration = node?.type === SEGMENT_PLANNER_LINKED_TYPE ? getLinkedPrimitiveValue(node, "segment_duration_s") : null; + const linkedPlanningMode = node?.type === SEGMENT_PLANNER_LINKED_TYPE ? getLinkedPrimitiveValue(node, "planning_mode_in") : null; + const linkedSegmentPreset = node?.type === SEGMENT_PLANNER_LINKED_TYPE ? getLinkedPrimitiveValue(node, "segment_preset_in") : null; + const linkedOverlapFrames = node?.type === SEGMENT_PLANNER_LINKED_TYPE ? getLinkedPrimitiveValue(node, "overlap_frames_in") : null; + + const segmentDuration = clampNumber(linkedSegmentDuration ?? wSeg.value, 0.01, 3600.0); + const planningMode = String(linkedPlanningMode ?? wPlanning.value || "manual_segment_seconds"); + const segmentPreset = String(linkedSegmentPreset ?? wProfile.value || "15sec"); + const inputOverlapFrames = clampNumber(linkedOverlapFrames ?? wOverlap.value, 0, 4096); const roundMode = String(wRound.value || "up"); const segmentIndex = Math.max(0, Math.trunc(Number(wIndex.value || 0))); - const rec = contentProfile === "monologue" - ? { baseTargetS: 15.0, minS: 8.0, maxS: 15.0, overlap: 13, leftContext: 0.75, preset: "monologue_audio_24fps" } - : { baseTargetS: 10.0, minS: 5.0, maxS: 10.0, overlap: 9, leftContext: 0.5, preset: "videoclip_audio_24fps" }; + const rec = getPlannerProfile(segmentPreset); + const explicitPresetMode = planningMode === "explicit_preset_seconds" || planningMode === "auto_profile"; + const autoDurationProfile = !explicitPresetMode ? getAutoOverlapProfileForDuration(segmentDuration) : null; + const effectiveSegmentDuration = explicitPresetMode ? rec.segmentSeconds : segmentDuration; + const effectiveOverlapFrames = inputOverlapFrames; - const effectiveSegmentDuration = planningMode === "auto_profile" - ? Math.max(rec.minS, Math.min(rec.maxS, songDuration / Math.max(1, Math.ceil(songDuration / rec.baseTargetS)))) - : segmentDuration; - const effectiveOverlapFrames = planningMode === "auto_profile" ? rec.overlap : overlapFrames; + if (explicitPresetMode && node?.type !== SEGMENT_PLANNER_LINKED_TYPE && !numbersClose(wSeg.value, effectiveSegmentDuration)) { + wSeg.value = effectiveSegmentDuration; + } + const overlapFrames = clampNumber(wOverlap.value, 0, 4096); const totalFrames = Math.max(1, Math.round(songDuration * fps)); const uniqueFrames = Math.max(1, Math.round(effectiveSegmentDuration * fps)); @@ -178,7 +365,7 @@ function updateSegmentPlannerPreview(node) { `total=${totalFrames}f`, `unique=${uniqueFrames}f`, `mode=${planningMode}`, - `profile=${contentProfile}`, + `profile=${segmentPreset}`, `first_raw=${firstRaw}f`, `next_raw=${nextRaw}f`, `segments=${segments}`, @@ -197,9 +384,10 @@ function updateSegmentPlannerPreview(node) { `segment_duration_s = ${segmentDuration.toFixed(3)}`, `effective_segment_duration_s = ${effectiveSegmentDuration.toFixed(3)}`, `planning_mode = ${planningMode}`, - `content_profile = ${contentProfile}`, + `segment_preset = ${segmentPreset}`, `overlap_frames = ${overlapFrames}`, `effective_overlap_frames = ${effectiveOverlapFrames}`, + `overlap_policy = manual/default_9`, `ltx_round_mode = ${roundMode}`, `segment_index = ${clampedIndex}`, `total_frames = ${totalFrames}`, @@ -218,6 +406,7 @@ function updateSegmentPlannerPreview(node) { `current_segment_end_s = ${currentEndS.toFixed(3)}`, `recommended_audio_left_context_s = ${rec.leftContext.toFixed(2)}`, `recommended_extension_preset = ${rec.preset}`, + `auto_duration_profile = ${autoDurationProfile?.profileLabel || "none"}`, ].join("\n"); const widget = ensurePlannerPreviewWidget(node); @@ -241,9 +430,9 @@ function installSegmentPlannerSync(node) { getWidget(node, "song_duration_s"), getWidget(node, "fps"), getWidget(node, "segment_duration_s"), - getWidget(node, "planning_mode"), - getWidget(node, "content_profile"), - getWidget(node, "overlap_frames"), + getWidget(node, "planning_mode") || getWidget(node, "planning_mode_in"), + getWidget(node, "segment_preset") || getWidget(node, "segment_preset_in") || getWidget(node, "content_profile"), + getWidget(node, "overlap_frames") || getWidget(node, "overlap_frames_in"), getWidget(node, "ltx_round_mode"), getWidget(node, "segment_index"), ].filter(Boolean); @@ -271,6 +460,38 @@ function installSegmentPlannerSync(node) { updateSegmentPlannerPreview(node); } +function installSegmentPlannerSettingsSync(node) { + const widgets = [ + getWidget(node, "segment_duration_s"), + getWidget(node, "planning_mode"), + getWidget(node, "segment_preset"), + getWidget(node, "overlap_frames"), + getWidget(node, "auto_sync_overlap"), + ].filter(Boolean); + + if (!widgets.length || node._iamccsSegmentPlannerSettingsSyncInstalled) { + updateSegmentPlannerSettingsReport(node); + return; + } + + node._iamccsSegmentPlannerSettingsSyncInstalled = true; + ensurePlannerSettingsReportWidget(node); + ensurePlannerNodeSize(node); + + for (const widget of widgets) { + const prev = widget.callback; + widget.callback = function () { + const r = prev?.apply(this, arguments); + updateSegmentPlannerSettingsReport(node); + refreshLinkedPlannerPreviews(); + return r; + }; + } + + updateSegmentPlannerSettingsReport(node); + refreshLinkedPlannerPreviews(); +} + function installSecondsLengthSync(node, { snapRule = false } = {}) { const wSeconds = getWidget(node, "seconds"); const wLength = getWidget(node, "length"); @@ -321,13 +542,15 @@ app.registerExtension({ if (!nodeData?.name) return; const name = nodeData.name; - if (name !== TIMEFRAME_TYPE && name !== VALIDATOR_TYPE && name !== SEGMENT_PLANNER_TYPE) return; + if (name !== TIMEFRAME_TYPE && name !== VALIDATOR_TYPE && name !== SEGMENT_PLANNER_TYPE && name !== SEGMENT_PLANNER_LINKED_TYPE && name !== SEGMENT_PLANNER_SETTINGS_TYPE) return; const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const r = onNodeCreated?.apply(this, arguments); - if (name === SEGMENT_PLANNER_TYPE) { + if (name === SEGMENT_PLANNER_TYPE || name === SEGMENT_PLANNER_LINKED_TYPE) { installSegmentPlannerSync(this); + } else if (name === SEGMENT_PLANNER_SETTINGS_TYPE) { + installSegmentPlannerSettingsSync(this); } else { // Snap to 8n+1 on both nodes, since LTX-2 VAE encode requires it. installSecondsLengthSync(this, { snapRule: true }); @@ -338,9 +561,12 @@ app.registerExtension({ const onConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { const r = onConfigure?.apply(this, arguments); - if (name === SEGMENT_PLANNER_TYPE) { + if (name === SEGMENT_PLANNER_TYPE || name === SEGMENT_PLANNER_LINKED_TYPE) { installSegmentPlannerSync(this); updateSegmentPlannerPreview(this); + } else if (name === SEGMENT_PLANNER_SETTINGS_TYPE) { + installSegmentPlannerSettingsSync(this); + updateSegmentPlannerSettingsReport(this); } return r; }; diff --git a/web/iamccs_supernodes_exec_ui.js b/web/iamccs_supernodes_exec_ui.js new file mode 100644 index 0000000..d20f1a2 --- /dev/null +++ b/web/iamccs_supernodes_exec_ui.js @@ -0,0 +1,808 @@ +import { app } from "../../../scripts/app.js"; + +const PRESET_CONFIGS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { modular_decode: "low_ram", steps: 16, image_compression: 40, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + balanced: { modular_decode: "normal", steps: 20, image_compression: 33, continuity_anchor_mode: "off", anchor_refresh_interval: 3, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + high_quality: { modular_decode: "high", steps: 24, image_compression: 28, continuity_anchor_mode: "off", anchor_refresh_interval: 1, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + fast_preview: { modular_decode: "low_ram", steps: 12, image_compression: 45, continuity_anchor_mode: "off", anchor_refresh_interval: 2, anti_drift_mode: "off", anti_drift_strength: 0.0, identity_persistence_strength: 0.0 }, + }, + visibility: { + low_ram_safe: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + balanced: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + high_quality: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: true }, + fast_preview: { anchor_image_strength: false, anti_drift_mode: false, anti_drift_strength: false, identity_persistence_strength: false, output_root: false }, + }, + }, + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": { + presetWidget: "ui_preset", + defaultPreset: "balanced", + values: { + low_ram_safe: { decode_mode: "low_ram", jpg_quality: 92, crf: 21, tiled_tile_size: 512, tiled_overlap: 64 }, + balanced: { decode_mode: "normal", jpg_quality: 95, crf: 19, tiled_tile_size: 512, tiled_overlap: 64 }, + high_quality: { decode_mode: "high", jpg_quality: 100, crf: 16, tiled_tile_size: 768, tiled_overlap: 96 }, + fast_preview: { decode_mode: "low_ram", jpg_quality: 88, crf: 24, tiled_tile_size: 384, tiled_overlap: 48 }, + }, + visibility: { + low_ram_safe: { frames_subdir: true, image_format: true, jpg_quality: true }, + balanced: { frames_subdir: true, image_format: true, jpg_quality: true }, + high_quality: { frames_subdir: true, image_format: true, jpg_quality: true }, + fast_preview: { frames_subdir: true, image_format: true, jpg_quality: true }, + }, + }, +}; + +const NODE_GROUPS = { + "IAMCCS-SuperNodes AU+IMG2VID Exec Planner": [ + { key: "timeline", label: "Timeline", color: "#2f8f80", widgets: ["fps", "segment_seconds"] }, + { key: "planning", label: "Planning", color: "#a07b2c", widgets: ["planning_mode", "segment_preset", "overlap_frames", "ltx_round_mode"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Render": [ + { key: "preset", label: "Preset", color: "#557ea6", widgets: ["ui_preset"] }, + { key: "prompts", label: "Prompting", color: "#9a6b2f", widgets: ["positive_text", "negative_text"] }, + { key: "video", label: "Video", color: "#3f6fb0", widgets: ["width", "height"] }, + { key: "sampling", label: "Sampling", color: "#8352a6", widgets: ["steps", "cfg", "sampler_name", "seed", "max_shift", "base_shift", "sigma_terminal", "manual_sigmas", "image_strength", "image_compression"] }, + { key: "audio", label: "Audio", color: "#3d8a5c", widgets: ["audio_context_mode", "audio_left_context_s", "audio_right_context_s"] }, + { key: "stitch", label: "Stitch", color: "#a4673d", widgets: ["stitch_preset", "overlap_side", "overlap_mode", "start_frames_rule"] }, + { key: "anchor", label: "Anchor", color: "#b35c5c", widgets: ["continuity_anchor_mode", "anchor_refresh_interval", "anchor_image_strength", "anti_drift_mode", "anti_drift_strength", "identity_persistence_strength"] }, + { key: "modular", label: "Modular", color: "#46769a", widgets: ["modular_decode", "downstream_stage_mode", "output_root"] }, + { key: "debug", label: "Debug", color: "#8a8a36", widgets: ["segment_overlay_mode", "segment_overlay_text"] }, + ], + "IAMCCS-SuperNodes Second Stage": [ + { key: "stage2", label: "Second Stage", color: "#8a5ca0", widgets: ["second_stage_mode", "stage2_model_policy", "second_stage_upscale_model", "second_stage_reinject_strength", "second_stage_cfg", "second_stage_manual_sigmas"] }, + ], + IAMCCS_GC_AudioConcatSupernode: [ + { key: "concat", label: "Audio Concat", color: "#4e8f6b", widgets: ["concat_mode", "clip_durations_seconds", "gap_seconds", "intro_seconds", "outro_seconds"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec VAE": [ + { key: "preset", label: "Preset", color: "#557ea6", widgets: ["ui_preset"] }, + { key: "decode", label: "Decode", color: "#4b79a6", widgets: ["frame_rate", "decode_mode", "output_root", "frames_subdir", "image_format", "jpg_quality", "tiled_tile_size", "tiled_overlap"] }, + { key: "final", label: "Finalize", color: "#9b7441", widgets: ["filename_prefix", "crf", "pix_fmt", "trim_to_audio", "save_metadata"] }, + ], + "IAMCCS-SuperNodes AU+IMG2VID Exec Finalize": [ + { key: "output", label: "Output", color: "#6a85a0", widgets: ["frame_rate", "filename_prefix", "crf", "pix_fmt", "trim_to_audio"] }, + ], +}; + +const SECTION_BUTTON_COLLAPSED_BG = "#22303f"; +const SECTION_BUTTON_TEXT = "#f8fbff"; +const SECTION_BUTTON_HEIGHT = 30; + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget?.name === name); +} + +function setWidgetVisibility(widget, visible) { + if (!widget || widget.type === "converted-widget") { + return; + } + + widget.hidden = !visible; + widget.disabled = !visible; + + if (widget.element) { + widget.element.style.display = visible ? "" : "none"; + } + if (widget.inputEl) { + widget.inputEl.style.display = visible ? "" : "none"; + } + + if (visible) { + if (Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.computeSize = widget.__iamccsOrigComputeSize; + } else { + delete widget.computeSize; + } + } else { + if (!Object.prototype.hasOwnProperty.call(widget, "__iamccsOrigComputeSize")) { + widget.__iamccsOrigComputeSize = widget.computeSize; + } + widget.computeSize = () => [0, -4]; + widget.y = undefined; + widget.last_y = undefined; + } +} + +function fitNodeToWidgets(node) { + const size = node.computeSize?.() || node.size; + node.setSize([ + Math.max(node.size?.[0] || 0, size[0]), + Math.max(size[1], size[1] + 8), + ]); +} + +function setWidgetLabel(node, widgetName, label) { + const widget = findWidget(node, widgetName); + if (!widget) { + return; + } + widget.label = label; +} + +function sectionButtonCaption(group, isExpanded) { + return `${isExpanded ? "β–Ό" : "β–Ά"} ${group.label}`; +} + +function insertWidget(node, widget, index) { + const widgets = node.widgets || []; + const currentIndex = widgets.indexOf(widget); + if (currentIndex >= 0) { + widgets.splice(currentIndex, 1); + } + widgets.splice(index, 0, widget); +} + +function setWidgetValue(node, widgetName, value) { + const widget = findWidget(node, widgetName); + if (!widget || widget.value === value) { + return; + } + widget.value = value; + widget.callback?.(value); +} + +const EXEC_PLANNER_PRESETS = { + "5sec": { seconds: 5.0, overlap: 9 }, + "10sec": { seconds: 10.0, overlap: 9 }, + "15sec": { seconds: 15.0, overlap: 9 }, + "20sec": { seconds: 20.0, overlap: 9 }, + videoclip: { seconds: 10.0, overlap: 9 }, + monologue: { seconds: 15.0, overlap: 9 }, +}; + +function getExecPlannerPreset(value) { + return EXEC_PLANNER_PRESETS[String(value || "15sec")] || EXEC_PLANNER_PRESETS["15sec"]; +} + +function getExecPlannerPresetForSeconds(seconds) { + const value = Number(seconds); + if (!Number.isFinite(value)) { + return null; + } + for (const presetName of ["5sec", "10sec", "15sec", "20sec"]) { + const rec = EXEC_PLANNER_PRESETS[presetName]; + if (Math.abs(value - rec.seconds) <= 0.001) { + return rec; + } + } + return null; +} + +function ltxSafeFramesAtLeast(frameCount) { + const frames = Math.max(1, Math.round(Number(frameCount) || 1)); + const remainder = (frames - 1) % 8; + return remainder === 0 ? frames : frames + (8 - remainder); +} + +function readPlannerNumber(node, widgetName, fallback) { + const value = Number(findWidget(node, widgetName)?.value); + return Number.isFinite(value) ? value : fallback; +} + +function getExecPlannerLiveConfig(node) { + const fps = Math.max(0.001, readPlannerNumber(node, "fps", 24.0)); + const planningMode = String(findWidget(node, "planning_mode")?.value || "manual_segment_seconds"); + const segmentPreset = String(findWidget(node, "segment_preset")?.value || "15sec"); + const presetRec = getExecPlannerPreset(segmentPreset); + let seconds = Math.max(0.01, readPlannerNumber(node, "segment_seconds", presetRec.seconds)); + const overlap = Math.max(0, Math.round(readPlannerNumber(node, "overlap_frames", 9))); + let overlapSource = "manual/default"; + + if (planningMode === "explicit_preset_seconds") { + seconds = presetRec.seconds; + } else { + const secondsRec = getExecPlannerPresetForSeconds(seconds); + if (secondsRec) { + overlapSource = `${secondsRec.seconds}s duration`; + } + } + + return { fps, planningMode, segmentPreset, seconds, overlap, overlapSource }; +} + +function updateExecPlannerLivePreview(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + return; + } + node.properties = node.properties || {}; + const config = getExecPlannerLiveConfig(node); + const uniqueFrames = Math.max(1, Math.round(config.seconds * config.fps)); + const firstRaw = ltxSafeFramesAtLeast(uniqueFrames); + const continuationTarget = uniqueFrames + config.overlap; + const continuationRaw = ltxSafeFramesAtLeast(continuationTarget); + const lines = [ + `live ${config.seconds.toFixed(2)}s @ ${config.fps.toFixed(2)}fps -> ${uniqueFrames}f unique`, + `overlap ${config.overlap}f (${config.overlapSource}) | first raw ${firstRaw}f | cont raw ${continuationRaw}f`, + ]; + + const storedDuration = Number(node.properties.iamccsPlannerDuration); + const storedTotalFrames = Number(node.properties.iamccsPlannerTotalFrames); + const hasDuration = Number.isFinite(storedDuration) && storedDuration > 0; + const totalFrames = Number.isFinite(storedTotalFrames) && storedTotalFrames > 0 + ? Math.round(storedTotalFrames) + : hasDuration + ? Math.round(storedDuration * config.fps) + : 0; + + if (totalFrames > 0) { + const segmentCount = Math.max(1, Math.ceil(totalFrames / uniqueFrames)); + lines.push(`audio ${hasDuration ? storedDuration.toFixed(2) : "cached"}s -> ${totalFrames}f | segments ${segmentCount}`); + const parts = []; + for (let index = 0; index < Math.min(segmentCount, 4); index += 1) { + const remaining = Math.max(0, totalFrames - index * uniqueFrames); + const effective = Math.max(1, Math.min(uniqueFrames, remaining)); + const target = index === 0 ? effective : effective + config.overlap; + parts.push(`s${index + 1}:${effective}${index === 0 ? "" : `+${config.overlap}`}->${ltxSafeFramesAtLeast(target)}`); + } + lines.push(`${parts.join(" ")}${segmentCount > 4 ? " ..." : ""}`); + } else { + lines.push("audio duration unknown until planner runs once"); + } + + node.properties.iamccsPlannerLivePreview = lines.join("\n"); +} + +function syncExecPlannerExplicitPreset(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + return; + } + const planningMode = String(findWidget(node, "planning_mode")?.value || "manual_segment_seconds"); + if (planningMode === "explicit_preset_seconds") { + const rec = getExecPlannerPreset(findWidget(node, "segment_preset")?.value); + setWidgetValue(node, "segment_seconds", rec.seconds); + } + updateExecPlannerLivePreview(node); +} + +function installExecPlannerExplicitPresetSync(node) { + if (node._iamccsExecPlannerExplicitPresetSyncInstalled) { + syncExecPlannerExplicitPreset(node); + updateExecPlannerLivePreview(node); + return; + } + node._iamccsExecPlannerExplicitPresetSyncInstalled = true; + for (const widgetName of ["fps", "segment_seconds", "planning_mode", "segment_preset", "overlap_frames", "ltx_round_mode"]) { + const widget = findWidget(node, widgetName); + if (!widget) { + continue; + } + const originalCallback = widget.callback; + widget.callback = (...args) => { + originalCallback?.apply(widget, args); + if (widgetName === "planning_mode") { + applyPlannerModeVisibility(node); + } else { + syncExecPlannerExplicitPreset(node); + } + updateExecPlannerLivePreview(node); + app.graph.setDirtyCanvas(true, true); + }; + } + syncExecPlannerExplicitPreset(node); + updateExecPlannerLivePreview(node); +} + +function getLinxTargets(node) { + const results = []; + for (const output of node.outputs || []) { + if (output?.type !== "IAMCCS_SUPERNODE_LINX") { + continue; + } + for (const linkId of output.links || []) { + const link = app.graph.links?.[linkId]; + if (!link) { + continue; + } + const targetNode = app.graph.getNodeById?.(link.target_id); + if (targetNode) { + results.push(targetNode); + } + } + } + return results; +} + +function applyVaeDecodeModeVisibility(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + return; + } + const decodeMode = String(findWidget(node, "decode_mode")?.value || "low_ram"); + const isLowRam = decodeMode === "low_ram"; + const isNormal = decodeMode === "normal"; + setWidgetVisibility(findWidget(node, "frames_subdir"), isLowRam); + setWidgetVisibility(findWidget(node, "image_format"), isLowRam); + setWidgetVisibility(findWidget(node, "jpg_quality"), isLowRam); + setWidgetVisibility(findWidget(node, "tiled_tile_size"), isNormal); + setWidgetVisibility(findWidget(node, "tiled_overlap"), isNormal); + fitNodeToWidgets(node); +} + +function applyRenderAnchorLabels(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + return; + } + setWidgetLabel(node, "continuity_anchor_mode", "Anchor Refresh"); + setWidgetLabel(node, "anchor_refresh_interval", "Refresh Interval"); + setWidgetLabel(node, "anchor_image_strength", "Anchor Guidance Strength"); +} + +function syncDownstreamVaeDecodeModes(renderNode) { + const renderDecode = String(findWidget(renderNode, "modular_decode")?.value || "low_ram"); + const visited = new Set(); + const queue = [...getLinxTargets(renderNode)]; + while (queue.length > 0) { + const node = queue.shift(); + if (!node || visited.has(node.id)) { + continue; + } + visited.add(node.id); + if (node.comfyClass === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + setWidgetValue(node, "decode_mode", renderDecode); + setWidgetValue(node, "ui_preset", "custom"); + applyVaeDecodeModeVisibility(node); + app.graph.setDirtyCanvas(true, true); + continue; + } + if (node.comfyClass === "IAMCCS-SuperNodes Second Stage") { + queue.push(...getLinxTargets(node)); + } + } +} + +function applyPresetConfig(node, nodeName) { + const config = PRESET_CONFIGS[nodeName]; + if (!config) { + return; + } + const presetWidget = findWidget(node, config.presetWidget); + if (!presetWidget) { + return; + } + const presetName = String(presetWidget.value || config.defaultPreset || "custom"); + if (presetName !== "custom") { + const values = config.values?.[presetName] || {}; + Object.entries(values).forEach(([widgetName, widgetValue]) => setWidgetValue(node, widgetName, widgetValue)); + } + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + applyRenderAnchorLabels(node); + } + fitNodeToWidgets(node); +} + +function getSerializableWidgets(node) { + return (node.widgets || []).filter((widget) => widget && widget.serialize !== false); +} + +function rehydrateSerializedWidgets(node, serializedValues) { + if (!Array.isArray(serializedValues) || !node.widgets?.length) { + return; + } + + const widgets = getSerializableWidgets(node); + const count = Math.min(widgets.length, serializedValues.length); + for (let index = 0; index < count; index += 1) { + const widget = widgets[index]; + if (!widget) { + continue; + } + widget.value = serializedValues[index]; + } +} + +function addSectionButton(node, group, index) { + // Duplicate guard: if a button for this section key is already in the widget list, + // return it immediately. This prevents double-insertion if onNodeCreated or + // enhanceNodeLayout is called more than once on the same node instance. + const existing = node.widgets?.find((w) => w._iamccsSectionKey === group.key); + if (existing) { + return existing; + } + + node.properties = node.properties || {}; + const propKey = `iamccs_section_${group.key}`; + if (node.properties[propKey] === undefined) { + node.properties[propKey] = true; + } + + const sectionButton = node.addWidget("button", "", "", () => { + node.properties[propKey] = !node.properties[propKey]; + applyGroupVisibility(node, group, propKey, sectionButton); + }); + // IMPORTANT: do NOT set serialize = false. + // Section buttons must occupy a slot in widgets_values so that ComfyUI's + // index-based configure() assignment keeps real widget values aligned. + // Their value (empty string / null) is harmless. + sectionButton._iamccsSectionKey = group.key; + sectionButton.computeSize = (width) => [width || 280, SECTION_BUTTON_HEIGHT]; + sectionButton.label = group.label; + sectionButton.options = { bgcolor: group.color || "#4f6f8f", color: SECTION_BUTTON_TEXT }; + sectionButton.value = ""; + + applyGroupVisibility(node, group, propKey, sectionButton); + insertWidget(node, sectionButton, index); + return sectionButton; +} + +function applyGroupVisibility(node, group, propKey, button) { + const isExpanded = !!node.properties[propKey]; + const caption = sectionButtonCaption(group, isExpanded); + button.name = caption; + button.label = caption; + button.value = caption; + button.options = { + bgcolor: isExpanded ? (group.color || "#4f6f8f") : SECTION_BUTTON_COLLAPSED_BG, + color: SECTION_BUTTON_TEXT, + }; + for (const widgetName of group.widgets) { + setWidgetVisibility(findWidget(node, widgetName), isExpanded); + } + fitNodeToWidgets(node); + app.graph.setDirtyCanvas(true, true); +} + +function applyPlannerModeVisibility(node) { + if (node.comfyClass !== "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + return; + } + const planningMode = String(findWidget(node, "planning_mode")?.value || "manual_segment_seconds"); + const planningExpanded = !!node.properties?.iamccs_section_planning; + const showSegmentPreset = planningExpanded && planningMode === "explicit_preset_seconds"; + setWidgetVisibility(findWidget(node, "segment_preset"), showSegmentPreset); + syncExecPlannerExplicitPreset(node); + fitNodeToWidgets(node); +} + +function refreshNodeLayoutState(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + for (const group of groups) { + const button = node.widgets?.find((widget) => widget?._iamccsSectionKey === group.key); + if (!button) { + continue; + } + applyGroupVisibility(node, group, `iamccs_section_${group.key}`, button); + } + + const config = PRESET_CONFIGS[nodeName]; + if (config) { + const presetWidget = findWidget(node, config.presetWidget); + const presetName = String(presetWidget?.value || config.defaultPreset || "custom"); + const visibilityMap = config.visibility?.[presetName] || {}; + Object.entries(visibilityMap).forEach(([widgetName, isVisible]) => setWidgetVisibility(findWidget(node, widgetName), !!isVisible)); + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } else if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + applyPlannerModeVisibility(node); + } else { + fitNodeToWidgets(node); + } +} + +function addPlannerChip(node) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === "planner_chip_preview")) { + return; + } + const chipWidget = { + type: "custom", + name: "planner_chip_preview", + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 30]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.iamccsPlannerChip || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 22; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = "#18312b"; + ctx.strokeStyle = "#6ea88d"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 9); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#d8efe2"; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(chipWidget); +} + +function addStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 42]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 34; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x + 10, top + h / 2 + 0.5); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function addMultiLineStatusBox(node, propertyName, widgetName, fill, stroke, textColor) { + if (!node.widgets || node.widgets.some((widget) => widget?.name === widgetName)) { + return; + } + const statusWidget = { + type: "custom", + name: widgetName, + serialize: false, + computeSize(width) { + return [Math.max(220, (width || 260) - 20), 78]; + }, + draw(ctx, widget, nodeRef, widgetWidth, y) { + const text = nodeRef.properties?.[propertyName] || ""; + if (!text) { + return; + } + const lines = String(text).split("\n").filter(Boolean).slice(0, 4); + const x = 12; + const w = Math.max(180, widgetWidth - 24); + const h = 70; + const top = y + 4; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, top, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = textColor; + ctx.font = "12px Segoe UI"; + ctx.textBaseline = "top"; + lines.forEach((line, index) => { + ctx.fillText(line, x + 10, top + 8 + index * 15); + }); + ctx.restore(); + }, + }; + node.widgets.push(statusWidget); +} + +function enhanceNodeLayout(node, nodeName) { + const groups = NODE_GROUPS[nodeName] || []; + if (!groups.length || !node.widgets?.length) { + return; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + addPlannerChip(node); + addMultiLineStatusBox(node, "iamccsPlannerLivePreview", "planner_live_preview", "#1d2f38", "#67a7bb", "#e5f3f7"); + addMultiLineStatusBox(node, "iamccsPlannerDetails", "planner_details_preview", "#24362d", "#72ab8e", "#e4f2e8"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + addStatusBox(node, "iamccsRenderStatus", "render_status_preview", "#1e2f3f", "#6f93ba", "#e4edf7"); + } + if (nodeName === "IAMCCS-SuperNodes Second Stage") { + addStatusBox(node, "iamccsSecondStageStatus", "second_stage_status_preview", "#30243d", "#9f77bf", "#f1e7f8"); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(node); + } + + for (let index = groups.length - 1; index >= 0; index -= 1) { + const group = groups[index]; + const firstWidgetIndex = node.widgets.findIndex((widget) => group.widgets.includes(widget?.name)); + if (firstWidgetIndex >= 0) { + addSectionButton(node, group, firstWidgetIndex); + } + } + + applyPresetConfig(node, nodeName); + fitNodeToWidgets(node); +} + +app.registerExtension({ + name: "IAMCCS.SuperNodesExecUI", + + async beforeRegisterNodeDef(nodeType, nodeData) { + const nodeName = nodeData?.name; + if (!NODE_GROUPS[nodeName]) { + return; + } + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + this.properties = this.properties || {}; + // MUST run synchronously, BEFORE configure() assigns widgets_values by index. + // Section buttons inserted here occupy their index slots so that the null + // placeholders in widgets_values land on buttons, not on real widgets. + // (Using setTimeout here was the root cause of NaN on load / after undo.) + enhanceNodeLayout(this, nodeName); + const config = PRESET_CONFIGS[nodeName]; + const presetWidget = config ? findWidget(this, config.presetWidget) : null; + if (presetWidget) { + const originalCallback = presetWidget.callback; + presetWidget.callback = (...args) => { + originalCallback?.apply(presetWidget, args); + applyPresetConfig(this, nodeName); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + syncDownstreamVaeDecodeModes(this); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + applyVaeDecodeModeVisibility(this); + } + app.graph.setDirtyCanvas(true, true); + }; + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const modularDecodeWidget = findWidget(this, "modular_decode"); + if (modularDecodeWidget) { + const originalCallback = modularDecodeWidget.callback; + modularDecodeWidget.callback = (...args) => { + originalCallback?.apply(modularDecodeWidget, args); + syncDownstreamVaeDecodeModes(this); + app.graph.setDirtyCanvas(true, true); + }; + syncDownstreamVaeDecodeModes(this); + } + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + installExecPlannerExplicitPresetSync(this); + applyPlannerModeVisibility(this); + } + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec VAE") { + const decodeModeWidget = findWidget(this, "decode_mode"); + if (decodeModeWidget) { + const originalCallback = decodeModeWidget.callback; + decodeModeWidget.callback = (...args) => { + originalCallback?.apply(decodeModeWidget, args); + applyVaeDecodeModeVisibility(this); + app.graph.setDirtyCanvas(true, true); + }; + } + } + return result; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + const result = onConfigure?.apply(this, arguments); + this.properties = this.properties || {}; + enhanceNodeLayout(this, nodeName); + rehydrateSerializedWidgets(this, info?.widgets_values); + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + installExecPlannerExplicitPresetSync(this); + } + refreshNodeLayoutState(this, nodeName); + return result; + }; + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Planner") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + this.properties = this.properties || {}; + if (Array.isArray(message?.planner_chip) && message.planner_chip.length > 0) { + this.properties.iamccsPlannerChip = String(message.planner_chip[0] || ""); + } + const details = []; + const duration = Array.isArray(message?.planned_duration_seconds) && message.planned_duration_seconds.length > 0 + ? message.planned_duration_seconds[0] + : Array.isArray(message?.duration_seconds) && message.duration_seconds.length > 0 + ? message.duration_seconds[0] + : null; + const totalFrames = Array.isArray(message?.planned_total_frames) && message.planned_total_frames.length > 0 + ? message.planned_total_frames[0] + : Array.isArray(message?.total_frames) && message.total_frames.length > 0 + ? message.total_frames[0] + : null; + const segments = Array.isArray(message?.planned_segment_count) && message.planned_segment_count.length > 0 + ? message.planned_segment_count[0] + : Array.isArray(message?.segment_count) && message.segment_count.length > 0 + ? message.segment_count[0] + : null; + if (duration !== null) { + this.properties.iamccsPlannerDuration = Number(duration || 0); + } + if (totalFrames !== null) { + this.properties.iamccsPlannerTotalFrames = Number(totalFrames || 0); + } + if (segments !== null) { + this.properties.iamccsPlannerSegmentCount = Number(segments || 0); + } + if (duration !== null) { + details.push(`duration ${Number(duration || 0).toFixed(2)}s`); + } + if (totalFrames !== null) { + details.push(`total ${totalFrames}f`); + } + if (segments !== null) { + details.push(`segments ${segments}`); + } + if (Array.isArray(message?.recommended_overlap_frames) && message.recommended_overlap_frames.length > 0) { + details.push(`overlap ${message.recommended_overlap_frames[0]}f`); + } + if (Array.isArray(message?.recommended_audio_left_context_s) && message.recommended_audio_left_context_s.length > 0) { + details.push(`left ctx ${Number(message.recommended_audio_left_context_s[0] || 0).toFixed(2)}s`); + } + const plannerReport = Array.isArray(message?.planning_report) && message.planning_report.length > 0 + ? message.planning_report[0] + : Array.isArray(message?.report) && message.report.length > 0 + ? message.report[0] + : null; + if (plannerReport !== null) { + details.push(String(plannerReport || "")); + } + updateExecPlannerLivePreview(this); + if (details.length > 0) { + this.properties.iamccsPlannerDetails = details.join("\n"); + app.graph.setDirtyCanvas(true, true); + } + }; + } + + if (nodeName === "IAMCCS-SuperNodes AU+IMG2VID Exec Render") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + if (Array.isArray(message?.report) && message.report.length > 0) { + this.properties = this.properties || {}; + this.properties.iamccsRenderStatus = String(message.report[0] || "").split("\n")[0]; + app.graph.setDirtyCanvas(true, true); + } + }; + } + + if (nodeName === "IAMCCS-SuperNodes Second Stage") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + if (Array.isArray(message?.report) && message.report.length > 0) { + this.properties = this.properties || {}; + this.properties.iamccsSecondStageStatus = String(message.report[0] || "").split("\n")[0]; + app.graph.setDirtyCanvas(true, true); + } + }; + } + }, +}); diff --git a/web/iamccs_wan_lora_schedule_ui.js b/web/iamccs_wan_lora_schedule_ui.js index c201a23..05fdd1f 100644 --- a/web/iamccs_wan_lora_schedule_ui.js +++ b/web/iamccs_wan_lora_schedule_ui.js @@ -1,6 +1,12 @@ -import { app } from "../../scripts/app.js"; +ο»Ώimport { app } from "../../scripts/app.js"; const NODE_NAME = "IAMCCS_WanLoRASchedule"; +const NODE_NAMES = new Set([ + "IAMCCS_WanLoRASchedule", + "IAMCCS_WanLoRAHookSchedule", + "IAMCCS_ApplyScheduledWanLoRAFromConditioning", + "IAMCCS_BuildScheduledWanModelBank", +]); const MODE_WIDGET = "_iamccs_schedule_mode"; const LOAD_WIDGET = "_iamccs_schedule_load_preset"; const SAVE_WIDGET = "_iamccs_schedule_save_preset"; @@ -29,6 +35,45 @@ const SLOT_BLOCK = 5; const SLOTS_START_IDX = 3; const VISIBLE_PRESET_OPTIONS = ["custom range", "all generations", "gen 0 only", "gen 1 onwards"]; +function getSerializedHeadWidgetNames(node) { + const names = []; + if (getWidget(node, "generation_count")) { + names.push("generation_count"); + } else if (getWidget(node, "generation_index")) { + names.push("generation_index"); + } + names.push("log_prefix", "model_type"); + return names; +} + +function expectedSerializedValueCount(node, slotCount = MAX_SLOTS) { + return getSerializedHeadWidgetNames(node).length + (slotCount * SLOT_BLOCK); +} + +function getSerializedWidgetNames(node, slotCount = MAX_SLOTS) { + const names = [...getSerializedHeadWidgetNames(node)]; + for (let slot = 1; slot <= slotCount; slot += 1) { + const prefix = `slot_${String(slot).padStart(2, "0")}`; + names.push(`${prefix}_lora_name`); + names.push(`${prefix}_strength`); + names.push(`${prefix}_preset`); + names.push(`${prefix}_start`); + names.push(`${prefix}_end`); + } + return names; +} + +function reapplySerializedWidgetValues(node, values) { + if (!Array.isArray(values) || !node) return; + let index = 0; + for (const name of getSerializedWidgetNames(node)) { + if (index >= values.length) break; + const widget = getWidget(node, name); + if (!widget) continue; + widget.value = values[index++]; + } +} + const SLOT_META = [ { slot: 1, label: "Gen 0", preset: "gen 0 only", start: 0, end: 0 }, { slot: 2, label: "Gen 1", preset: "custom range", start: 1, end: 1 }, @@ -62,6 +107,82 @@ function getWidget(node, name) { return node?.widgets?.find((widget) => widget?.name === name || widget?.label === name) || null; } +function setButtonText(widget, text) { + if (!widget) return; + widget.label = text; + widget.options = { ...(widget.options || {}), label: text }; + widget._iamccsButtonText = text; +} + +function getButtonText(widget, fallback = "") { + return String(widget?._iamccsButtonText || widget?.label || widget?.options?.label || fallback); +} + +function waitForNextPaint() { + return new Promise((resolve) => { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => setTimeout(resolve, 0)); + return; + } + setTimeout(resolve, 0); + }); +} + +async function runButtonAction(node, widget, busyText, action) { + if (!widget || widget._iamccsBusy) return; + const originalText = getButtonText(widget); + widget._iamccsBusy = true; + setButtonText(widget, busyText); + node.setDirtyCanvas?.(true, true); + await waitForNextPaint(); + try { + await action(); + } finally { + widget._iamccsBusy = false; + setButtonText(widget, originalText); + node.setDirtyCanvas?.(true, true); + } +} + +function beginBatchUpdate(node) { + node._iamccsBatchDepth = Number(node._iamccsBatchDepth || 0) + 1; +} + +function endBatchUpdate(node) { + node._iamccsBatchDepth = Math.max(0, Number(node._iamccsBatchDepth || 0) - 1); + if (!node._iamccsBatchDepth && node._iamccsPendingSyncLowNodes) { + node._iamccsPendingSyncLowNodes = false; + requestSyncAllLinkedLowNodes(node); + } +} + +function isBatchUpdating(node) { + return Number(node?._iamccsBatchDepth || 0) > 0; +} + +function isRestoringNodeState(node) { + return Boolean(node?._iamccsRestoringState); +} + +function requestSyncAllLinkedLowNodes(node) { + if (!node) return; + if (isBatchUpdating(node)) { + node._iamccsPendingSyncLowNodes = true; + return; + } + if (isRestoringNodeState(node)) { + node._iamccsPendingSyncLowNodes = true; + return; + } + if (node._iamccsSyncLowNodesTimer) return; + node._iamccsSyncLowNodesTimer = setTimeout(() => { + node._iamccsSyncLowNodesTimer = 0; + void syncAllLinkedLowNodes(node).finally(() => { + node.setDirtyCanvas?.(true, true); + }); + }, 0); +} + function markUiWidgetNonSerializable(widget) { if (!widget) return widget; widget.options = { ...(widget.options || {}), serialize: false }; @@ -78,9 +199,9 @@ function isModelTypeValue(value) { return value === "wan2x" || value === "flow" || value === "standard"; } -// Correct serialized count: generation_index(1) + log_prefix(1) + model_type(1) + 6 slots Γ— 5 = 33 +// Correct serialized count: generation_index(1) + log_prefix(1) + model_type(1) + 6 slots Γƒβ€” 5 = 33 // (generation_index stays as a widget even when wired, in ComfyUI's serialization model) -const EXPECTED_SERIALIZED_VALUES_CONNECTED = EXPECTED_SERIALIZED_VALUES; // 33 – gen_idx present as wired widget +const EXPECTED_SERIALIZED_VALUES_CONNECTED = EXPECTED_SERIALIZED_VALUES; // 33 Γ’β‚¬β€œ gen_idx present as wired widget function slotCountFromSerializedLength(length) { if (typeof length !== "number" || length < 3) return 0; @@ -103,7 +224,7 @@ function sanitizePresetValue(value) { return aliases[raw] || raw; } -function normalizeLegacyWidgetValues(config) { +function normalizeLegacyWidgetValues(config, node = null) { const values = config?.widgets_values; if (!Array.isArray(values)) return; @@ -141,13 +262,67 @@ function normalizeLegacyWidgetValues(config) { normalized = [generationIndex, logPrefix, modelType, ...slotValues]; } + const hasGenerationCount = Boolean(node && getWidget(node, "generation_count")); + const genericExpected = node ? expectedSerializedValueCount(node) : EXPECTED_SERIALIZED_VALUES; + const maxSlotValuesLength = MAX_SLOTS * SLOT_BLOCK; + + // Pattern C (bank nodes): generation_count is serialized last, while log_prefix/model_type + // stay at the front and UI-only widgets are inserted before the slot payload. + // Example observed on disk: + // log_prefix, model_type, mode, null..., slot_01..., ..., slot_64..., generation_count + if ( + !normalized + && hasGenerationCount + && values.length > genericExpected + && maxSlotValuesLength > 0 + && typeof values[0] === "string" + && isModelTypeValue(values[1]) + ) { + const slotStart = values.length - maxSlotValuesLength - 1; + if (slotStart > 2) { + const generationCount = values[values.length - 1] ?? 1; + const logPrefix = values[0] ?? "WAN LoRA schedule"; + const modelType = values[1] ?? "flow"; + const slotValues = values.slice(slotStart, values.length - 1); + const inferredMode = values.slice(2, slotStart).find((value) => isModeValue(value)); + if (slotValues.length === maxSlotValuesLength) { + modeValue = isModeValue(inferredMode) ? inferredMode : modeValue; + normalized = [generationCount, logPrefix, modelType, ...slotValues]; + } + } + } + + // Pattern D (bank nodes): current head widgets are already first, but UI-only widgets + // were serialized before the slot payload. Example: + // generation_count, log_prefix, model_type, , slot_01..., ..., slot_64... + if ( + !normalized + && hasGenerationCount + && values.length > genericExpected + && typeof values[0] === "number" + && typeof values[1] === "string" + && isModelTypeValue(values[2]) + ) { + const extraCount = values.length - genericExpected; + const slotStart = 3 + extraCount; + const generationCount = values[0] ?? 1; + const logPrefix = values[1] ?? "WAN LoRA schedule"; + const modelType = values[2] ?? "flow"; + const uiValues = values.slice(3, slotStart); + const slotValues = values.slice(slotStart); + const inferredMode = uiValues.find((value) => isModeValue(value)); + if (slotValues.length === maxSlotValuesLength) { + modeValue = isModeValue(inferredMode) ? inferredMode : modeValue; + normalized = [generationCount, logPrefix, modelType, ...slotValues]; + } + } // Determine what to work on: use normalized if we reordered, else work on original (in-place) // for preset-migration-only case (already-correct 33-value arrays with legacy preset names). const inferredSlots = slotCountFromSerializedLength(values.length); const target = normalized ?? (inferredSlots > 0 ? values : null); if (!target) return; - // Migrate legacy internal preset names β†’ current _PRESET_OPTIONS names. + // Migrate legacy internal preset names Ò†’ current _PRESET_OPTIONS names. // "manual_range" was an internal alias for "custom range" in early builds. // ComfyUI server REJECTS it as an invalid combo value because it is not in _PRESET_OPTIONS. let presetPatched = false; @@ -197,13 +372,16 @@ function showWidget(widget) { } } -function setWidgetValue(node, name, value) { +function setWidgetValue(node, name, value, options = {}) { + const { invokeCallback = true } = options; const widget = getWidget(node, name); if (!widget) return false; widget.value = value; - try { - widget.callback?.(value, app.canvas, node); - } catch {} + if (invokeCallback && !isBatchUpdating(node)) { + try { + widget.callback?.(value, app.canvas, node); + } catch {} + } return true; } @@ -261,26 +439,84 @@ function resetScheduleRules(node) { node.properties[INIT_PROP] = {}; node.properties[AUTO_PROP] = {}; setVisibleSlots(node, 3); + beginBatchUpdate(node); for (let slot = 1; slot <= MAX_SLOTS; slot += 1) { const meta = slotMeta(slot); const prefix = `slot_${String(slot).padStart(2, "0")}`; - setWidgetValue(node, `${prefix}_lora_name`, "no"); - setWidgetValue(node, `${prefix}_strength`, 1); - setWidgetValue(node, `${prefix}_preset`, meta.preset); - setWidgetValue(node, `${prefix}_start`, meta.start); - setWidgetValue(node, `${prefix}_end`, meta.end); + setWidgetValue(node, `${prefix}_lora_name`, "no", { invokeCallback: false }); + setWidgetValue(node, `${prefix}_strength`, 1, { invokeCallback: false }); + setWidgetValue(node, `${prefix}_preset`, meta.preset, { invokeCallback: false }); + setWidgetValue(node, `${prefix}_start`, meta.start, { invokeCallback: false }); + setWidgetValue(node, `${prefix}_end`, meta.end, { invokeCallback: false }); node.properties[INIT_PROP][slot] = true; } + endBatchUpdate(node); clearDropHoverSlot(node); } -function notifyScheduleUi(message, isError = false) { +function notifyScheduleUi(message, isError = false, node = null, options = {}) { const prefix = "[IAMCCS_WanLoRASchedule UI]"; if (isError) { console.error(prefix, message); } else { console.info(prefix, message); } + if (node) { + showNodeToast(node, message, { isError, ...options }); + } +} + +function showNodeToast(node, message, options = {}) { + if (!node || !message) return; + const { isError = false, anchorWidgetName = null, durationMs = 1800 } = options; + node._iamccsToast = { + message: String(message), + isError: Boolean(isError), + anchorWidgetName, + until: Date.now() + Math.max(800, Number(durationMs) || 1800), + }; + node.setDirtyCanvas?.(true, true); +} + +function drawNodeToast(node, ctx) { + const toast = node?._iamccsToast; + if (!toast?.message) return; + if ((Number(toast.until) || 0) <= Date.now()) { + delete node._iamccsToast; + node.setDirtyCanvas?.(true, true); + return; + } + + const message = toast.message.length > 72 ? `${toast.message.slice(0, 69)}...` : toast.message; + const anchorWidget = toast.anchorWidgetName ? getWidget(node, toast.anchorWidgetName) : null; + const fallbackWidth = Number(node?.size?.[0]) || SIMPLE_WIDTH; + const toastHeight = 24; + const paddingX = 10; + const textWidth = Math.ceil(ctx.measureText(message).width); + const toastWidth = Math.min(Math.max(textWidth + (paddingX * 2), 110), Math.max(120, fallbackWidth - 12)); + + let anchorY = 18; + if (anchorWidget) { + anchorY = getWidgetBodyY(node, anchorWidget) + (getWidgetHeight(anchorWidget) / 2); + } + + const x = Math.max(6, fallbackWidth - toastWidth - 8); + const maxY = Math.max(8, (Number(node?.size?.[1]) || 200) - toastHeight - 8); + const y = Math.min(Math.max(8, anchorY - (toastHeight / 2)), maxY); + + ctx.save(); + ctx.fillStyle = toast.isError ? "rgba(120, 24, 24, 0.96)" : "rgba(24, 30, 36, 0.96)"; + ctx.strokeStyle = toast.isError ? "rgba(255, 126, 126, 0.95)" : "rgba(110, 214, 156, 0.95)"; + ctx.lineWidth = 1.25; + ctx.beginPath(); + ctx.roundRect(x, y, toastWidth, toastHeight, 8); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#f5f7fa"; + ctx.font = "12px sans-serif"; + ctx.textBaseline = "middle"; + ctx.fillText(message, x + paddingX, y + (toastHeight / 2) + 0.5); + ctx.restore(); } function isAbortError(error) { @@ -316,7 +552,7 @@ function buildSchedulePreset(node) { return { kind: PRESET_KIND, version: PRESET_VERSION, - node_type: NODE_NAME, + node_type: node?.type || NODE_NAME, exported_at: new Date().toISOString(), mode, visible_slots: getVisibleSlots(node), @@ -362,11 +598,32 @@ async function applySchedulePreset(node, payload) { } } + if (hasConnectedInput(node, "linx")) { + const optionSet = getOptionSetForNode(node) ?? await getLoraSet(); + for (const [slot, record] of slotMap.entries()) { + const sourceName = String(record?.lora_name || "no"); + if (!sourceName || sourceName === "no") continue; + + const candidates = candidateLowNames(sourceName); + let resolvedName = sourceName; + if (optionSet && optionSet.size > 0) { + resolvedName = candidates.find((candidate) => optionSet.has(candidate)) || sourceName; + } else { + resolvedName = candidates.find((candidate) => candidate !== sourceName) || sourceName; + } + + if (resolvedName !== sourceName) { + slotMap.set(slot, { ...record, lora_name: resolvedName }); + } + } + } + const nextMode = isModeValue(payload.mode) ? payload.mode : "simple"; const nextVisibleSlots = Math.max(3, Math.min(MAX_SLOTS, Math.trunc(Number(payload.visible_slots ?? payload.visibleSlots) || 3))); const nextModelType = isModelTypeValue(payload.model_type || payload.modelType) ? String(payload.model_type || payload.modelType) : "flow"; const nextLogPrefix = String(payload.log_prefix || payload.logPrefix || "WAN LoRA schedule"); + beginBatchUpdate(node); resetScheduleRules(node); node.properties = node.properties || {}; node.properties[MODE_PROP] = nextMode; @@ -395,10 +652,11 @@ async function applySchedulePreset(node, payload) { if (endWidget) endWidget.value = record.end; node.properties[INIT_PROP][slot] = true; } + endBatchUpdate(node); clearDropHoverSlot(node); applyLayout(node); - await syncAllLinkedLowNodes(node); + requestSyncAllLinkedLowNodes(node); node.setDirtyCanvas?.(true, true); } @@ -441,7 +699,7 @@ async function saveSchedulePresetAs(node) { downloadTextFile(suggestedName, text); } - notifyScheduleUi(`Preset saved: ${suggestedName}`); + notifyScheduleUi(`Preset saved: ${suggestedName}`, false, node, { anchorWidgetName: SAVE_WIDGET }); } async function pickPresetTextFile() { @@ -485,7 +743,7 @@ async function loadSchedulePreset(node) { const text = await pickPresetTextFile(); const payload = JSON.parse(text); await applySchedulePreset(node, payload); - notifyScheduleUi("Preset loaded."); + notifyScheduleUi("Preset loaded.", false, node, { anchorWidgetName: LOAD_WIDGET }); } function applyLabels(node) { @@ -546,7 +804,7 @@ function ensureModeWidget(node) { (value) => { node.properties[MODE_PROP] = String(value || "simple"); applyLayout(node); - syncAllLinkedLowNodes(node); + requestSyncAllLinkedLowNodes(node); node.setDirtyCanvas(true, true); }, { values: ["simple", "advanced"], serialize: false } @@ -558,22 +816,28 @@ function ensureModeWidget(node) { function ensureActionWidgets(node) { if (!getWidget(node, LOAD_WIDGET)) { const widget = node.addWidget("button", "Load Preset", null, () => { - void loadSchedulePreset(node).catch((error) => { + void runButtonAction(node, widget, "Loading...", async () => { + await loadSchedulePreset(node); + }).catch((error) => { if (isAbortError(error)) return; - notifyScheduleUi(`Load preset failed: ${error?.message || error}`, true); + notifyScheduleUi(`Load preset failed: ${error?.message || error}`, true, node, { anchorWidgetName: LOAD_WIDGET, durationMs: 2600 }); }); }); widget.name = LOAD_WIDGET; + setButtonText(widget, "Load Preset"); markUiWidgetNonSerializable(widget); } if (!getWidget(node, SAVE_WIDGET)) { const widget = node.addWidget("button", "Save As...", null, () => { - void saveSchedulePresetAs(node).catch((error) => { + void runButtonAction(node, widget, "Saving...", async () => { + await saveSchedulePresetAs(node); + }).catch((error) => { if (isAbortError(error)) return; - notifyScheduleUi(`Save preset failed: ${error?.message || error}`, true); + notifyScheduleUi(`Save preset failed: ${error?.message || error}`, true, node, { anchorWidgetName: SAVE_WIDGET, durationMs: 2600 }); }); }); widget.name = SAVE_WIDGET; + setButtonText(widget, "Save As..."); markUiWidgetNonSerializable(widget); } if (!getWidget(node, ADD_WIDGET)) { @@ -584,30 +848,36 @@ function ensureActionWidgets(node) { setVisibleSlots(node, newSlot); ensureVisibleDefaults(node); applyLayout(node); - syncAllLinkedLowNodes(node); + requestSyncAllLinkedLowNodes(node); node.setDirtyCanvas(true, true); }); widget.name = ADD_WIDGET; + setButtonText(widget, "+ Add Slot"); markUiWidgetNonSerializable(widget); } if (!getWidget(node, DELETE_WIDGET)) { const widget = node.addWidget("button", "- Delete Slot", null, () => { setVisibleSlots(node, getVisibleSlots(node) - 1); applyLayout(node); - syncAllLinkedLowNodes(node); + requestSyncAllLinkedLowNodes(node); node.setDirtyCanvas(true, true); }); widget.name = DELETE_WIDGET; + setButtonText(widget, "- Delete Slot"); markUiWidgetNonSerializable(widget); } if (!getWidget(node, RESET_WIDGET)) { const widget = node.addWidget("button", "Reset Rules", null, () => { - resetScheduleRules(node); - applyLayout(node); - syncAllLinkedLowNodes(node); - node.setDirtyCanvas(true, true); + void runButtonAction(node, widget, "Resetting...", async () => { + resetScheduleRules(node); + applyLayout(node); + requestSyncAllLinkedLowNodes(node); + node.setDirtyCanvas(true, true); + notifyScheduleUi("Rules reset.", false, node, { anchorWidgetName: RESET_WIDGET, durationMs: 1400 }); + }); }); widget.name = RESET_WIDGET; + setButtonText(widget, "Reset Rules"); markUiWidgetNonSerializable(widget); } } @@ -620,6 +890,12 @@ function getInputIndexByName(node, name) { return (node?.inputs || []).findIndex((input) => input?.name === name); } +function hasConnectedInput(node, name) { + const inputIndex = getInputIndexByName(node, name); + if (inputIndex < 0) return false; + return node?.inputs?.[inputIndex]?.link != null; +} + function getLinkedLinxTargets(node) { const graph = getGraph(); if (!graph) return []; @@ -792,7 +1068,7 @@ function hasDroppedLoraCandidate(event) { // The canvas is translated to node.pos before drawNode/onDrawForeground, so the coordinate // system for drawing starts at (0,0) = top-left of node (including title). // widget.y is set by _arrangeWidgets and starts at NODE_TITLE_HEIGHT (approximately), -// so no extra correction is needed β€” just subtract node.pos to get node-local coords. +// so no extra correction is needed Ò€” just subtract node.pos to get node-local coords. function resolveLocalDropPoint(node, event) { try { const canvas = app?.canvas?.canvas; @@ -854,7 +1130,7 @@ function getWidgetHeight(widget, nodeWidth) { // widget.y is set by _arrangeWidgets(), widget.last_y is set during drawWidgets(). // Both are relative to the node body (not including the title bar). function getWidgetBodyY(widget) { - // widget.y is set by _arrangeWidgets() β€” always > 0 for arranged widgets (title adds ~30px offset). + // widget.y is set by _arrangeWidgets() Ò€” always > 0 for arranged widgets (title adds ~30px offset). // A value of 0 means the widget hasn't been arranged yet (default from addWidget), treat as unknown. const y = widget?.y; if (typeof y === "number" && Number.isFinite(y) && y > 0) return y; @@ -1029,7 +1305,7 @@ function assignDroppedLora(node, event) { try { targetWidget.callback?.(loraName, app.canvas, node); } catch {} - syncAllLinkedLowNodes(node); + requestSyncAllLinkedLowNodes(node); clearDropHoverSlot(node); node.setDirtyCanvas?.(true, true); return true; @@ -1057,7 +1333,7 @@ async function getLoraSet() { const nodeType = Object.keys(app.graph?._nodes_by_id || {}); for (const id of nodeType) { const n = app.graph.getNodeById(id); - if (n?.type !== NODE_NAME) continue; + if (!NODE_NAMES.has(n?.type)) continue; const w = getWidget(n, "slot_01_lora_name"); const v = w?.options?.values ?? (Array.isArray(w?.options) ? w.options : null); if (Array.isArray(v) && v.length > 1) { @@ -1070,7 +1346,7 @@ async function getLoraSet() { return _loraSetCache; } -async function syncSlotToLowNode(highNode, lowNode, slot) { +async function syncSlotToLowNode(highNode, lowNode, slot, providedOptionSet = null) { const prefix = `slot_${String(slot).padStart(2, "0")}`; const highWidget = getWidget(highNode, `${prefix}_lora_name`); const lowWidget = getWidget(lowNode, `${prefix}_lora_name`); @@ -1097,7 +1373,7 @@ async function syncSlotToLowNode(highNode, lowNode, slot) { if (!candidates.length) return; // no _HIGH_/_LOW_ pattern in name // Build option set: prefer widget options, then server cache - let optionSet = null; + let optionSet = providedOptionSet; const rawOpts = lowWidget?.options?.values ?? (Array.isArray(lowWidget?.options) ? lowWidget.options : null); if (Array.isArray(rawOpts) && rawOpts.length > 1) { optionSet = new Set(rawOpts); @@ -1122,16 +1398,33 @@ async function syncSlotToLowNode(highNode, lowNode, slot) { } catch {} } +function syncSlotSettingsToLowNode(highNode, lowNode, slot) { + const prefix = `slot_${String(slot).padStart(2, "0")}`; + const fieldNames = ["strength", "preset", "start", "end"]; + for (const fieldName of fieldNames) { + const highWidget = getWidget(highNode, `${prefix}_${fieldName}`); + const lowWidget = getWidget(lowNode, `${prefix}_${fieldName}`); + if (!highWidget || !lowWidget) continue; + lowWidget.value = highWidget.value; + try { + lowWidget.callback?.(highWidget.value, app.canvas, lowNode); + } catch {} + } +} + async function syncAllLinkedLowNodes(highNode) { const targets = getLinkedLinxTargets(highNode); if (!targets.length) return; + const optionSet = await getLoraSet(); for (const lowNode of targets) { syncLowNodePresentation(highNode, lowNode); ensureVisibleDefaults(lowNode); applyLayout(lowNode); for (let slot = 1; slot <= getVisibleSlots(highNode); slot += 1) { - await syncSlotToLowNode(highNode, lowNode, slot); + await syncSlotToLowNode(highNode, lowNode, slot, optionSet); + syncSlotSettingsToLowNode(highNode, lowNode, slot); } + applyLayout(lowNode); lowNode.setDirtyCanvas?.(true, true); } } @@ -1140,28 +1433,43 @@ function wrapSlotCallbacks(node) { if (node._iamccsScheduleWrappedCallbacks) return; for (let slot = 1; slot <= MAX_SLOTS; slot += 1) { const prefix = `slot_${String(slot).padStart(2, "0")}`; - // Wrap lora_name β†’ triggers linx sync + // Wrap lora_name Ò†’ triggers linx sync const loraWidget = getWidget(node, `${prefix}_lora_name`); if (loraWidget && !loraWidget._iamccsWrapped) { const origLora = loraWidget.callback; loraWidget.callback = (value, canvas, targetNode) => { try { origLora?.(value, canvas, targetNode); } finally { - syncAllLinkedLowNodes(node); + requestSyncAllLinkedLowNodes(node); } }; loraWidget._iamccsWrapped = true; } - // Wrap preset β†’ refreshes start/end visibility + // Wrap preset Ò†’ refreshes start/end visibility const presetWidget = getWidget(node, `${prefix}_preset`); if (presetWidget && !presetWidget._iamccsWrapped) { const origPreset = presetWidget.callback; presetWidget.callback = (value, canvas, targetNode) => { try { origPreset?.(value, canvas, targetNode); } finally { - setTimeout(() => { applyLayout(node); node.setDirtyCanvas(true, true); }, 0); + setTimeout(() => { + applyLayout(node); + requestSyncAllLinkedLowNodes(node); + node.setDirtyCanvas(true, true); + }, 0); } }; presetWidget._iamccsWrapped = true; } + for (const fieldName of ["strength", "start", "end"]) { + const widget = getWidget(node, `${prefix}_${fieldName}`); + if (!widget || widget._iamccsWrapped) continue; + const origCallback = widget.callback; + widget.callback = (value, canvas, targetNode) => { + try { origCallback?.(value, canvas, targetNode); } finally { + requestSyncAllLinkedLowNodes(node); + } + }; + widget._iamccsWrapped = true; + } } node._iamccsScheduleWrappedCallbacks = true; } @@ -1207,9 +1515,7 @@ function setSlotVisibility(node, slot, visible, advanced) { function reorderWidgets(node) { const orderedNames = [ - "generation_index", - "log_prefix", - "model_type", + ...getSerializedHeadWidgetNames(node), MODE_WIDGET, LOAD_WIDGET, SAVE_WIDGET, @@ -1281,7 +1587,7 @@ app.registerExtension({ name: "iamccs.wan_lora_schedule_ui", async beforeRegisterNodeDef(nodeType, nodeData) { - if (nodeData?.name !== NODE_NAME) return; + if (!NODE_NAMES.has(nodeData?.name)) return; const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { @@ -1293,7 +1599,6 @@ app.registerExtension({ ensureActionWidgets(this); setTimeout(() => { applyLayout(this); - syncAllLinkedLowNodes(this); this.setDirtyCanvas(true, true); }, 0); return result; @@ -1344,26 +1649,23 @@ app.registerExtension({ nodeType.prototype.onDrawForeground = function (ctx) { const result = onDrawForeground?.apply(this, arguments); drawDropHover(this, ctx); + drawNodeToast(this, ctx); return result; }; const onConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function (config) { + this._iamccsRestoringState = true; // 1. Normalize legacy corrupt widget-value arrays (modifies config in place) - normalizeLegacyWidgetValues(config); + normalizeLegacyWidgetValues(config, this); + const restoredValues = Array.isArray(config?.widgets_values) ? [...config.widgets_values] : null; // 2. Re-apply normalized values to widgets explicitly. - // LiteGraph assigns widgets_values β†’ widget.value BEFORE calling onConfigure, + // LiteGraph assigns widgets_values Ò†’ widget.value BEFORE calling onConfigure, // so if the values were corrupt, they are already set wrong. Re-assigning here - // (in the same iteration order, skipping non-serializable UI widgets) corrects them. - if (Array.isArray(config.widgets_values) && this.widgets) { - let j = 0; - for (const widget of this.widgets) { - if (!widget || widget.options?.serialize === false || widget._iamccsUiOnly) continue; - if (j < config.widgets_values.length) { - widget.value = config.widgets_values[j++]; - } - } + // by explicit serialized-widget name order avoids bank-node slot shifts. + if (restoredValues && this.widgets) { + reapplySerializedWidgetValues(this, restoredValues); } const result = onConfigure?.apply(this, arguments); @@ -1372,6 +1674,9 @@ app.registerExtension({ if (!this.properties[SLOTS_PROP]) this.properties[SLOTS_PROP] = 3; ensureModeWidget(this); ensureActionWidgets(this); + if (restoredValues && this.widgets) { + reapplySerializedWidgetValues(this, restoredValues); + } setTimeout(() => { // Mark ALL slots as already-initialized so ensureSlotDefaults won't clobber // values that were restored from the saved workflow. @@ -1379,8 +1684,15 @@ app.registerExtension({ for (let s = 1; s <= MAX_SLOTS; s++) { this.properties[INIT_PROP][s] = true; } + if (restoredValues && this.widgets) { + reapplySerializedWidgetValues(this, restoredValues); + } applyLayout(this); - syncAllLinkedLowNodes(this); + this._iamccsRestoringState = false; + if (this._iamccsPendingSyncLowNodes) { + this._iamccsPendingSyncLowNodes = false; + requestSyncAllLinkedLowNodes(this); + } this.setDirtyCanvas(true, true); }, 0); return result; @@ -1391,7 +1703,9 @@ app.registerExtension({ const result = onConnectionsChange?.apply(this, arguments); setTimeout(() => { applyLayout(this); - syncAllLinkedLowNodes(this); + if (!isRestoringNodeState(this)) { + requestSyncAllLinkedLowNodes(this); + } this.setDirtyCanvas(true, true); }, 0); return result; @@ -1402,7 +1716,9 @@ app.registerExtension({ nodeType.prototype.collapse = function () { const result = collapse?.apply(this, arguments); setTimeout(() => { - syncAllLinkedLowNodes(this); + if (!isRestoringNodeState(this)) { + requestSyncAllLinkedLowNodes(this); + } this.setDirtyCanvas(true, true); }, 0); return result; diff --git a/web/iamccs_workflow_persist_cleanup.js b/web/iamccs_workflow_persist_cleanup.js index 0d252ee..6d034dd 100644 --- a/web/iamccs_workflow_persist_cleanup.js +++ b/web/iamccs_workflow_persist_cleanup.js @@ -20,6 +20,8 @@ const V2_PREFIX = { const MAX_DRAFTS_PER_WORKSPACE = 4; const MAX_DRAFT_CHARS_PER_WORKSPACE = 2_500_000; +const STORAGE_PATCH_FLAG = "__iamccsWorkflowDraftQuotaPatchApplied"; +const STORAGE_PROTOTYPE_PATCH_FLAG = "__iamccsWorkflowDraftQuotaPrototypePatchApplied"; function safeParseJson(json) { if (!json) return null; @@ -56,6 +58,62 @@ function getPayloadKey(workspaceId, draftKey) { return `${V2_PREFIX.draftPayload}${workspaceId}:${draftKey}`; } +function extractWorkspaceIdFromKey(key) { + if (typeof key !== "string") return null; + if (key.startsWith(V2_PREFIX.draftIndex)) { + return key.slice(V2_PREFIX.draftIndex.length); + } + if (key.startsWith(V2_PREFIX.draftPayload)) { + const rest = key.slice(V2_PREFIX.draftPayload.length); + const separatorIndex = rest.indexOf(":"); + return separatorIndex === -1 ? null : rest.slice(0, separatorIndex); + } + if (key.startsWith(V2_PREFIX.lastActivePath)) { + return key.slice(V2_PREFIX.lastActivePath.length); + } + if (key.startsWith(V2_PREFIX.lastOpenPaths)) { + return key.slice(V2_PREFIX.lastOpenPaths.length); + } + return null; +} + +function isDraftStorageKey(key) { + return typeof key === "string" && ( + key.startsWith(V2_PREFIX.draftIndex) + || key.startsWith(V2_PREFIX.draftPayload) + || key.startsWith(V2_PREFIX.lastActivePath) + || key.startsWith(V2_PREFIX.lastOpenPaths) + || LEGACY_LOCAL_KEYS.includes(key) + || LEGACY_PREFIXES.some((prefix) => key.startsWith(prefix)) + ); +} + +function isQuotaExceededError(error) { + if (!error) return false; + const name = String(error.name || ""); + const message = String(error.message || "").toLowerCase(); + return name === "QuotaExceededError" + || name === "NS_ERROR_DOM_QUOTA_REACHED" + || message.includes("quota") + || message.includes("storage is full"); +} + +function cleanupWorkspaceTrackingStorage(localStorageRef, workspaceId = null) { + let removed = 0; + for (const key of listStorageKeys(localStorageRef)) { + const isTrackingKey = key.startsWith(V2_PREFIX.lastActivePath) + || key.startsWith(V2_PREFIX.lastOpenPaths); + if (!isTrackingKey) continue; + + if (workspaceId !== null && extractWorkspaceIdFromKey(key) !== workspaceId) { + continue; + } + + if (removeKey(localStorageRef, key)) removed += 1; + } + return removed; +} + function cleanupLegacyWorkflowStorage(localStorageRef) { let removed = 0; for (const key of LEGACY_LOCAL_KEYS) { @@ -154,6 +212,7 @@ function cleanupWorkflowPersistenceStorage() { if (!localStorageRef) return; let removed = cleanupLegacyWorkflowStorage(localStorageRef); + removed += cleanupWorkspaceTrackingStorage(localStorageRef); let rewritten = 0; const workspaceIds = new Set(); @@ -180,9 +239,199 @@ function cleanupWorkflowPersistenceStorage() { } } +function recoverDraftStorage(localStorageRef, key) { + let removed = cleanupLegacyWorkflowStorage(localStorageRef); + let rewritten = 0; + + const workspaceIds = new Set(); + const workspaceId = extractWorkspaceIdFromKey(key); + if (workspaceId) workspaceIds.add(workspaceId); + + for (const storageKey of listStorageKeys(localStorageRef)) { + if (storageKey.startsWith(V2_PREFIX.draftIndex)) { + workspaceIds.add(storageKey.slice(V2_PREFIX.draftIndex.length)); + } + } + + for (const currentWorkspaceId of workspaceIds) { + removed += cleanupWorkspaceTrackingStorage(localStorageRef, currentWorkspaceId); + const result = cleanupWorkspaceDrafts(localStorageRef, currentWorkspaceId); + removed += result.removed; + if (result.rewritten) rewritten += 1; + } + + return { removed, rewritten }; +} + +function aggressiveRecoverDraftStorage(localStorageRef) { + let removed = cleanupLegacyWorkflowStorage(localStorageRef); + removed += cleanupWorkspaceTrackingStorage(localStorageRef); + + for (const storageKey of listStorageKeys(localStorageRef)) { + if ( + storageKey.startsWith(V2_PREFIX.draftIndex) + || storageKey.startsWith(V2_PREFIX.draftPayload) + ) { + if (removeKey(localStorageRef, storageKey)) removed += 1; + } + } + + return { removed, rewritten: 0 }; +} + +function isStorageLike(storageRef) { + return storageRef != null + && typeof storageRef.getItem === "function" + && typeof storageRef.setItem === "function" + && typeof storageRef.removeItem === "function"; +} + +function isLocalStorageRef(storageRef) { + try { + return storageRef === window.localStorage; + } catch { + return false; + } +} + +function shouldSuppressDraftQuotaFailure(key) { + if (!isDraftStorageKey(key)) return false; + return typeof key === "string" && ( + key.startsWith(V2_PREFIX.draftPayload) + || key.startsWith(V2_PREFIX.draftIndex) + || key.startsWith(V2_PREFIX.lastActivePath) + || key.startsWith(V2_PREFIX.lastOpenPaths) + || LEGACY_LOCAL_KEYS.includes(key) + || LEGACY_PREFIXES.some((prefix) => key.startsWith(prefix)) + ); +} + +function handleDraftQuotaSetItem(originalSetItem, storageRef, key, value, error) { + if (!isDraftStorageKey(key) || !isQuotaExceededError(error) || !isStorageLike(storageRef)) { + throw error; + } + + const canRecover = isLocalStorageRef(storageRef); + const recovery = canRecover ? recoverDraftStorage(storageRef, key) : { removed: 0, rewritten: 0 }; + console.warn("[IAMCCS] Workflow draft quota reached, attempting recovery", { + key, + removedKeys: recovery.removed, + rewrittenIndexes: recovery.rewritten, + storage: canRecover ? "localStorage" : "other", + }); + + try { + const result = Reflect.apply(originalSetItem, storageRef, [key, value]); + console.info("[IAMCCS] Workflow draft save recovered after storage cleanup", { + key, + removedKeys: recovery.removed, + rewrittenIndexes: recovery.rewritten, + storage: canRecover ? "localStorage" : "other", + }); + return result; + } catch (retryError) { + if (!isQuotaExceededError(retryError)) { + console.error("[IAMCCS] Workflow draft save failed after cleanup with non-quota error", { + key, + removedKeys: recovery.removed, + rewrittenIndexes: recovery.rewritten, + retryError, + }); + throw retryError; + } + + const aggressiveRecovery = canRecover + ? aggressiveRecoverDraftStorage(storageRef) + : { removed: 0, rewritten: 0 }; + + if (aggressiveRecovery.removed) { + console.warn("[IAMCCS] Workflow draft quota still exceeded, applying aggressive recovery", { + key, + removedKeys: aggressiveRecovery.removed, + storage: canRecover ? "localStorage" : "other", + }); + } + + try { + const result = Reflect.apply(originalSetItem, storageRef, [key, value]); + console.info("[IAMCCS] Workflow draft save recovered after aggressive cleanup", { + key, + removedKeys: recovery.removed + aggressiveRecovery.removed, + rewrittenIndexes: recovery.rewritten, + storage: canRecover ? "localStorage" : "other", + }); + return result; + } catch (finalRetryError) { + if (!isQuotaExceededError(finalRetryError) || !shouldSuppressDraftQuotaFailure(key)) { + console.error("[IAMCCS] Workflow draft save still exceeds storage quota after aggressive cleanup", { + key, + removedKeys: recovery.removed + aggressiveRecovery.removed, + rewrittenIndexes: recovery.rewritten, + retryError: finalRetryError, + }); + throw finalRetryError; + } + + console.warn("[IAMCCS] Workflow draft save skipped after quota recovery failed", { + key, + removedKeys: recovery.removed + aggressiveRecovery.removed, + rewrittenIndexes: recovery.rewritten, + storage: canRecover ? "localStorage" : "other", + }); + return undefined; + } + } +} + +function installDraftStorageQuotaGuard() { + try { + const localStorageRef = window.localStorage; + if (!localStorageRef || localStorageRef[STORAGE_PATCH_FLAG]) return; + + const storagePrototype = Object.getPrototypeOf(localStorageRef); + if (!storagePrototype || storagePrototype[STORAGE_PROTOTYPE_PATCH_FLAG]) { + Object.defineProperty(localStorageRef, STORAGE_PATCH_FLAG, { + value: true, + configurable: false, + enumerable: false, + writable: false, + }); + return; + } + + const originalSetItem = storagePrototype.setItem; + if (typeof originalSetItem !== "function") return; + + storagePrototype.setItem = function patchedSetItem(key, value) { + try { + return Reflect.apply(originalSetItem, this, [key, value]); + } catch (error) { + return handleDraftQuotaSetItem(originalSetItem, this, key, value, error); + } + }; + + Object.defineProperty(storagePrototype, STORAGE_PROTOTYPE_PATCH_FLAG, { + value: true, + configurable: false, + enumerable: false, + writable: false, + }); + + Object.defineProperty(localStorageRef, STORAGE_PATCH_FLAG, { + value: true, + configurable: false, + enumerable: false, + writable: false, + }); + } catch (error) { + console.warn("[IAMCCS] Workflow draft quota guard skipped", error); + } +} + app.registerExtension({ name: "iamccs.workflow_persist_cleanup", async setup() { cleanupWorkflowPersistenceStorage(); + installDraftStorageQuotaGuard(); }, });