How to install
- Setup differs for this server — follow the Installation part of the README below.
- Claude Code:
claude mcp add <name> -- <command>. - Claude Desktop / Cursor: add it under
mcpServersin the MCP config file.
npx degit artokun/comfyui-mcp/plugin/skills/ltx-director#main ~/.claude/skills/ltx-directorFor one project only, change the path to .claude/skills/ltx-director.
This one runs on your machine and can reach your files. Read the README below before you connect it.
Not working?
- Check which app you pasted it into — the steps above name the right one.
- Some skills need the paid tier of Claude or ChatGPT.
Paste into Claude, ChatGPT or Cursor.
Show the full text183 lines
LTX Director (Timeline)
LTXDirector (pack: WhatDreamsCost-ComfyUI, category WhatDreamsCost) is a
video timeline editor node. Its on-canvas UI has Add Image, Add Text
and Add Audio buttons.
You cannot click those buttons. Don't try.
They are not node inputs. The pack builds them as raw DOM elements with JS handlers:
addTextBtn.addEventListener("click", () => this.addTextSegmentFreeSpace());
Panel/MCP tools drive the LiteGraph node model (widgets + inputs). They cannot invoke arbitrary DOM handlers, so "click Add Text" is impossible. This is a hard limitation, not flakiness. Say so plainly rather than retrying.
The real control surface: timeline_data
Everything those buttons do is serialized into ONE string widget, timeline_data.
The pack's own code treats it as "the absolute source of truth". Set it and the
editor renders it.
It is listed in the pack's HIDDEN_WIDGET_NAMES, so it does not appear in the
node's visible widget list, but it is an ordinary input and is settable:
create_workflow(action="modify", workflow=<the graph>, operations=[
{ op: "set_input", node_id: "42",
input_name: "timeline_data", value: "<json string>" },
])
The value is a JSON string, not an object. Empty state is "{}".
Sibling hidden widgets: local_prompts, segment_lengths, guide_strength,
audio_data, use_custom_audio, inpaint_audio, use_custom_motion,
override_audio.
Schema (verified against a real production workflow)
{
// TRACK GATES — a track's segments are IGNORED unless its gate is true.
"mainTrackEnabled": true,
"audioTrackEnabled": false,
"motionTrackEnabled": false,
// must mirror the node's start_frame / duration_frames widgets (see Edges)
"normalStartFrame": 0,
"normalDurationFrames": 4393,
"global_prompt": "",
"overrideAudio": false,
"inpaint_audio": false,
// retake mode
"retakeMode": false, "retakeStart": 24, "retakeLength": 48,
"retakePrompt": "", "retakeStrength": 1, "retakeVideo": null,
"retake_global_prompt": "",
// display-only
"propHeight": 90, "globalPropHeight": 60, "showFilenames": true,
"segments": [], // main track: image AND text segments
"motionSegments": [],
"audioSegments": []
}
Text segment (segments)
{ "id": "seg1", "start": 0, "length": 48, "prompt": "wide shot, neon city", "type": "text" }
Image segment (segments)
{
"id": "1784657553220x4fxu",
"start": 0,
"length": 241.68,
"prompt": "",
"type": "image",
"imageFile": "whatdreamscost/Nintendo_ZeldaUrbosaHotspringKF_00002_.png",
"imageB64": "/api/view?filename=Nintendo_ZeldaUrbosaHotspringKF_00002_.png&type=input&subfolder=whatdreamscost"
}
imageB64 is a misnomer. It holds a /api/view URL, not base64. So an
image segment points at a file already in ComfyUI's input dir. Full
agent-drivable recipe:
upload_image (action:"image")→ puts the file in the input dir (note itssubfolder/name)imageFile="<subfolder>/<name>.png"imageB64="/api/view?filename=<name>.png&type=input&subfolder=<subfolder>"
Audio segment (audioSegments)
{
"id": "17846562234967gjvk", "type": "audio",
"start": 0, "length": 4393, "trimStart": 0, "audioDurationFrames": 4393,
"audioFile": "whatdreamscost/Intergalactic Hip Hop.mp3",
"fileName": "Intergalactic Hip Hop.mp3",
"waveformPeaks": [0.047, 0.274, 0.537, "…~200 floats…"]
}
waveformPeaks is the rendered waveform. It is cosmetic. Omit it or pass [] if
you're writing a segment programmatically; the audio still plays. Don't fabricate
plausible-looking peaks and imply they were measured.
Edges (get these wrong and it silently does nothing)
- Track gates. Pushing into
audioSegmentsdoes nothing whileaudioTrackEnabledisfalse. Set the gate in the same edit. (The reference workflow ships an audio segment with the track OFF, which is easy to misread as broken.) - Frame bookkeeping must agree.
normalDurationFrames==duration_frameswidget ==segment_lengthswidget (observed: all4393), andnormalStartFrame==start_frame. Change the timeline length and you must update all of them. - Frames, not seconds.
start/lengthare pixel-space frames and may be fractional (241.68).duration_seconds/frame_rate/time_unitsare display concerns. global_promptexists twice, insidetimeline_dataand as aforceInputsocket on the node. In the reference workflow the socket is wired from aPrimitiveStringMultiline; prefer the wired source and keep them consistent.guide_datamust go somewhere.LTXDirector.guide_data→LTXDirectorGuide. That partner node is what injects the keyframes.- Unsupported by the pack. The widget tooltip says "auto-managed; do not edit by hand". Well-formed blobs load fine; malformed JSON will break the editor. Round-trip and re-read after writing, and tell the user this is unofficial.
Reference wiring (from a working LTX 2.3 Director graph)
Power Lora Loader (rgthree) ─ model ─┐
DualCLIPLoader ──────────── clip ────┤
VAELoaderKJ ───────────── audio_vae ─┤ LTXDirector #42
PrimitiveStringMultiline ─ global_prompt ─┘
│ model → LTX2_NAG
│ positive → ConditioningZeroOut, LTXVConditioning
│ audio_latent → LTXVConcatAVLatent
│ guide_data → LTXDirectorGuide ← required partner
│ frame_rate → AudioToFrameCount, VHS_VideoCombine
└ combined_audio→ AudioToFrameCount, PreviewAudio, …
Procedure
- Find the node:
get_workflow (action:"query")withtypes: ["LTXDirector"],fields: "detail"(orpanel_query_graphon the live canvas). - Read the current
timeline_dataandJSON.parseit. Never hand-splice the string. - Mutate the parsed object (append a segment, flip a track gate, retime).
- Keep
normalStartFrame/normalDurationFramesin sync with the widgets (edge 2). - Write it back as a string via
set_input. - Re-read to confirm it round-trips, then
create_workflow (action:"validate").
Related
PromptRelayEncodeTimeline (pack: ComfyUI-PromptRelay) carries the same
timeline_data hidden-widget pattern, so this skill's approach applies there too.
Any node whose controls are DOM widgets over hidden state is invisible to clicking and must be driven through its state widget. LTX Director is the clearest example.
Sources
- Official: none found.
- Empirical: timeline_data schema and edge rules from a working LTX 2.3 Director graph.
| 1 | |
| 2 | name ltx-director |
| 3 | description Drive the LTX Director (Timeline) node. Its Add Image/Text/Audio buttons are DOM-only and cannot be clicked by an agent; edit the hidden timeline_data JSON widget instead. Load when a workflow contains LTXDirector / LTXDirectorGuide / PromptRelayEncodeTimeline, or when asked to add, move, retime, or remove timeline segments (image / text / audio / motion). |
| 4 | |
| 5 | |
| 6 | # LTX Director (Timeline) |
| 7 | |
| 8 | `LTXDirector` (pack: **WhatDreamsCost-ComfyUI**, category `WhatDreamsCost`) is a |
| 9 | video *timeline editor* node. Its on-canvas UI has **Add Image**, **Add Text** |
| 10 | and **Add Audio** buttons. |
| 11 | |
| 12 | ## You cannot click those buttons. Don't try. |
| 13 | |
| 14 | They are not node inputs. The pack builds them as raw DOM elements with JS |
| 15 | handlers: |
| 16 | |
| 17 | |
| 18 | addTextBtn.addEventListener("click", () => this.addTextSegmentFreeSpace()); |
| 19 | |
| 20 | |
| 21 | Panel/MCP tools drive the LiteGraph **node model** (widgets + inputs). They |
| 22 | cannot invoke arbitrary DOM handlers, so "click Add Text" is impossible. This |
| 23 | is a hard limitation, not flakiness. Say so plainly rather than retrying. |
| 24 | |
| 25 | ## The real control surface: `timeline_data` |
| 26 | |
| 27 | Everything those buttons do is serialized into ONE string widget, `timeline_data`. |
| 28 | The pack's own code treats it as *"the absolute source of truth"*. Set it and the |
| 29 | editor renders it. |
| 30 | |
| 31 | It is listed in the pack's `HIDDEN_WIDGET_NAMES`, so it does **not** appear in the |
| 32 | node's visible widget list, but it is an ordinary input and is settable: |
| 33 | |
| 34 | |
| 35 | create_workflow(action="modify", workflow=<the graph>, operations=[ |
| 36 | { op: "set_input", node_id: "42", |
| 37 | input_name: "timeline_data", value: "<json string>" }, |
| 38 | ]) |
| 39 | |
| 40 | |
| 41 | The value is a JSON **string**, not an object. Empty state is `"{}"`. |
| 42 | |
| 43 | Sibling hidden widgets: `local_prompts`, `segment_lengths`, `guide_strength`, |
| 44 | `audio_data`, `use_custom_audio`, `inpaint_audio`, `use_custom_motion`, |
| 45 | `override_audio`. |
| 46 | |
| 47 | ## Schema (verified against a real production workflow) |
| 48 | |
| 49 | |
| 50 | { |
| 51 | // TRACK GATES — a track's segments are IGNORED unless its gate is true. |
| 52 | "mainTrackEnabled": true, |
| 53 | "audioTrackEnabled": false, |
| 54 | "motionTrackEnabled": false, |
| 55 | |
| 56 | // must mirror the node's start_frame / duration_frames widgets (see Edges) |
| 57 | "normalStartFrame": 0, |
| 58 | "normalDurationFrames": 4393, |
| 59 | |
| 60 | "global_prompt": "", |
| 61 | "overrideAudio": false, |
| 62 | "inpaint_audio": false, |
| 63 | |
| 64 | // retake mode |
| 65 | "retakeMode": false, "retakeStart": 24, "retakeLength": 48, |
| 66 | "retakePrompt": "", "retakeStrength": 1, "retakeVideo": null, |
| 67 | "retake_global_prompt": "", |
| 68 | |
| 69 | // display-only |
| 70 | "propHeight": 90, "globalPropHeight": 60, "showFilenames": true, |
| 71 | |
| 72 | "segments": [], // main track: image AND text segments |
| 73 | "motionSegments": [], |
| 74 | "audioSegments": [] |
| 75 | } |
| 76 | |
| 77 | |
| 78 | ### Text segment (`segments`) |
| 79 | |
| 80 | |
| 81 | { "id": "seg1", "start": 0, "length": 48, "prompt": "wide shot, neon city", "type": "text" } |
| 82 | |
| 83 | |
| 84 | ### Image segment (`segments`) |
| 85 | |
| 86 | |
| 87 | { |
| 88 | "id": "1784657553220x4fxu", |
| 89 | "start": 0, |
| 90 | "length": 241.68, |
| 91 | "prompt": "", |
| 92 | "type": "image", |
| 93 | "imageFile": "whatdreamscost/Nintendo_ZeldaUrbosaHotspringKF_00002_.png", |
| 94 | "imageB64": "/api/view?filename=Nintendo_ZeldaUrbosaHotspringKF_00002_.png&type=input&subfolder=whatdreamscost" |
| 95 | } |
| 96 | |
| 97 | |
| 98 | **`imageB64` is a misnomer. It holds a `/api/view` URL, not base64.** So an |
| 99 | image segment points at a file already in ComfyUI's **input** dir. Full |
| 100 | agent-drivable recipe: |
| 101 | |
| 102 | `upload_image (action:"image")` → puts the file in the input dir (note its `subfolder`/name) |
| 103 | `imageFile` = `"<subfolder>/<name>.png"` |
| 104 | `imageB64` = `"/api/view?filename=<name>.png&type=input&subfolder=<subfolder>"` |
| 105 | |
| 106 | ### Audio segment (`audioSegments`) |
| 107 | |
| 108 | |
| 109 | { |
| 110 | "id": "17846562234967gjvk", "type": "audio", |
| 111 | "start": 0, "length": 4393, "trimStart": 0, "audioDurationFrames": 4393, |
| 112 | "audioFile": "whatdreamscost/Intergalactic Hip Hop.mp3", |
| 113 | "fileName": "Intergalactic Hip Hop.mp3", |
| 114 | "waveformPeaks": [0.047, 0.274, 0.537, "…~200 floats…"] |
| 115 | } |
| 116 | |
| 117 | |
| 118 | `waveformPeaks` is the rendered waveform. It is cosmetic. Omit it or pass `[]` if |
| 119 | you're writing a segment programmatically; the audio still plays. Don't fabricate |
| 120 | plausible-looking peaks and imply they were measured. |
| 121 | |
| 122 | ## Edges (get these wrong and it silently does nothing) |
| 123 | |
| 124 | **Track gates.** Pushing into `audioSegments` does nothing while |
| 125 | `audioTrackEnabled` is `false`. Set the gate in the same edit. (The reference |
| 126 | workflow ships an audio segment with the track OFF, which is easy to misread |
| 127 | as broken.) |
| 128 | **Frame bookkeeping must agree.** `normalDurationFrames` == |
| 129 | `duration_frames` widget == `segment_lengths` widget (observed: all `4393`), |
| 130 | and `normalStartFrame` == `start_frame`. Change the timeline length and you |
| 131 | must update all of them. |
| 132 | **Frames, not seconds.** `start` / `length` are pixel-space frames and may be |
| 133 | fractional (`241.68`). `duration_seconds` / `frame_rate` / `time_units` are |
| 134 | display concerns. |
| 135 | **`global_prompt` exists twice**, inside `timeline_data` and as a `forceInput` |
| 136 | socket on the node. In the reference workflow the socket is wired from a |
| 137 | `PrimitiveStringMultiline`; prefer the wired source and keep them consistent. |
| 138 | **`guide_data` must go somewhere.** `LTXDirector.guide_data` → |
| 139 | `LTXDirectorGuide`. That partner node is what injects the keyframes. |
| 140 | **Unsupported by the pack.** The widget tooltip says *"auto-managed; do not |
| 141 | edit by hand"*. Well-formed blobs load fine; malformed JSON will break the |
| 142 | editor. Round-trip and re-read after writing, and tell the user this is |
| 143 | unofficial. |
| 144 | |
| 145 | ## Reference wiring (from a working LTX 2.3 Director graph) |
| 146 | |
| 147 | |
| 148 | Power Lora Loader (rgthree) ─ model ─┐ |
| 149 | DualCLIPLoader ──────────── clip ────┤ |
| 150 | VAELoaderKJ ───────────── audio_vae ─┤ LTXDirector #42 |
| 151 | PrimitiveStringMultiline ─ global_prompt ─┘ |
| 152 | │ model → LTX2_NAG |
| 153 | │ positive → ConditioningZeroOut, LTXVConditioning |
| 154 | │ audio_latent → LTXVConcatAVLatent |
| 155 | │ guide_data → LTXDirectorGuide ← required partner |
| 156 | │ frame_rate → AudioToFrameCount, VHS_VideoCombine |
| 157 | └ combined_audio→ AudioToFrameCount, PreviewAudio, … |
| 158 | |
| 159 | |
| 160 | ## Procedure |
| 161 | |
| 162 | Find the node: `get_workflow (action:"query")` with `types: ["LTXDirector"]`, `fields: "detail"` |
| 163 | (or `panel_query_graph` on the live canvas). |
| 164 | Read the current `timeline_data` and `JSON.parse` it. **Never** hand-splice the string. |
| 165 | Mutate the parsed object (append a segment, flip a track gate, retime). |
| 166 | Keep `normalStartFrame` / `normalDurationFrames` in sync with the widgets (edge 2). |
| 167 | Write it back as a string via `set_input`. |
| 168 | Re-read to confirm it round-trips, then `create_workflow (action:"validate")`. |
| 169 | |
| 170 | ## Related |
| 171 | |
| 172 | `PromptRelayEncodeTimeline` (pack: ComfyUI-PromptRelay) carries the **same** |
| 173 | `timeline_data` hidden-widget pattern, so this skill's approach applies there too. |
| 174 | |
| 175 | Any node whose controls are DOM widgets over hidden state is invisible to |
| 176 | clicking and must be driven through its state widget. LTX Director is the |
| 177 | clearest example. |
| 178 | |
| 179 | ## Sources |
| 180 | |
| 181 | **Official:** none found. |
| 182 | **Empirical:** timeline_data schema and edge rules from a working LTX 2.3 Director graph. |
| 183 |