diff --git a/__init__.py b/__init__.py index 39a8c6b..ba51731 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1,5 @@ from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] +WEB_DIRECTORY = "./web" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/example_workflows/prompt_relay_ltx23_test_02.json b/example_workflows/prompt_relay_ltx23_test_02.json new file mode 100644 index 0000000..fd9bb5b --- /dev/null +++ b/example_workflows/prompt_relay_ltx23_test_02.json @@ -0,0 +1,2135 @@ +{ + "id": "7f5e0c56-93b4-4937-b7f2-efd0f1853e33", + "revision": 0, + "last_node_id": 607, + "last_link_id": 1530, + "nodes": [ + { + "id": 550, + "type": "LTXVAudioVAEDecode", + "pos": [ + 407.59478603729974, + 4973.902967051035 + ], + "size": [ + 203.9, + 46 + ], + "flags": {}, + "order": 30, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 1416 + }, + { + "label": "Audio VAE", + "name": "audio_vae", + "type": "VAE", + "link": 1459 + } + ], + "outputs": [ + { + "name": "Audio", + "type": "AUDIO", + "links": [ + 1503 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVAudioVAEDecode", + "cnr_id": "comfy-core", + "ver": "0.16.0" + }, + "widgets_values": [] + }, + { + "id": 228, + "type": "SetNode", + "pos": [ + -1731.9566833205165, + 4899.953651561091 + ], + "size": [ + 210, + 60 + ], + "flags": { + "collapsed": true + }, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "VAE", + "type": "VAE", + "link": 1425 + } + ], + "outputs": [ + { + "name": "*", + "type": "*", + "links": [] + } + ], + "title": "Set_video_vae", + "properties": { + "Node name for S&R": "SetNode", + "aux_id": "kijai/ComfyUI-KJNodes", + "previousName": "video_vae" + }, + "widgets_values": [ + "video_vae" + ], + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 575, + "type": "GetNode", + "pos": [ + 418.9799376524158, + 5068.570518529913 + ], + "size": [ + 210, + 60 + ], + "flags": { + "collapsed": true + }, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 1459 + ] + } + ], + "title": "Get_audio_vae", + "properties": { + "Node name for S&R": "GetNode", + "aux_id": "kijai/ComfyUI-KJNodes" + }, + "widgets_values": [ + "audio_vae" + ], + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 574, + "type": "SetNode", + "pos": [ + -1608.5190578719967, + 5067.579621493003 + ], + "size": [ + 210, + 60 + ], + "flags": { + "collapsed": true + }, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "VAE", + "type": "VAE", + "link": 1458 + } + ], + "outputs": [ + { + "name": "*", + "type": "*", + "links": null + } + ], + "title": "Set_audio_vae", + "properties": { + "Node name for S&R": "SetNode", + "aux_id": "kijai/ComfyUI-KJNodes", + "previousName": "audio_vae" + }, + "widgets_values": [ + "audio_vae" + ], + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 358, + "type": "VisualizeSigmasKJ", + "pos": [ + -246.25912055429907, + 5385.802247690478 + ], + "size": [ + 270, + 102 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [ + { + "name": "sigmas", + "type": "SIGMAS", + "link": 1364 + } + ], + "outputs": [ + { + "name": "sigmas_out", + "type": "SIGMAS", + "links": [ + 1445 + ] + }, + { + "name": "image", + "type": "IMAGE", + "links": [ + 1447 + ] + } + ], + "properties": { + "Node name for S&R": "VisualizeSigmasKJ", + "cnr_id": "comfyui-kjnodes", + "ver": "422e10c846768faf26197e8d2b44ceddec736df2" + }, + "widgets_values": [ + 0, + -1 + ] + }, + { + "id": 588, + "type": "LTX2SamplingPreviewOverride", + "pos": [ + 30.020029718971266, + 4826.277247838771 + ], + "size": [ + 316.7017578125, + 98 + ], + "flags": {}, + "order": 23, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1527 + }, + { + "name": "latent_upscale_model", + "shape": 7, + "type": "LATENT_UPSCALE_MODEL", + "link": null + }, + { + "name": "vae", + "shape": 7, + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 1475 + ] + } + ], + "properties": { + "Node name for S&R": "LTX2SamplingPreviewOverride", + "cnr_id": "comfyui-kjnodes", + "ver": "a9ab19d2a7933e03f72bc00103863f06b9483300" + }, + "widgets_values": [ + 8 + ] + }, + { + "id": 562, + "type": "PreviewImage", + "pos": [ + 457.0626012270293, + 5407.331924216174 + ], + "size": [ + 402.6036823594029, + 348.06865481820023 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 1447 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage", + "cnr_id": "comfy-core", + "ver": "0.16.0" + }, + "widgets_values": [] + }, + { + "id": 549, + "type": "LTXVSeparateAVLatent", + "pos": [ + 410.29756241023017, + 4866.429072900162 + ], + "size": [ + 196.9, + 46 + ], + "flags": {}, + "order": 28, + "mode": 0, + "inputs": [ + { + "name": "av_latent", + "type": "LATENT", + "link": 1442 + } + ], + "outputs": [ + { + "name": "video_latent", + "type": "LATENT", + "links": [ + 1443 + ] + }, + { + "name": "audio_latent", + "type": "LATENT", + "links": [ + 1416 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVSeparateAVLatent", + "cnr_id": "comfy-core", + "ver": "0.16.0" + }, + "widgets_values": [] + }, + { + "id": 471, + "type": "VAEDecode", + "pos": [ + 418.5843206856501, + 4758.88746125511 + ], + "size": [ + 195.85402430293288, + 46 + ], + "flags": {}, + "order": 29, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 1443 + }, + { + "name": "vae", + "type": "VAE", + "link": 1288 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1502 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.9.1" + }, + "widgets_values": [] + }, + { + "id": 553, + "type": "VAELoaderKJ", + "pos": [ + -2075.071776414608, + 4872.248332282984 + ], + "size": [ + 270, + 106 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 1425 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoaderKJ", + "cnr_id": "comfyui-kjnodes", + "ver": "13101465f14df2b725694ad5e16ed29c838ec8f2" + }, + "widgets_values": [ + "LTX23\\vae\\LTX23_video_vae_bf16.safetensors", + "main_device", + "bf16" + ], + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 470, + "type": "VAELoaderKJ", + "pos": [ + -2069.145364496889, + 5036.235976414522 + ], + "size": [ + 422.5309084617047, + 106 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 1458 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoaderKJ", + "cnr_id": "comfyui-kjnodes", + "ver": "47ca2b2c26358615974162059061e9dc78be6715" + }, + "widgets_values": [ + "LTX23\\vae\\LTX23_audio_vae_bf16.safetensors", + "main_device", + "bf16" + ], + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 416, + "type": "DualCLIPLoader", + "pos": [ + -2059.196643703852, + 5205.442554054825 + ], + "size": [ + 478.8229382589643, + 130 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 1525 + ] + } + ], + "properties": { + "Node name for S&R": "DualCLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.8.2" + }, + "widgets_values": [ + "gemma_3_12B_it.safetensors", + "LTX23_text_projection_bf16.safetensors", + "ltxv", + "default" + ], + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 414, + "type": "UNETLoader", + "pos": [ + -2082.75718554929, + 4716.577345251733 + ], + "size": [ + 659.469041562375, + 82 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 1510 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.8.2" + }, + "widgets_values": [ + "LTX23\\ltx-2.3-22b-distilled-1.1_transformer_only_fp8_scaled.safetensors", + "default" + ], + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 604, + "type": "VHS_VideoCombine", + "pos": [ + 972.9907313321646, + 4759.096993398134 + ], + "size": [ + 809.9995141076606, + 1137.9995141076606 + ], + "flags": {}, + "order": 31, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 1502 + }, + { + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": 1503 + }, + { + "name": "meta_batch", + "shape": 7, + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "shape": 7, + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": null + } + ], + "title": "v2", + "properties": { + "Node name for S&R": "VHS_VideoCombine", + "cnr_id": "comfyui-videohelpersuite", + "ver": "3234937ff5f3ca19068aaba5042771514de2429d" + }, + "widgets_values": { + "frame_rate": 25, + "loop_count": 0, + "filename_prefix": "ltx23", + "format": "video/h264-mp4", + "pix_fmt": "yuv420p", + "crf": 19, + "save_metadata": true, + "trim_to_audio": false, + "pingpong": false, + "save_output": true, + "videopreview": { + "hidden": false, + "paused": false, + "params": { + "filename": "ltx23_00010-audio.mp4", + "subfolder": "", + "type": "output", + "format": "video/h264-mp4", + "frame_rate": 25, + "workflow": "ltx23_00010.png", + "fullpath": "N:\\AI\\ComfyUI\\output\\ltx23_00010-audio.mp4" + } + } + } + }, + { + "id": 236, + "type": "GetNode", + "pos": [ + 395.8731619414867, + 4679.203601839582 + ], + "size": [ + 210, + 34 + ], + "flags": { + "collapsed": true + }, + "order": 5, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 1288 + ] + } + ], + "title": "Get_video_vae", + "properties": { + "Node name for S&R": "GetNode", + "aux_id": "kijai/ComfyUI-KJNodes" + }, + "widgets_values": [ + "video_vae" + ], + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 561, + "type": "SamplerCustom", + "pos": [ + 72.26226107618345, + 5056.932833069262 + ], + "size": [ + 271.125, + 495.125 + ], + "flags": {}, + "order": 27, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1475 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 1438 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 1439 + }, + { + "name": "sampler", + "type": "SAMPLER", + "link": 1444 + }, + { + "name": "sigmas", + "type": "SIGMAS", + "link": 1445 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 1446 + } + ], + "outputs": [ + { + "name": "output", + "type": "LATENT", + "links": [ + 1442 + ] + }, + { + "name": "denoised_output", + "type": "LATENT", + "links": null + } + ], + "properties": { + "Node name for S&R": "SamplerCustom", + "cnr_id": "comfy-core", + "ver": "0.16.0" + }, + "widgets_values": [ + true, + 17, + "fixed", + 1 + ] + }, + { + "id": 164, + "type": "LTXVConditioning", + "pos": [ + -636.0686261237182, + 5079.243988964387 + ], + "size": [ + 270, + 86.66666666666667 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [ + { + "name": "positive", + "type": "CONDITIONING", + "link": 1528 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 1522 + } + ], + "outputs": [ + { + "name": "positive", + "type": "CONDITIONING", + "links": [ + 1438 + ] + }, + { + "name": "negative", + "type": "CONDITIONING", + "links": [ + 1439 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVConditioning", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 25 + ] + }, + { + "id": 154, + "type": "KSamplerSelect", + "pos": [ + -275.0811547364064, + 4958.11872694023 + ], + "size": [ + 270, + 68.88020833333334 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 1444 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "euler_ancestral" + ] + }, + { + "id": 606, + "type": "LoraLoaderModelOnly", + "pos": [ + -1698.1787435019987, + 4560.154090823786 + ], + "size": [ + 270, + 82 + ], + "flags": {}, + "order": 13, + "mode": 4, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1510 + } + ], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 1511 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly" + }, + "widgets_values": [ + "LTXV\\ltx-2.3-22b-distilled-lora-384-1.1.safetensors", + 0.5 + ] + }, + { + "id": 420, + "type": "ConditioningZeroOut", + "pos": [ + -876.8236196126293, + 5147.96788547305 + ], + "size": [ + 204.1750030517578, + 26 + ], + "flags": { + "collapsed": true + }, + "order": 24, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 1529 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 1522 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut", + "cnr_id": "comfy-core", + "ver": "0.8.2" + }, + "widgets_values": [] + }, + { + "id": 542, + "type": "PathchSageAttentionKJ", + "pos": [ + -1356.9805324213662, + 4680.081199476252 + ], + "size": [ + 270, + 82 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1511 + } + ], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 1523, + 1524 + ] + } + ], + "properties": { + "Node name for S&R": "PathchSageAttentionKJ", + "cnr_id": "comfyui-kjnodes", + "ver": "092a0fa72b725bb8cb544b5fe4ffdc61f2f84f7f" + }, + "widgets_values": [ + "auto", + false + ] + }, + { + "id": 607, + "type": "PromptRelayEncodeTimeline", + "pos": [ + -930.9208211983965, + 4256.109519721487 + ], + "size": [ + 841.7419321034436, + 523.5633462710748 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1524 + }, + { + "name": "clip", + "type": "CLIP", + "link": 1525 + }, + { + "name": "latent", + "type": "LATENT", + "link": 1526 + }, + { + "name": "max_frames", + "type": "INT", + "widget": { + "name": "max_frames" + }, + "link": 1530 + } + ], + "outputs": [ + { + "name": "model", + "type": "MODEL", + "links": [ + 1527 + ] + }, + { + "name": "positive", + "type": "CONDITIONING", + "links": [ + 1528, + 1529 + ] + } + ], + "properties": { + "Node name for S&R": "PromptRelayEncodeTimeline" + }, + "widgets_values": [ + "A single continuous cinematic shot of an old man and a red panda", + 489, + "{\"segments\":[{\"prompt\":\"An old man says \\\"Well well... what do we have here?\\\" and takes his hat off\",\"length\":123,\"color\":\"#4f8edc\"},{\"prompt\":\"A red panda jumps onto the old mans lap\",\"length\":122,\"color\":\"#e07b3a\"},{\"prompt\":\"The old man feeds the red panda an ice cream cone\",\"length\":122,\"color\":\"#d9534f\"},{\"prompt\":\"The red panda grabs the ice cream cone and runs away, while the old man says \\\"oh, okay then... keep it\\\"\",\"length\":122,\"color\":\"#5cb85c\"}]}", + "An old man says \"Well well... what do we have here?\" and takes his hat off | A red panda jumps onto the old mans lap | The old man feeds the red panda an ice cream cone | The red panda grabs the ice cream cone and runs away, while the old man says \"oh, okay then... keep it\"", + "123, 122, 122, 122", + 0.001, + "" + ] + }, + { + "id": 578, + "type": "PrimitiveNode", + "pos": [ + -1273.88367082225, + 5354.760749541819 + ], + "size": [ + 210, + 82 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "widget": { + "name": "length" + }, + "links": [ + 1462, + 1463, + 1530 + ] + } + ], + "title": "length", + "properties": { + "Run widget replace on values": false + }, + "widgets_values": [ + 489, + "fixed" + ] + }, + { + "id": 577, + "type": "EmptyLTXVLatentVideo", + "pos": [ + -936.3442167858689, + 5350.81450987946 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 1462 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 1466, + 1526 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLTXVLatentVideo", + "cnr_id": "comfy-core", + "ver": "0.16.2" + }, + "widgets_values": [ + 640, + 640, + 489, + 1 + ] + }, + { + "id": 514, + "type": "BasicScheduler", + "pos": [ + -249.89573205841853, + 5212.6973459473775 + ], + "size": [ + 270, + 106 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1523 + } + ], + "outputs": [ + { + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 1364 + ] + } + ], + "properties": { + "Node name for S&R": "BasicScheduler", + "cnr_id": "comfy-core", + "ver": "0.10.0" + }, + "widgets_values": [ + "linear_quadratic", + 8, + 1 + ] + }, + { + "id": 548, + "type": "LTXVConcatAVLatent", + "pos": [ + -535.9503791731972, + 5583.462562695162 + ], + "size": [ + 183.9, + 46 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "name": "video_latent", + "type": "LATENT", + "link": 1467 + }, + { + "name": "audio_latent", + "type": "LATENT", + "link": 1411 + } + ], + "outputs": [ + { + "name": "latent", + "type": "LATENT", + "links": [ + 1446 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVConcatAVLatent", + "cnr_id": "comfy-core", + "ver": "0.16.0" + }, + "widgets_values": [] + }, + { + "id": 547, + "type": "LTXVEmptyLatentAudio", + "pos": [ + -684.132834750389, + 5770.6351958738105 + ], + "size": [ + 270, + 106 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "label": "Audio VAE", + "name": "audio_vae", + "type": "VAE", + "link": 1460 + }, + { + "name": "frames_number", + "type": "INT", + "widget": { + "name": "frames_number" + }, + "link": 1463 + } + ], + "outputs": [ + { + "name": "Latent", + "type": "LATENT", + "links": [ + 1411 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVEmptyLatentAudio", + "cnr_id": "comfy-core", + "ver": "0.16.0" + }, + "widgets_values": [ + 489, + 25, + 1 + ] + }, + { + "id": 576, + "type": "GetNode", + "pos": [ + -675.7164271081411, + 5735.579518189976 + ], + "size": [ + 210, + 34 + ], + "flags": { + "collapsed": true + }, + "order": 8, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 1460 + ] + } + ], + "title": "Get_audio_vae", + "properties": { + "Node name for S&R": "GetNode", + "aux_id": "kijai/ComfyUI-KJNodes" + }, + "widgets_values": [ + "audio_vae" + ], + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 582, + "type": "LTXVImgToVideoInplaceKJ", + "pos": [ + -942.7215500667429, + 5557.964498032986 + ], + "size": [ + 294.98046875, + 146 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "vae", + "type": "VAE", + "link": 1465 + }, + { + "name": "latent", + "type": "LATENT", + "link": 1466 + }, + { + "label": "image_1", + "name": "num_images.image_1", + "shape": 7, + "type": "IMAGE", + "link": 1473 + } + ], + "outputs": [ + { + "name": "latent", + "type": "LATENT", + "links": [ + 1467 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVImgToVideoInplaceKJ", + "cnr_id": "comfyui-kjnodes", + "ver": "a9ab19d2a7933e03f72bc00103863f06b9483300" + }, + "widgets_values": [ + "1", + 1, + 0 + ] + }, + { + "id": 583, + "type": "GetNode", + "pos": [ + -1120.0874433399276, + 5573.366934790074 + ], + "size": [ + 210, + 34 + ], + "flags": { + "collapsed": true + }, + "order": 9, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 1465 + ] + } + ], + "title": "Get_video_vae", + "properties": { + "Node name for S&R": "GetNode", + "aux_id": "kijai/ComfyUI-KJNodes" + }, + "widgets_values": [ + "video_vae" + ], + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 586, + "type": "LTXVPreprocess", + "pos": [ + -1276.4516625074652, + 5656.831992477144 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 1470 + } + ], + "outputs": [ + { + "name": "output_image", + "type": "IMAGE", + "links": [ + 1473 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVPreprocess", + "cnr_id": "comfy-core", + "ver": "0.16.2" + }, + "widgets_values": [ + 30 + ] + }, + { + "id": 584, + "type": "LoadImage", + "pos": [ + -1616.6195105053441, + 5587.7473399779155 + ], + "size": [ + 282.798828125, + 314 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1470 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage", + "cnr_id": "comfy-core", + "ver": "0.16.2" + }, + "widgets_values": [ + "oldman_upscaled.png", + "image" + ] + } + ], + "links": [ + [ + 1288, + 236, + 0, + 471, + 1, + "VAE" + ], + [ + 1364, + 514, + 0, + 358, + 0, + "SIGMAS" + ], + [ + 1411, + 547, + 0, + 548, + 1, + "LATENT" + ], + [ + 1416, + 549, + 1, + 550, + 0, + "LATENT" + ], + [ + 1425, + 553, + 0, + 228, + 0, + "VAE" + ], + [ + 1438, + 164, + 0, + 561, + 1, + "CONDITIONING" + ], + [ + 1439, + 164, + 1, + 561, + 2, + "CONDITIONING" + ], + [ + 1442, + 561, + 0, + 549, + 0, + "LATENT" + ], + [ + 1443, + 549, + 0, + 471, + 0, + "LATENT" + ], + [ + 1444, + 154, + 0, + 561, + 3, + "SAMPLER" + ], + [ + 1445, + 358, + 0, + 561, + 4, + "SIGMAS" + ], + [ + 1446, + 548, + 0, + 561, + 5, + "LATENT" + ], + [ + 1447, + 358, + 1, + 562, + 0, + "IMAGE" + ], + [ + 1458, + 470, + 0, + 574, + 0, + "VAE" + ], + [ + 1459, + 575, + 0, + 550, + 1, + "VAE" + ], + [ + 1460, + 576, + 0, + 547, + 0, + "VAE" + ], + [ + 1462, + 578, + 0, + 577, + 0, + "INT" + ], + [ + 1463, + 578, + 0, + 547, + 1, + "INT" + ], + [ + 1465, + 583, + 0, + 582, + 0, + "VAE" + ], + [ + 1466, + 577, + 0, + 582, + 1, + "LATENT" + ], + [ + 1467, + 582, + 0, + 548, + 0, + "LATENT" + ], + [ + 1470, + 584, + 0, + 586, + 0, + "IMAGE" + ], + [ + 1473, + 586, + 0, + 582, + 2, + "IMAGE" + ], + [ + 1475, + 588, + 0, + 561, + 0, + "MODEL" + ], + [ + 1502, + 471, + 0, + 604, + 0, + "IMAGE" + ], + [ + 1503, + 550, + 0, + 604, + 1, + "AUDIO" + ], + [ + 1510, + 414, + 0, + 606, + 0, + "MODEL" + ], + [ + 1511, + 606, + 0, + 542, + 0, + "MODEL" + ], + [ + 1522, + 420, + 0, + 164, + 1, + "CONDITIONING" + ], + [ + 1523, + 542, + 0, + 514, + 0, + "MODEL" + ], + [ + 1524, + 542, + 0, + 607, + 0, + "MODEL" + ], + [ + 1525, + 416, + 0, + 607, + 1, + "CLIP" + ], + [ + 1526, + 577, + 0, + 607, + 2, + "LATENT" + ], + [ + 1527, + 607, + 0, + 588, + 0, + "MODEL" + ], + [ + 1528, + 607, + 1, + 164, + 0, + "CONDITIONING" + ], + [ + 1529, + 607, + 1, + 420, + 0, + "CONDITIONING" + ], + [ + 1530, + 578, + 0, + 607, + 3, + "INT" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.5337067413064848, + "offset": [ + 2372.5181474537003, + -4114.50371234748 + ] + }, + "frontendVersion": "1.43.1", + "workflowRendererVersion": "LG", + "prompt": { + "1": { + "inputs": { + "ckpt_name": "ltx-av-step-1751000_vocoder_24K.safetensors" + }, + "class_type": "CheckpointLoaderSimple", + "_meta": { + "title": "Load Checkpoint" + } + }, + "2": { + "inputs": { + "gemma_path": "gemma-3-12b-it-qat-q4_0-unquantized_readout_proj/model/model.safetensors", + "ltxv_path": "ltx-av-step-1751000_vocoder_24K.safetensors", + "max_length": 1024 + }, + "class_type": "LTXVGemmaCLIPModelLoader", + "_meta": { + "title": "πŸ…›πŸ…£πŸ…§ Gemma 3 Model Loader" + } + }, + "3": { + "inputs": { + "text": "A medium close-up shot features a Caucasian man with a closely shaven head and face, wearing a black baseball cap with \"PNTR\" in white letters on the front, and a dark grey t-shirt with \"JUST DO IT\" visible across his chest. A small black microphone is clipped to his shirt collar. He is positioned slightly to the left of the frame, looking intently downwards and to his right, his eyes focused off-camera. His facial expression is one of deep concentration, with his brow slightly furrowed. As he looks down, a quick sniff sound is heard, and then he speaks with a deep male voice and a slightly frustrated tone, saying, \"I think it's so bad.\" The camera remains static throughout, maintaining a shallow depth of field, which keeps the man in sharp focus while the background is softly blurred, revealing a light-colored wall with white wooden shelving or trim, and a partially open white wooden door on the right. After a brief pause, another short, audible sniff is heard. The man then continues to speak, his voice maintaining the same quality, as he states, \"So bad. So bad.\" He elaborates further, emphasizing his point with a final statement, \"This got to be, it's got to be the worst tool I've ever seen.\"", + "clip": [ + "2", + 0 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "CLIP Text Encode (Prompt)" + } + }, + "4": { + "inputs": { + "text": "blurry, out of focus, overexposed, underexposed, low contrast, washed out colors, excessive noise, grainy texture, poor lighting, flickering, motion blur, distorted proportions, unnatural skin tones, deformed facial features, asymmetrical face, missing facial features, extra limbs, disfigured hands, wrong hand count, artifacts around text, unreadable text on shirt or hat, incorrect lettering on cap (β€œPNTR”), incorrect t-shirt slogan (β€œJUST DO IT”), missing microphone, misplaced microphone, inconsistent perspective, camera shake, incorrect depth of field, background too sharp, background clutter, distracting reflections, harsh shadows, inconsistent lighting direction, color banding, cartoonish rendering, 3D CGI look, unrealistic materials, uncanny valley effect, incorrect ethnicity, wrong gender, exaggerated expressions, smiling, laughing, exaggerated sadness, wrong gaze direction, eyes looking at camera, mismatched lip sync, silent or muted audio, distorted voice, robotic voice, echo, background noise, off-sync audio, missing sniff sounds, incorrect dialogue, added dialogue, repetitive speech, jittery movement, awkward pauses, incorrect timing, unnatural transitions, inconsistent framing, tilted camera, missing door or shelves, missing shallow depth of field, flat lighting, inconsistent tone, cinematic oversaturation, stylized filters, or AI artifacts.", + "clip": [ + "2", + 0 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "CLIP Text Encode (Prompt)" + } + }, + "8": { + "inputs": { + "sampler_name": "euler" + }, + "class_type": "KSamplerSelect", + "_meta": { + "title": "KSamplerSelect" + } + }, + "9": { + "inputs": { + "steps": 20, + "max_shift": 2.05, + "base_shift": 0.95, + "stretch": true, + "terminal": 0.1, + "latent": [ + "28", + 0 + ] + }, + "class_type": "LTXVScheduler", + "_meta": { + "title": "LTXVScheduler" + } + }, + "11": { + "inputs": { + "noise_seed": 10 + }, + "class_type": "RandomNoise", + "_meta": { + "title": "RandomNoise" + } + }, + "12": { + "inputs": { + "samples": [ + "29", + 0 + ], + "vae": [ + "1", + 2 + ] + }, + "class_type": "VAEDecode", + "_meta": { + "title": "VAE Decode" + } + }, + "13": { + "inputs": { + "ckpt_name": "ltx-av-step-1751000_vocoder_24K.safetensors" + }, + "class_type": "LTXVAudioVAELoader", + "_meta": { + "title": "πŸ…›πŸ…£πŸ…§ LTXV Audio VAE Loader" + } + }, + "14": { + "inputs": { + "samples": [ + "29", + 1 + ], + "audio_vae": [ + "13", + 0 + ] + }, + "class_type": "LTXVAudioVAEDecode", + "_meta": { + "title": "πŸ…›πŸ…£πŸ…§ LTXV Audio VAE Decode" + } + }, + "15": { + "inputs": { + "frame_rate": [ + "23", + 0 + ], + "loop_count": 0, + "filename_prefix": "AnimateDiff", + "format": "video/h264-mp4", + "pix_fmt": "yuv420p", + "crf": 19, + "save_metadata": true, + "trim_to_audio": false, + "pingpong": false, + "save_output": true, + "images": [ + "12", + 0 + ], + "audio": [ + "14", + 0 + ] + }, + "class_type": "VHS_VideoCombine", + "_meta": { + "title": "Video Combine πŸŽ₯πŸ…₯πŸ…—πŸ…’" + } + }, + "17": { + "inputs": { + "skip_blocks": "29", + "model": [ + "28", + 1 + ], + "positive": [ + "22", + 0 + ], + "negative": [ + "22", + 1 + ], + "parameters": [ + "18", + 0 + ] + }, + "class_type": "MultimodalGuider", + "_meta": { + "title": "πŸ…›πŸ…£πŸ…§ Multimodal Guider" + } + }, + "18": { + "inputs": { + "modality": "VIDEO", + "cfg": 3, + "stg": 0, + "rescale": 0, + "modality_scale": 3, + "parameters": [ + "19", + 0 + ] + }, + "class_type": "GuiderParameters", + "_meta": { + "title": "πŸ…›πŸ…£πŸ…§ Guider Parameters" + } + }, + "19": { + "inputs": { + "modality": "AUDIO", + "cfg": 7, + "stg": 0, + "rescale": 0, + "modality_scale": 3 + }, + "class_type": "GuiderParameters", + "_meta": { + "title": "πŸ…›πŸ…£πŸ…§ Guider Parameters" + } + }, + "21": { + "inputs": { + "audioUI": "", + "audio": [ + "14", + 0 + ] + }, + "class_type": "PreviewAudio", + "_meta": { + "title": "PreviewAudio" + } + }, + "22": { + "inputs": { + "frame_rate": [ + "23", + 0 + ], + "positive": [ + "3", + 0 + ], + "negative": [ + "4", + 0 + ] + }, + "class_type": "LTXVConditioning", + "_meta": { + "title": "LTXVConditioning" + } + }, + "23": { + "inputs": { + "value": 25 + }, + "class_type": "FloatConstant", + "_meta": { + "title": "Float Constant" + } + }, + "26": { + "inputs": { + "frames_number": [ + "27", + 0 + ], + "frame_rate": [ + "42", + 0 + ], + "batch_size": 1 + }, + "class_type": "LTXVEmptyLatentAudio", + "_meta": { + "title": "πŸ…›πŸ…£πŸ…§ LTXV Empty Latent Audio" + } + }, + "27": { + "inputs": { + "value": 105 + }, + "class_type": "INTConstant", + "_meta": { + "title": "INT Constant" + } + }, + "28": { + "inputs": { + "video_latent": [ + "43", + 0 + ], + "audio_latent": [ + "26", + 0 + ], + "model": [ + "44", + 0 + ] + }, + "class_type": "LTXVConcatAVLatent", + "_meta": { + "title": "πŸ…›πŸ…£πŸ…§ LTXV Concat AV Latent" + } + }, + "29": { + "inputs": { + "av_latent": [ + "41", + 0 + ], + "model": [ + "28", + 1 + ] + }, + "class_type": "LTXVSeparateAVLatent", + "_meta": { + "title": "πŸ…›πŸ…£πŸ…§ LTXV Separate AV Latent" + } + }, + "41": { + "inputs": { + "noise": [ + "11", + 0 + ], + "guider": [ + "17", + 0 + ], + "sampler": [ + "8", + 0 + ], + "sigmas": [ + "9", + 0 + ], + "latent_image": [ + "28", + 0 + ] + }, + "class_type": "SamplerCustomAdvanced", + "_meta": { + "title": "SamplerCustomAdvanced" + } + }, + "42": { + "inputs": { + "a": [ + "23", + 0 + ] + }, + "class_type": "CM_FloatToInt", + "_meta": { + "title": "FloatToInt" + } + }, + "43": { + "inputs": { + "width": 768, + "height": 512, + "length": [ + "27", + 0 + ], + "batch_size": 1 + }, + "class_type": "EmptyLTXVLatentVideo", + "_meta": { + "title": "EmptyLTXVLatentVideo" + } + }, + "44": { + "inputs": { + "torch_compile": true, + "disable_backup": false, + "model": [ + "1", + 0 + ] + }, + "class_type": "LTXVSequenceParallelMultiGPUPatcher", + "_meta": { + "title": "LTXVSequenceParallelMultiGPUPatcher" + } + }, + "45": { + "inputs": { + "frame_idx": 0, + "strength": 1 + }, + "class_type": "LTXVAddGuide", + "_meta": { + "title": "LTXVAddGuide" + } + } + }, + "comfy_fork_version": "feature/av_inference@a6994ed1", + "VHS_latentpreview": true, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true + }, + "version": 0.4 +} \ No newline at end of file diff --git a/nodes.py b/nodes.py index e44d048..543d264 100644 --- a/nodes.py +++ b/nodes.py @@ -15,6 +15,84 @@ from .patches import detect_model_type, apply_patches log = logging.getLogger(__name__) +def _convert_to_latent_lengths(pixel_lengths, temporal_stride, latent_frames): + """Convert pixel-space segment lengths to integer latent-space lengths using the + largest-remainder method. Targets the full `latent_frames` when the pixel sum looks + like full coverage (within one stride of latent_frames * stride). Otherwise targets + round(total_pixel / temporal_stride) so partial-coverage timelines stay partial. + """ + if not pixel_lengths: + return [] + total_pixel = sum(pixel_lengths) + if total_pixel <= 0: + return [1] * len(pixel_lengths) + + naive_total = max(1, round(total_pixel / temporal_stride)) + target_total = min(latent_frames, naive_total) + # Within one frame of full β†’ user clearly intended full coverage; pin to latent_frames. + if target_total >= latent_frames - 1: + target_total = latent_frames + + exact = [p * target_total / total_pixel for p in pixel_lengths] + result = [int(e) for e in exact] + diff = target_total - sum(result) + if diff > 0: + order = sorted(range(len(exact)), key=lambda i: -(exact[i] - int(exact[i]))) + for k in range(diff): + result[order[k % len(order)]] += 1 + + # Ensure every segment has β‰₯ 1 latent frame (steal from the largest if needed). + for i in range(len(result)): + if result[i] < 1: + max_idx = max(range(len(result)), key=lambda j: result[j]) + if result[max_idx] > 1: + result[max_idx] -= 1 + result[i] = 1 + + return result + + +def _encode_relay(model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon): + locals_list = [p.strip() for p in local_prompts.split("|") if p.strip()] + if not locals_list: + raise ValueError("At least one local prompt is required (separate with |)") + + arch, patch_size, temporal_stride = detect_model_type(model) + + samples = latent["samples"] + latent_frames = samples.shape[2] + tokens_per_frame = (samples.shape[3] // patch_size[1]) * (samples.shape[4] // patch_size[2]) + + parsed_lengths = None + if segment_lengths.strip(): + pixel_lengths = [int(x.strip()) for x in segment_lengths.split(",") if x.strip()] + parsed_lengths = _convert_to_latent_lengths(pixel_lengths, temporal_stride, latent_frames) + + raw_tokenizer = get_raw_tokenizer(clip) + full_prompt, token_ranges = map_token_indices(raw_tokenizer, global_prompt, locals_list) + + log.info("[PromptRelay] Global: tokens [0:%d] (%d tokens)", token_ranges[0][0], token_ranges[0][0]) + for i, (s, e) in enumerate(token_ranges): + log.info("[PromptRelay] Segment %d: tokens [%d:%d] (%d tokens)", i, s, e, e - s) + + conditioning = clip.encode_from_tokens_scheduled(clip.tokenize(full_prompt)) + + effective_lengths = distribute_segment_lengths(len(locals_list), latent_frames, parsed_lengths) + + log.info( + "[PromptRelay] Latent: %d frames, %d tokens/frame, segments: %s", + latent_frames, tokens_per_frame, effective_lengths, + ) + + q_token_idx = build_segments(token_ranges, effective_lengths, epsilon) + mask_fn = create_mask_fn(q_token_idx, tokens_per_frame, latent_frames) + + patched = model.clone() + apply_patches(patched, arch, mask_fn) + + return patched, conditioning + + class PromptRelayEncode(io.ComfyNode): """Encodes temporal local prompts and patches the model for Prompt Relay.""" @@ -58,50 +136,75 @@ class PromptRelayEncode(io.ComfyNode): @classmethod def execute(cls, model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon) -> io.NodeOutput: - locals_list = [p.strip() for p in local_prompts.split("|") if p.strip()] - if not locals_list: - raise ValueError("At least one local prompt is required (separate with |)") + patched, conditioning = _encode_relay( + model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon, + ) + return io.NodeOutput(patched, conditioning) - arch, patch_size, temporal_stride = detect_model_type(model) - parsed_lengths = None - if segment_lengths.strip(): - pixel_lengths = [int(x.strip()) for x in segment_lengths.split(",") if x.strip()] - parsed_lengths = [max(1, round(p / temporal_stride)) for p in pixel_lengths] +class PromptRelayEncodeTimeline(io.ComfyNode): + """WYSIWYG timeline variant β€” segments and lengths come from a visual editor in the node UI.""" - raw_tokenizer = get_raw_tokenizer(clip) - full_prompt, token_ranges = map_token_indices(raw_tokenizer, global_prompt, locals_list) - - log.info("[PromptRelay] Global: tokens [0:%d] (%d tokens)", token_ranges[0][0], token_ranges[0][0]) - for i, (s, e) in enumerate(token_ranges): - log.info("[PromptRelay] Segment %d: tokens [%d:%d] (%d tokens)", i, s, e, e - s) - - conditioning = clip.encode_from_tokens_scheduled(clip.tokenize(full_prompt)) - - samples = latent["samples"] - latent_frames = samples.shape[2] - tokens_per_frame = (samples.shape[3] // patch_size[1]) * (samples.shape[4] // patch_size[2]) - - effective_lengths = distribute_segment_lengths(len(locals_list), latent_frames, parsed_lengths) - - log.info( - "[PromptRelay] Latent: %d frames, %d tokens/frame, segments: %s", - latent_frames, tokens_per_frame, effective_lengths, + @classmethod + def define_schema(cls): + return io.Schema( + node_id="PromptRelayEncodeTimeline", + display_name="Prompt Relay Encode (Timeline)", + category="conditioning/prompt_relay", + description=( + "Same as Prompt Relay Encode, but local prompts and segment lengths are edited " + "visually as draggable blocks on a timeline. The max_frames input only sets the " + "timeline scale (pixel space) β€” actual frame count is still read from the latent." + ), + inputs=[ + io.Model.Input("model"), + io.Clip.Input("clip"), + io.Latent.Input("latent", tooltip="Empty latent video β€” dimensions are read from its shape."), + io.String.Input( + "global_prompt", multiline=True, default="", + tooltip="Conditions the entire video. Anchors persistent characters, objects, and scene context.", + ), + io.Int.Input( + "max_frames", default=49, min=1, max=10000, step=1, + tooltip="Total timeline length in pixel-space frames. Used by the editor for visual scale only.", + ), + io.String.Input( + "timeline_data", default="", + tooltip="JSON state of the timeline editor (auto-managed; do not edit by hand).", + ), + io.String.Input( + "local_prompts", multiline=True, default="", + tooltip="Auto-populated from the timeline editor.", + ), + io.String.Input( + "segment_lengths", default="", + tooltip="Auto-populated from the timeline editor (pixel-space frame counts).", + ), + io.Float.Input( + "epsilon", default=1e-3, min=1e-6, max=0.99, step=1e-4, + tooltip="Penalty decay parameter. Values below ~0.1 all produce sharp boundaries (paper default 0.001). For softer transitions, try 0.5 or higher.", + ), + ], + outputs=[ + io.Model.Output(display_name="model"), + io.Conditioning.Output(display_name="positive"), + ], ) - q_token_idx = build_segments(token_ranges, effective_lengths, epsilon) - mask_fn = create_mask_fn(q_token_idx, tokens_per_frame, latent_frames) - - patched = model.clone() - apply_patches(patched, arch, mask_fn) - + @classmethod + def execute(cls, model, clip, latent, global_prompt, max_frames, timeline_data, local_prompts, segment_lengths, epsilon) -> io.NodeOutput: + patched, conditioning = _encode_relay( + model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon, + ) return io.NodeOutput(patched, conditioning) NODE_CLASS_MAPPINGS = { "PromptRelayEncode": PromptRelayEncode, + "PromptRelayEncodeTimeline": PromptRelayEncodeTimeline, } NODE_DISPLAY_NAME_MAPPINGS = { "PromptRelayEncode": "Prompt Relay Encode", + "PromptRelayEncodeTimeline": "Prompt Relay Encode (Timeline)", } diff --git a/web/js/prompt_relay_timeline.js b/web/js/prompt_relay_timeline.js new file mode 100644 index 0000000..f032997 --- /dev/null +++ b/web/js/prompt_relay_timeline.js @@ -0,0 +1,879 @@ +const { app } = window.comfyAPI.app; + +const PALETTE = [ + "#4f8edc", "#e07b3a", "#5cb85c", "#d9534f", "#9b6cd6", + "#a07060", "#e377c2", "#7f7f7f", "#c4c447", "#3fbac4", +]; + +const RULER_HEIGHT = 22; +const BLOCK_HEIGHT = 64; +const CANVAS_HEIGHT = RULER_HEIGHT + BLOCK_HEIGHT; +const HANDLE_HIT_PX = 6; +const REORDER_THRESHOLD_PX = 6; +const MIN_SEGMENT_LENGTH = 1; +const HIDDEN_WIDGET_NAMES = ["timeline_data", "local_prompts", "segment_lengths"]; + +function hideWidget(w) { + if (!w) return; + w.type = "hidden"; + w.hidden = true; + w.computeSize = () => [0, -4]; +} + +function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } + +function pickColor(existingColors) { + // First palette color not currently in use; otherwise generate one via golden-angle hue + // rotation so additional segments stay visually distinct without colliding with the palette. + for (const c of PALETTE) if (!existingColors.has(c)) return c; + const idx = existingColors.size; + const hue = (idx * 137.508) % 360; + return `hsl(${hue.toFixed(0)}, 55%, 55%)`; +} + +function defaultTimeline(maxFrames) { + const half = Math.max(MIN_SEGMENT_LENGTH, Math.floor(maxFrames / 2)); + return { + segments: [ + { prompt: "", length: half, color: PALETTE[0] }, + { prompt: "", length: Math.max(MIN_SEGMENT_LENGTH, maxFrames - half), color: PALETTE[1] }, + ], + }; +} + +function parseInitial(jsonStr, maxFrames) { + if (!jsonStr) return defaultTimeline(maxFrames); + try { + const obj = JSON.parse(jsonStr); + if (Array.isArray(obj?.segments) && obj.segments.length > 0) { + return { + segments: obj.segments.map((s, i) => ({ + prompt: typeof s.prompt === "string" ? s.prompt : "", + length: Math.max(MIN_SEGMENT_LENGTH, parseInt(s.length, 10) || MIN_SEGMENT_LENGTH), + // Backward compat: assign a stable color if missing. + color: typeof s.color === "string" ? s.color : PALETTE[i % PALETTE.length], + })), + }; + } + } catch (_) {} + return defaultTimeline(maxFrames); +} + +class TimelineEditor { + constructor(node, container) { + this.node = node; + this.container = container; + this.maxFramesWidget = node.widgets.find(w => w.name === "max_frames"); + this.timelineDataWidget = node.widgets.find(w => w.name === "timeline_data"); + this.localPromptsWidget = node.widgets.find(w => w.name === "local_prompts"); + this.segmentLengthsWidget = node.widgets.find(w => w.name === "segment_lengths"); + + this.timeline = parseInitial(this.timelineDataWidget?.value, this.getMaxFrames()); + this.selectedIndex = 0; + this.hoverIndex = -1; + this.hoverHandle = -1; + this.dragHandle = -1; + this.dragStart = null; + this.reorder = null; // { sourceIdx, targetIdx, startX, startY, active } + this._settling = false; // true between reorder release and animation convergence + this._inputBaseline = null; // length snapshot for revertible lengthInput edits + this._textCommitTimer = null; // debounce handle for textarea-driven commits + // Per-segment displayed X (animated). Keyed by segment array index. + this._displayedX = new Map(); + this._targetX = new Map(); + this._animRaf = null; + + this.buildDOM(); + this.bindEvents(); + this.syncWidgetsFromTimeline(); + this.updateUIFromSelection(); + this.render(); + } + + getMaxFrames() { + return Math.max(1, parseInt(this.maxFramesWidget?.value, 10) || 1); + } + + buildDOM() { + this.container.innerHTML = ""; + this.container.style.cssText = ` + display: flex; flex-direction: column; gap: 6px; + padding: 6px 8px; box-sizing: border-box; + font-family: sans-serif; font-size: 11px; color: #ddd; + width: 100%; height: 100%; + `; + + this.canvas = document.createElement("canvas"); + this.canvas.style.cssText = ` + width: 100%; height: ${CANVAS_HEIGHT}px; + display: block; background: #1a1a1a; border-radius: 4px; + cursor: default; + `; + this.container.appendChild(this.canvas); + this.ctx = this.canvas.getContext("2d"); + + this.textarea = document.createElement("textarea"); + this.textarea.placeholder = "Click a segment above to edit its prompt…"; + this.textarea.style.cssText = ` + width: 100%; min-height: 60px; flex: 1 1 auto; + box-sizing: border-box; resize: none; + background: #2a2a2a; color: #eee; border: 1px solid #444; + border-radius: 4px; padding: 6px; font-family: inherit; font-size: 12px; + `; + this.container.appendChild(this.textarea); + + const row = document.createElement("div"); + row.style.cssText = "display: flex; gap: 6px; align-items: center;"; + + const lengthLabel = document.createElement("label"); + lengthLabel.style.cssText = "display: flex; align-items: center; gap: 4px;"; + lengthLabel.textContent = "Length:"; + this.lengthInput = document.createElement("input"); + this.lengthInput.type = "number"; + this.lengthInput.min = MIN_SEGMENT_LENGTH; + this.lengthInput.style.cssText = ` + width: 70px; background: #2a2a2a; color: #eee; + border: 1px solid #444; border-radius: 3px; padding: 2px 4px; + `; + lengthLabel.appendChild(this.lengthInput); + row.appendChild(lengthLabel); + + this.totalLabel = document.createElement("span"); + this.totalLabel.style.cssText = "color: #888; margin-left: 4px;"; + row.appendChild(this.totalLabel); + + const spacer = document.createElement("div"); + spacer.style.flex = "1"; + row.appendChild(spacer); + + this.addBtn = this.makeButton( + "+ Add", + "Add a new segment. Steals space from existing segments (from the end) if the timeline is full.", + ); + this.distributeBtn = this.makeButton( + "Equalize", + "Set every segment to the same length so the total exactly fills max_frames.", + ); + this.deleteBtn = this.makeButton( + "Delete", + "Remove the currently selected segment. Disabled when only one segment is left.", + ); + row.appendChild(this.addBtn); + row.appendChild(this.distributeBtn); + row.appendChild(this.deleteBtn); + + this.container.appendChild(row); + } + + makeButton(label, tooltip) { + const b = document.createElement("button"); + b.textContent = label; + if (tooltip) b.title = tooltip; + b.style.cssText = ` + background: #3a3a3a; color: #eee; border: 1px solid #555; + border-radius: 3px; padding: 3px 10px; cursor: pointer; font-size: 11px; + `; + b.addEventListener("mouseenter", () => b.style.background = "#4a4a4a"); + b.addEventListener("mouseleave", () => b.style.background = "#3a3a3a"); + return b; + } + + bindEvents() { + // stopPropagation prevents LiteGraph from treating clicks as node-drag/zoom + this.canvas.addEventListener("pointerdown", e => { e.stopPropagation(); this.onPointerDown(e); }); + this.canvas.addEventListener("pointermove", e => { e.stopPropagation(); this.onPointerMove(e); }); + this.canvas.addEventListener("pointerup", e => { e.stopPropagation(); this.onPointerUp(e); }); + this.canvas.addEventListener("contextmenu", e => { e.preventDefault(); e.stopPropagation(); }); + this.canvas.addEventListener("wheel", e => e.stopPropagation(), { passive: true }); + this.canvas.addEventListener("pointerleave", () => { + if (this.dragHandle < 0) { + this.hoverIndex = -1; + this.hoverHandle = -1; + this.canvas.style.cursor = "default"; + this.render(); + } + }); + // Keep textarea scrolling/typing from zooming or hijacking the graph + this.textarea.addEventListener("wheel", e => e.stopPropagation(), { passive: true }); + this.textarea.addEventListener("pointerdown", e => e.stopPropagation()); + this.lengthInput.addEventListener("pointerdown", e => e.stopPropagation()); + this.lengthInput.addEventListener("wheel", e => e.stopPropagation(), { passive: true }); + + this.textarea.addEventListener("input", () => { + const seg = this.timeline.segments[this.selectedIndex]; + if (!seg) return; + seg.prompt = this.textarea.value; + // Update local_prompts widget immediately so a workflow run picks up the latest text + // even if the debounced commit hasn't fired yet. + if (this.localPromptsWidget) { + this.localPromptsWidget.value = this.timeline.segments.map(s => s.prompt).join(" | "); + } + this.render(); + // Debounce the heavier timeline_data JSON write. + if (this._textCommitTimer) clearTimeout(this._textCommitTimer); + this._textCommitTimer = setTimeout(() => { + this._textCommitTimer = null; + this.commit(); + }, 120); + }); + this.textarea.addEventListener("blur", () => { + if (this._textCommitTimer) { + clearTimeout(this._textCommitTimer); + this._textCommitTimer = null; + this.commit(); + } + }); + this.lengthInput.addEventListener("focus", () => { this._inputBaseline = null; }); + this.lengthInput.addEventListener("blur", () => { this._inputBaseline = null; }); + this.lengthInput.addEventListener("input", () => { + const idx = this.selectedIndex; + const seg = this.timeline.segments[idx]; + if (!seg) return; + const v = parseInt(this.lengthInput.value, 10); + if (!Number.isFinite(v) || v < MIN_SEGMENT_LENGTH) return; + // Snapshot pre-edit state on the first keystroke so 20β†’30β†’20 reverts cleanly. + if (!this._inputBaseline) { + this._inputBaseline = this.timeline.segments.map(s => s.length); + } + this._setLengthShifting(idx, v, this._inputBaseline); + this.commit(); + this.render(); + this.updateTotalLabel(); + }); + + this.addBtn.addEventListener("click", () => this.addSegment()); + this.distributeBtn.addEventListener("click", () => this.distributeEvenly()); + this.deleteBtn.addEventListener("click", () => this.deleteSelected()); + + if (this.maxFramesWidget) { + const prev = this.maxFramesWidget.callback; + this.maxFramesWidget.callback = (...args) => { + prev?.apply(this.maxFramesWidget, args); + this.trimToFit(); + this.commit(); + this.updateUIFromSelection(); + this.render(); + }; + } + + this.resizeObserver = new ResizeObserver(() => this.resizeCanvas()); + this.resizeObserver.observe(this.container); + this.resizeCanvas(); + } + + resizeCanvas() { + const dpr = window.devicePixelRatio || 1; + // offsetWidth is the canvas's CSS pixel size (ignores LiteGraph's zoom transform); + // getBoundingClientRect would return post-transform pixels and break hit-testing math. + const w = Math.max(50, Math.floor(this.canvas.offsetWidth)); + this.canvas.width = w * dpr; + this.canvas.height = CANVAS_HEIGHT * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this._cssWidth = w; + this.render(); + } + + // ─── Layout ─── + + pxPerFrame() { + return this._cssWidth / this.getMaxFrames(); + } + + // Layout: by default uses the visual (effective) order, which matches the natural array + // order except during an active reorder, where the dragged segment previews at targetIdx. + segmentRects(order) { + const segs = this.timeline.segments; + const ord = order ?? this._getEffectiveOrder(); + const ppf = this.pxPerFrame(); + const rects = []; + let cursor = 0; + for (let visualPos = 0; visualPos < ord.length; visualPos++) { + const idx = ord[visualPos]; + const len = segs[idx].length; + rects.push({ index: idx, visualPos, x: cursor * ppf, w: len * ppf, frameStart: cursor, frameEnd: cursor + len }); + cursor += len; + } + return rects; + } + + _getEffectiveOrder() { + const n = this.timeline.segments.length; + const natural = Array.from({ length: n }, (_, i) => i); + if (!this.reorder?.active || this.reorder.targetIdx === this.reorder.sourceIdx) return natural; + const order = natural.filter(i => i !== this.reorder.sourceIdx); + order.splice(this.reorder.targetIdx, 0, this.reorder.sourceIdx); + return order; + } + + // Pick the target slot whose source-slot center is closest to where the dragged block's + // center is currently rendered. This makes swaps trigger based on the block's visible + // overlap (not raw cursor position), so dragOffset and segment-size differences feel natural. + _computeReorderTarget() { + const ppf = this.pxPerFrame(); + const sourceIdx = this.reorder.sourceIdx; + const sourceLen = this.timeline.segments[sourceIdx].length; + const blockCenterFrame = + (this.reorder.cursorX - this.reorder.dragOffsetPx) / ppf + sourceLen / 2; + + const others = []; + for (let i = 0; i < this.timeline.segments.length; i++) { + if (i !== sourceIdx) others.push(this.timeline.segments[i].length); + } + + let bestTarget = 0; + let bestDist = Infinity; + let cum = 0; + for (let target = 0; target <= others.length; target++) { + const sourceCenter = cum + sourceLen / 2; + const dist = Math.abs(blockCenterFrame - sourceCenter); + if (dist < bestDist) { bestDist = dist; bestTarget = target; } + if (target < others.length) cum += others[target]; + } + return bestTarget; + } + + hitBoundary(mx) { + const rects = this.segmentRects(); + for (let i = 0; i < rects.length; i++) { + const right = rects[i].x + rects[i].w; + if (Math.abs(mx - right) <= HANDLE_HIT_PX) return i; + } + return -1; + } + + hitBlock(mx, my) { + if (my < RULER_HEIGHT) return -1; + const rects = this.segmentRects(); + for (const r of rects) { + if (mx >= r.x && mx < r.x + r.w) return r.index; + } + return -1; + } + + // ─── Pointer ─── + + onPointerDown(e) { + const { x, y } = this.localPos(e); + // Any new interaction interrupts a post-reorder settle animation. + this._settling = false; + const handle = this.hitBoundary(x); + if (handle >= 0) { + this.dragHandle = handle; + const segs = this.timeline.segments; + this.dragStart = { + x, + initialLengths: segs.map(s => s.length), + }; + this.canvas.setPointerCapture(e.pointerId); + return; + } + const block = this.hitBlock(x, y); + if (block >= 0) { + this.selectedIndex = block; + this.updateUIFromSelection(); + this.render(); + // dragOffsetPx = where inside the block we clicked, so the block follows the cursor at that offset. + const sourceX = this._displayedX.get(block) ?? 0; + this.reorder = { + sourceIdx: block, targetIdx: block, + startX: x, startY: y, + cursorX: x, + dragOffsetPx: x - sourceX, + active: false, + }; + try { this.canvas.setPointerCapture(e.pointerId); } catch (_) {} + } + } + + onPointerMove(e) { + const { x, y } = this.localPos(e); + if (this.dragHandle >= 0) { + const ppf = this.pxPerFrame(); + const dxFrames = Math.round((x - this.dragStart.x) / ppf); + const handle = this.dragHandle; + const initial = this.dragStart.initialLengths; + this._setLengthShifting(handle, initial[handle] + dxFrames, initial); + + const segs = this.timeline.segments; + this.commit(); + if (segs[this.selectedIndex]) this.lengthInput.value = segs[this.selectedIndex].length; + this.updateTotalLabel(); + this.render(); + return; + } + + if (this.reorder) { + const dx = x - this.reorder.startX; + const dy = y - this.reorder.startY; + if (!this.reorder.active && Math.hypot(dx, dy) > REORDER_THRESHOLD_PX) { + this.reorder.active = true; + this.canvas.style.cursor = "grabbing"; + } + if (this.reorder.active) { + this.reorder.cursorX = x; + this.reorder.targetIdx = this._computeReorderTarget(); + // Render every move so the dragged block tracks the cursor in real time. + this.render(); + return; + } + } + + const handle = this.hitBoundary(x); + const block = handle >= 0 ? -1 : this.hitBlock(x, y); + if (handle !== this.hoverHandle || block !== this.hoverIndex) { + this.hoverHandle = handle; + this.hoverIndex = block; + this.canvas.style.cursor = handle >= 0 ? "ew-resize" : (block >= 0 ? "pointer" : "default"); + this.render(); + } + } + + onPointerUp(e) { + if (this.dragHandle >= 0) { + try { this.canvas.releasePointerCapture(e.pointerId); } catch (_) {} + this.dragHandle = -1; + this.dragStart = null; + } + if (this.reorder) { + try { this.canvas.releasePointerCapture(e.pointerId); } catch (_) {} + if (this.reorder.active) { + this.canvas.style.cursor = "default"; + const { sourceIdx, targetIdx } = this.reorder; + if (sourceIdx !== targetIdx) { + // Remap displayed positions so segments visually stay where they were on release. + // After splice, segment at new index `i` is timeline.segments[effectiveOrder[i]] (pre-splice). + const effective = this._getEffectiveOrder(); + const oldDisplayed = new Map(this._displayedX); + const seg = this.timeline.segments.splice(sourceIdx, 1)[0]; + this.timeline.segments.splice(targetIdx, 0, seg); + this._displayedX = new Map(); + for (let newIdx = 0; newIdx < effective.length; newIdx++) { + const oldIdx = effective[newIdx]; + if (oldDisplayed.has(oldIdx)) this._displayedX.set(newIdx, oldDisplayed.get(oldIdx)); + } + this.selectedIndex = targetIdx; + this.commit(); + this.updateUIFromSelection(); + this._settling = true; // lerp the dragged block from cursor pos to its final slot + } + } + this.reorder = null; + this.render(); + } + } + + localPos(e) { + const rect = this.canvas.getBoundingClientRect(); + // rect is post-CSS-transform; divide by the on-screen-to-CSS scale to get logical coords. + const sx = (rect.width / this.canvas.offsetWidth) || 1; + const sy = (rect.height / this.canvas.offsetHeight) || 1; + return { + x: (e.clientX - rect.left) / sx, + y: (e.clientY - rect.top) / sy, + }; + } + + // ─── Mutations ─── + + addSegment() { + const max = this.getMaxFrames(); + const n = this.timeline.segments.length; + // Refuse only when truly impossible β€” every segment (incl. the new one) at MIN won't fit. + if (max < (n + 1) * MIN_SEGMENT_LENGTH) return; + + const desired = Math.max(MIN_SEGMENT_LENGTH, Math.floor(max / (n + 1))); + const newIdx = n; + const usedColors = new Set(this.timeline.segments.map(s => s.color)); + this.timeline.segments.push({ prompt: "", length: desired, color: pickColor(usedColors) }); + this.trimToFit(newIdx); // shrink other segments to fit; protect the new one + + // If still over (e.g., desired itself was too big), shrink the new one down to its slack. + let total = this.timeline.segments.reduce((a, s) => a + s.length, 0); + if (total > max) { + this.timeline.segments[newIdx].length -= (total - max); + } + + this.selectedIndex = newIdx; + this.commit(); + this.updateUIFromSelection(); + this.updateTotalLabel(); + this.render(); + } + + // Max length segment `idx` can be without pushing total past max_frames. + maxLengthFor(idx) { + const max = this.getMaxFrames(); + let others = 0; + for (let i = 0; i < this.timeline.segments.length; i++) { + if (i !== idx) others += this.timeline.segments[i].length; + } + return Math.max(MIN_SEGMENT_LENGTH, max - others); + } + + // Reset all lengths to `baseline`, set segment `idx` to `newLen`, then borrow from + // subsequent segments (one at a time, down to MIN) if the total exceeds max_frames. + // Used by both boundary drag and the length input so they share identical semantics. + _setLengthShifting(idx, newLen, baseline) { + const segs = this.timeline.segments; + const max = this.getMaxFrames(); + for (let i = 0; i < segs.length; i++) segs[i].length = baseline[i]; + segs[idx].length = Math.max(MIN_SEGMENT_LENGTH, newLen); + let total = segs.reduce((a, s) => a + s.length, 0); + for (let i = idx + 1; i < segs.length && total > max; i++) { + const reducible = segs[i].length - MIN_SEGMENT_LENGTH; + const take = Math.min(reducible, total - max); + segs[i].length -= take; + total -= take; + } + if (total > max) segs[idx].length -= (total - max); + } + + // Trim segments from the end (then second-to-last, etc.) until total fits max_frames. + // protectIndex (optional): a segment to leave alone (e.g. the segment we just added). + // Caller is responsible for committing afterward. + trimToFit(protectIndex = -1) { + const max = this.getMaxFrames(); + let total = this.timeline.segments.reduce((a, s) => a + s.length, 0); + for (let i = this.timeline.segments.length - 1; i >= 0 && total > max; i--) { + if (i === protectIndex) continue; + const seg = this.timeline.segments[i]; + const reducible = seg.length - MIN_SEGMENT_LENGTH; + const take = Math.min(reducible, total - max); + seg.length -= take; + total -= take; + } + } + + // Spread segment lengths evenly across max_frames; leftover frames go to the first segments + // so the total exactly equals max_frames (when feasible). + distributeEvenly() { + const max = this.getMaxFrames(); + const n = this.timeline.segments.length; + if (n === 0) return; + const base = Math.max(MIN_SEGMENT_LENGTH, Math.floor(max / n)); + const remainder = Math.max(0, max - base * n); + for (let i = 0; i < n; i++) { + this.timeline.segments[i].length = base + (i < remainder ? 1 : 0); + } + this.commit(); + this.updateUIFromSelection(); + this.render(); + } + + deleteSelected() { + if (this.timeline.segments.length <= 1) return; + this.timeline.segments.splice(this.selectedIndex, 1); + this.selectedIndex = clamp(this.selectedIndex, 0, this.timeline.segments.length - 1); + this.commit(); + this.updateUIFromSelection(); + this.updateTotalLabel(); + this.render(); + } + + // ─── Persistence ─── + + commit() { + this.syncWidgetsFromTimeline(); + this.node.graph?.setDirtyCanvas?.(true, true); + } + + syncWidgetsFromTimeline() { + const segs = this.timeline.segments; + if (this.timelineDataWidget) this.timelineDataWidget.value = JSON.stringify(this.timeline); + if (this.localPromptsWidget) this.localPromptsWidget.value = segs.map(s => s.prompt).join(" | "); + if (this.segmentLengthsWidget) this.segmentLengthsWidget.value = segs.map(s => s.length).join(", "); + } + + // ─── UI sync ─── + + updateUIFromSelection() { + const seg = this.timeline.segments[this.selectedIndex]; + if (!seg) { + this.textarea.value = ""; + this.lengthInput.value = ""; + } else { + if (this.textarea.value !== seg.prompt) this.textarea.value = seg.prompt; + this.lengthInput.value = seg.length; + } + // Programmatic value change invalidates any in-progress baseline. + this._inputBaseline = null; + this.updateTotalLabel(); + } + + updateTotalLabel() { + const total = this.timeline.segments.reduce((a, s) => a + s.length, 0); + const max = this.getMaxFrames(); + this.totalLabel.textContent = `Total: ${total} / ${max} frames`; + } + + // ─── Render ─── + + render() { + // Compute target X for each segment from the effective (preview) order. + const rects = this.segmentRects(); + this._targetX = new Map(); + for (const r of rects) this._targetX.set(r.index, r.x); + + if (this.reorder?.active) { + // The dragged segment follows the cursor in real time (no lerp). Other segments + // lerp toward their preview-order slots. + const sourceIdx = this.reorder.sourceIdx; + const sourcePos = this.reorder.cursorX - this.reorder.dragOffsetPx; + this._targetX.set(sourceIdx, sourcePos); + this._displayedX.set(sourceIdx, sourcePos); + this._kickAnim(); + } else if (this._settling) { + // Post-release: lerp displayed positions toward their final slots without snapping. + this._kickAnim(); + } else { + // All other state changes (boundary drag, add/delete, length input, resize) snap. + for (const [idx, target] of this._targetX) this._displayedX.set(idx, target); + this._draw(); + } + } + + _kickAnim() { + if (this._animRaf) return; + this._animRaf = requestAnimationFrame(() => this._tick()); + } + + _tick() { + this._animRaf = null; + let needsMore = false; + const speed = 0.15; + for (const [idx, target] of this._targetX) { + const cur = this._displayedX.get(idx); + if (cur === undefined) { this._displayedX.set(idx, target); continue; } + const diff = target - cur; + if (Math.abs(diff) < 0.3) { + this._displayedX.set(idx, target); + } else { + this._displayedX.set(idx, cur + diff * speed); + needsMore = true; + } + } + this._draw(); + // Keep ticking only while there's lerp work to do β€” render() will re-kick when targets change. + if (needsMore) { + this._animRaf = requestAnimationFrame(() => this._tick()); + } else if (!this.reorder?.active) { + // Animation has converged and no reorder is in progress β€” clear settling flag. + this._settling = false; + } + } + + _draw() { + const ctx = this.ctx; + const w = this._cssWidth; + ctx.clearRect(0, 0, w, CANVAS_HEIGHT); + this.drawRuler(ctx, w); + this.drawSegments(ctx, w); + } + + drawRuler(ctx, w) { + const max = this.getMaxFrames(); + ctx.fillStyle = "#222"; + ctx.fillRect(0, 0, w, RULER_HEIGHT); + + const ppf = this.pxPerFrame(); + const targetLabelSpacing = 60; + let step = Math.max(1, Math.round(targetLabelSpacing / ppf)); + const niceSteps = [1, 2, 4, 5, 8, 10, 16, 20, 25, 50, 100]; + for (const s of niceSteps) { if (s >= step) { step = s; break; } } + + ctx.strokeStyle = "#444"; + ctx.fillStyle = "#aaa"; + ctx.font = "10px sans-serif"; + ctx.textBaseline = "top"; + ctx.lineWidth = 1; + + for (let f = 0; f <= max; f += step) { + const x = Math.floor(f * ppf) + 0.5; + ctx.beginPath(); + ctx.moveTo(x, RULER_HEIGHT - 6); + ctx.lineTo(x, RULER_HEIGHT); + ctx.stroke(); + ctx.fillText(String(f), x + 2, 2); + } + // Final tick at max if not aligned + const xMax = Math.floor(max * ppf) - 0.5; + ctx.strokeStyle = "#666"; + ctx.beginPath(); + ctx.moveTo(xMax, 0); + ctx.lineTo(xMax, RULER_HEIGHT); + ctx.stroke(); + } + + drawSegments(ctx, w) { + const rects = this.segmentRects(); + const blockY = RULER_HEIGHT + 2; + const blockH = BLOCK_HEIGHT - 4; + + // Empty timeline background + ctx.fillStyle = "#101010"; + ctx.fillRect(0, blockY, w, blockH); + + // Render in two passes so the dragged segment is on top during reorder. + const dragIdx = this.reorder?.active ? this.reorder.sourceIdx : -1; + const rendered = [...rects].sort((a, b) => (a.index === dragIdx ? 1 : 0) - (b.index === dragIdx ? 1 : 0)); + + for (const r of rendered) { + const seg = this.timeline.segments[r.index]; + const color = seg.color || PALETTE[r.index % PALETTE.length]; + const isSelected = r.index === this.selectedIndex; + const isHover = r.index === this.hoverIndex; + const isDragging = r.index === dragIdx; + + const drawX = Math.floor(this._displayedX.get(r.index) ?? r.x); + const drawW = Math.max(2, Math.floor(r.w)); + + ctx.fillStyle = color; + ctx.globalAlpha = isDragging ? 0.9 : (isSelected ? 1.0 : (isHover ? 0.9 : 0.75)); + ctx.fillRect(drawX, blockY, drawW, blockH); + ctx.globalAlpha = 1.0; + + ctx.strokeStyle = isDragging ? "#ffd54f" : (isSelected ? "#fff" : "rgba(0,0,0,0.4)"); + ctx.lineWidth = isDragging || isSelected ? 2 : 1; + ctx.strokeRect(drawX + 0.5, blockY + 0.5, drawW - 1, blockH - 1); + + // Label: prompt (wrapped to 2 lines) + frame range + ctx.fillStyle = "#fff"; + ctx.font = "11px sans-serif"; + ctx.textBaseline = "top"; + const label = seg.prompt || `(segment ${r.index + 1})`; + const [line1, line2] = this.wrapTwoLines(ctx, label, drawW - 8); + ctx.fillText(line1, drawX + 4, blockY + 4); + if (line2) ctx.fillText(line2, drawX + 4, blockY + 18); + + ctx.fillStyle = "rgba(255,255,255,0.75)"; + ctx.font = "10px monospace"; + const range = `${r.frameStart}–${r.frameEnd} (${seg.length}f)`; + const rangeTrunc = this.truncateText(ctx, range, drawW - 8); + ctx.fillText(rangeTrunc, drawX + 4, blockY + blockH - 14); + } + + // Boundary handles (visible cue) β€” hidden during reorder since they're not interactive then. + if (!this.reorder?.active) { + for (let i = 0; i < rects.length; i++) { + const r = rects[i]; + const drawX = this._displayedX.get(r.index) ?? r.x; + const right = Math.floor(drawX + r.w); + const isHover = i === this.hoverHandle || i === this.dragHandle; + ctx.fillStyle = isHover ? "#fff" : "rgba(255,255,255,0.4)"; + ctx.fillRect(right - 1, blockY + 4, 2, blockH - 8); + } + } + } + + truncateText(ctx, text, maxWidth) { + if (ctx.measureText(text).width <= maxWidth) return text; + let lo = 0, hi = text.length; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (ctx.measureText(text.slice(0, mid) + "…").width <= maxWidth) lo = mid; + else hi = mid - 1; + } + return lo > 0 ? text.slice(0, lo) + "…" : ""; + } + + // Greedy word wrap to at most two lines; line 2 ellipsizes if it still overflows. + wrapTwoLines(ctx, text, maxWidth) { + if (ctx.measureText(text).width <= maxWidth) return [text, ""]; + + const tokens = text.split(/(\s+)/); // alternating words and whitespace + let line1 = ""; + let consumed = 0; + for (let i = 0; i < tokens.length; i++) { + const candidate = line1 + tokens[i]; + if (ctx.measureText(candidate).width > maxWidth) break; + line1 = candidate; + consumed = i + 1; + } + + // First word is wider than maxWidth β€” fall back to char-level ellipsis on one line. + if (!line1.trim()) return [this.truncateText(ctx, text, maxWidth), ""]; + + let line2 = tokens.slice(consumed).join("").trim(); + if (!line2) return [line1.trimEnd(), ""]; + if (ctx.measureText(line2).width > maxWidth) { + line2 = this.truncateText(ctx, line2, maxWidth); + } + return [line1.trimEnd(), line2]; + } + + destroy() { + this.resizeObserver?.disconnect(); + if (this._animRaf) cancelAnimationFrame(this._animRaf); + if (this._textCommitTimer) { + clearTimeout(this._textCommitTimer); + this._textCommitTimer = null; + // Best-effort flush so an in-flight prompt edit doesn't disappear if the user + // removes the node mid-typing (commit is a no-op if widgets are gone). + try { this.commit(); } catch (_) {} + } + } +} + +app.registerExtension({ + name: "PromptRelay.Timeline", + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData?.name !== "PromptRelayEncodeTimeline") return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = onNodeCreated?.apply(this, arguments); + + for (const name of HIDDEN_WIDGET_NAMES) { + hideWidget(this.widgets.find(w => w.name === name)); + } + + const container = document.createElement("div"); + this._timelineWidget = this.addDOMWidget("prompt_relay_timeline", "PromptRelayTimeline", container, { + serialize: false, + hideOnZoom: false, + getMinHeight: () => 220, + getHeight: () => 220, + }); + + // Defer construction until widgets are settled (configure runs after onNodeCreated for saved nodes) + const self = this; + setTimeout(() => { + try { + self._timelineEditor = new TimelineEditor(self, container); + } catch (err) { + console.error("[PromptRelay] timeline editor init failed:", err); + } + }, 0); + + const onRemoved = this.onRemoved; + this.onRemoved = function () { + this._timelineEditor?.destroy(); + return onRemoved?.apply(this, arguments); + }; + + const onConfigure = this.onConfigure; + this.onConfigure = function (info) { + const out = onConfigure?.apply(this, arguments); + // Rebuild from restored widget values + setTimeout(() => { + if (this._timelineEditor) { + this._timelineEditor.timeline = parseInitial( + this._timelineEditor.timelineDataWidget?.value, + this._timelineEditor.getMaxFrames(), + ); + this._timelineEditor.selectedIndex = clamp( + this._timelineEditor.selectedIndex, 0, + this._timelineEditor.timeline.segments.length - 1, + ); + this._timelineEditor.updateUIFromSelection(); + this._timelineEditor.render(); + } + }, 10); + return out; + }; + + return r; + }; + }, +});