// ComfyUI extension registration and node lifecycle wiring. import { app } from "../../../../scripts/app.js"; import { CANVAS_SIZE_PROPERTY, CONTROL_TAB_PROPERTY, LAYER_LOCK_PROPERTY, OUTPUT_SETTINGS_PROPERTY, UI_INSTANCE_PROPERTY, UPLOAD_MODE_PROPERTY, } from "./constants.js"; import { applyBackendTimelineSettings, installBackendTimelineSettingsListener, isTargetNode, normalizeTimelineSettings, payloadsMatchNode, setTimelineStatus, uiPayloadValue, } from "./runtime.js"; import { activeLayer, controlsTab, deleteActiveLayer, ensureSelectedKeyframe, layerEditingLocked, normalizeKeyframes, normalizePan, preloadImage, preserveExecutedFilterFields, refreshTimeline, rejectLayerEditIfLocked, selectedKeyframe, selectedKeyframeIndex, serializableTimelinePayload, setActiveLayerId, setControlsTab, setSelectedKeyframe, syncTimelineWidget, timelineReloadSource, toggleLayerEditingLock, toggleUploadMode, updateActiveLayer, uploadMode, } from "./model.js"; import { fixPromptWidgetHeights, migrateDisableRelayWidgetOrder, syncChainInheritedWidgets, syncKeyframePromptEditor, syncTextLayerEditor, } from "./editors.js"; import { configuredCanvasSize, rememberTimelineCanvasSize, scheduleTimelineCanvasSizeRestore, setupNode, } from "./node_setup.js"; import { addKeyframeAt, bounds, deleteKeyframeAtIndex, duplicateKeyframeAtIndex, findKeyframeAt, previewImageBox, slotConflict, slotToSeconds, updateTimelineSliderDrag, widgetOutputSettings, xToSlot, } from "./timeline_ops.js"; import { controlTabAt, layerChipAt, previewPlacement, previewSourceForLayer, refreshButtonBounds, } from "./rendering.js"; import { addTextLayer, deleteLayerButtonBounds, imageButtonBounds, installKeyboardShortcuts, installPointerRelease, installWheelCapture, keyframeStrengthSliderAt, layerFilterSliderAt, lockLayerButtonBounds, modeButtonBounds, releaseDrag, textLayerButtonBounds, uploadModeButtonBounds, zoomPreviewAt, } from "./interaction.js"; import { installClipboardPasteCapture, installFileDropCapture } from "./media.js"; export function selectedKeyframeIndexForMenu(node) { setupNode(node); return selectedKeyframeIndex(node, normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node)); } export function timelineNodeMenuItems(node) { if (!isTargetNode(node)) return []; return [ null, { content: "ETK Timeline: Refresh previews", callback: () => { setupNode(node); refreshTimeline(node); }, }, { content: `ETK Timeline: Upload mode: ${uploadMode(node) === "layer" ? "add layer" : "replace"}`, callback: () => { setupNode(node); toggleUploadMode(node); }, }, { content: "ETK Timeline: Upload image to selected keyframe...", callback: () => { setupNode(node); const keyframe = ensureSelectedKeyframe(node); if (!keyframe) return; if (node.etkTimelineFileInput) node.etkTimelineFileInput.click(); }, }, { content: "ETK Timeline: Duplicate selected keyframe", callback: () => { const index = selectedKeyframeIndexForMenu(node); if (index < 0 || !duplicateKeyframeAtIndex(node, index)) { setTimelineStatus(node, "Select a keyframe to duplicate.", "error"); } }, }, { content: "ETK Timeline: Delete selected keyframe", callback: () => { const index = selectedKeyframeIndexForMenu(node); if (index < 0 || !deleteKeyframeAtIndex(node, index)) { setTimelineStatus(node, "Select a keyframe to delete.", "error"); } }, }, ]; } app.registerExtension({ name: "ETK.LTXVTimelineImageEditor", getNodeMenuItems(node) { return timelineNodeMenuItems(node); }, beforeRegisterNodeDef(nodeType, nodeData) { if (!isTargetNode(nodeData.name)) return; if (nodeType.prototype.etkTimelinePatched) return; nodeType.prototype.etkTimelinePatched = true; installBackendTimelineSettingsListener(); installWheelCapture(); installPointerRelease(); installKeyboardShortcuts(); installFileDropCapture(); installClipboardPasteCapture(); const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; nodeType.prototype.getExtraMenuOptions = function (canvas, options) { const extra = originalGetExtraMenuOptions ? originalGetExtraMenuOptions.apply(this, arguments) : undefined; const timelineItems = timelineNodeMenuItems(this); if (Array.isArray(extra)) return extra.concat(timelineItems); if (Array.isArray(options)) options.push(...timelineItems); return extra; }; const originalOnNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { if (originalOnNodeCreated) originalOnNodeCreated.apply(this, arguments); setupNode(this); scheduleTimelineCanvasSizeRestore(this); }; const originalOnConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function (config) { if (originalOnConfigure) originalOnConfigure.apply(this, arguments); if (!this.properties) this.properties = {}; const migratedWidgetOrder = migrateDisableRelayWidgetOrder(this); this.properties.etk_ltxv_timeline_keyframes = normalizeKeyframes(timelineReloadSource(this, migratedWidgetOrder), this); setupNode(this); scheduleTimelineCanvasSizeRestore(this, configuredCanvasSize(config)); selectedKeyframeIndex(this, this.properties.etk_ltxv_timeline_keyframes); }; const originalOnSerialize = nodeType.prototype.onSerialize; nodeType.prototype.onSerialize = function (serialized) { if (originalOnSerialize) originalOnSerialize.apply(this, arguments); if (!serialized) return; setupNode(this); const keyframes = normalizeKeyframes(this.properties.etk_ltxv_timeline_keyframes, this); this.properties.etk_ltxv_timeline_keyframes = keyframes; syncTimelineWidget(this); if (!serialized.properties) serialized.properties = {}; serialized.properties.etk_ltxv_timeline_keyframes = serializableTimelinePayload(this, keyframes); serialized.properties.etk_ltxv_selected_keyframe = this.properties.etk_ltxv_selected_keyframe; serialized.properties.etk_ltxv_selected_keyframe_id = this.properties.etk_ltxv_selected_keyframe_id; serialized.properties[UPLOAD_MODE_PROPERTY] = uploadMode(this); serialized.properties[LAYER_LOCK_PROPERTY] = layerEditingLocked(this); serialized.properties[CONTROL_TAB_PROPERTY] = controlsTab(this); const outputSettings = normalizeTimelineSettings(this.etkTimelineBackendSettings || this.properties[OUTPUT_SETTINGS_PROPERTY]) || widgetOutputSettings(this); if (outputSettings) { serialized.properties[OUTPUT_SETTINGS_PROPERTY] = outputSettings; } const canvasSize = rememberTimelineCanvasSize(this); if (canvasSize) { serialized.size = [...canvasSize]; serialized.properties[CANVAS_SIZE_PROPERTY] = [...canvasSize]; } delete serialized.properties[UI_INSTANCE_PROPERTY]; }; const originalOnExecuted = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function (message) { if (originalOnExecuted) originalOnExecuted.apply(this, arguments); const payload = uiPayloadValue(message, "etk_ltxv_timeline_keyframes"); const settingsPayload = uiPayloadValue(message, "etk_ltxv_timeline_settings"); if (!payload && !settingsPayload) return; if (!payloadsMatchNode(this, [payload, settingsPayload])) return; setupNode(this); applyBackendTimelineSettings(this, settingsPayload); if (payload) { const keyframes = preserveExecutedFilterFields(this, payload); this.properties.etk_ltxv_timeline_keyframes = keyframes; const selectedIndex = selectedKeyframeIndex(this, keyframes); if (selectedIndex < 0 && keyframes.length) { setSelectedKeyframe(this, 0, keyframes); } else { syncKeyframePromptEditor(this, selectedIndex >= 0 ? keyframes[selectedIndex] : null); syncTextLayerEditor(this); } for (const keyframe of keyframes) preloadImage(this, keyframe); } syncTimelineWidget(this); this.setDirtyCanvas(true, true); if (this.graph && typeof this.graph.setDirtyCanvas === "function") { this.graph.setDirtyCanvas(true, true); } }; const originalOnRemoved = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function () { releaseDrag(this); if (this.etkTimelineFileInput) { this.etkTimelineFileInput.remove(); this.etkTimelineFileInput = null; } if (originalOnRemoved) originalOnRemoved.apply(this, arguments); }; const originalComputeSize = nodeType.prototype.computeSize; nodeType.prototype.computeSize = function () { fixPromptWidgetHeights(this); const size = originalComputeSize ? originalComputeSize.apply(this, arguments) : [360, 260]; size[0] = Math.max(size[0], 460); return size; }; const originalOnResize = nodeType.prototype.onResize; nodeType.prototype.onResize = function (size) { fixPromptWidgetHeights(this); syncChainInheritedWidgets(this); if (originalOnResize) originalOnResize.apply(this, arguments); if (!size) return; size[0] = Math.max(size[0], 460); rememberTimelineCanvasSize(this, size); if (this.setDirtyCanvas) this.setDirtyCanvas(true, true); }; const originalOnConnectionsChange = nodeType.prototype.onConnectionsChange; nodeType.prototype.onConnectionsChange = function () { if (originalOnConnectionsChange) originalOnConnectionsChange.apply(this, arguments); setupNode(this); syncChainInheritedWidgets(this); if (this.setDirtyCanvas) this.setDirtyCanvas(true, true); }; const handleTimelineDoubleClick = function () { const pos = Array.from(arguments).find((arg) => Array.isArray(arg) && arg.length >= 2); if (!pos) return false; setupNode(this); const { padX, width, timelineY, timelineH } = bounds(this); const [x, y] = pos; if (x < padX || x > padX + width || y < timelineY || y > timelineY + timelineH) return false; if (findKeyframeAt(this, pos) >= 0) return false; addKeyframeAt(this, x); return true; }; const originalMouseDblClick = nodeType.prototype.onMouseDblClick; nodeType.prototype.onMouseDblClick = function (_event, pos) { if (handleTimelineDoubleClick.apply(this, arguments)) return true; return originalMouseDblClick ? originalMouseDblClick.apply(this, arguments) : false; }; const originalDblClick = nodeType.prototype.onDblClick; nodeType.prototype.onDblClick = function () { if (handleTimelineDoubleClick.apply(this, arguments)) return true; return originalDblClick ? originalDblClick.apply(this, arguments) : false; }; const originalMouseDown = nodeType.prototype.onMouseDown; nodeType.prototype.onMouseDown = function (event, pos) { if (event && event.button != null && event.button !== 0) { return originalMouseDown ? originalMouseDown.apply(this, arguments) : false; } setupNode(this); const keyframes = normalizeKeyframes(this.properties.etk_ltxv_timeline_keyframes, this); const hitIndex = findKeyframeAt(this, pos); const { padX, width, timelineY, timelineH, controlsY, controlsH, previewY, previewH } = bounds(this); const [x, y] = pos; const inControls = x >= padX && x <= padX + width && y >= controlsY && y <= controlsY + controlsH; const inPreview = x >= padX && x <= padX + width && y >= previewY && y <= previewY + previewH; const refreshButton = refreshButtonBounds(this); if ( x >= refreshButton.x && x <= refreshButton.x + refreshButton.w && y >= refreshButton.y && y <= refreshButton.y + refreshButton.h ) { refreshTimeline(this); return true; } if (hitIndex >= 0) { setSelectedKeyframe(this, hitIndex, keyframes); this.etkTimelineDragging = true; this.etkTimelineDraggingId = keyframes[hitIndex].id; window.etkTimelineActiveDragNode = this; this.setDirtyCanvas(true, true); return true; } if (x >= padX && x <= padX + width && y >= timelineY && y <= timelineY + timelineH) { const now = performance.now(); const isDoubleClick = (event && event.detail && event.detail >= 2) || (this.etkTimelineLastClick && now - this.etkTimelineLastClick < 350); if (isDoubleClick) { addKeyframeAt(this, x); this.etkTimelineLastClick = 0; } else { this.etkTimelineLastClick = now; this.setDirtyCanvas(true, true); } return true; } if (inControls || inPreview) { if (inControls) { const tab = controlTabAt(this, x, y); if (tab) { setControlsTab(this, tab); return true; } } const selected = selectedKeyframe(this); if (selected) { const activeControlTab = controlsTab(this); if (inControls && activeControlTab === "layers") { const button = modeButtonBounds(this); const imageButton = imageButtonBounds(this); const textButton = textLayerButtonBounds(this); const uploadButton = uploadModeButtonBounds(this); const lockLayerButton = lockLayerButtonBounds(this); const deleteLayerButton = deleteLayerButtonBounds(this); if (x >= lockLayerButton.x && x <= lockLayerButton.x + lockLayerButton.w && y >= lockLayerButton.y && y <= lockLayerButton.y + lockLayerButton.h) { toggleLayerEditingLock(this); return true; } if (x >= button.x && x <= button.x + button.w && y >= button.y && y <= button.y + button.h) { if (rejectLayerEditIfLocked(this, "Fit mode change")) return true; const layer = activeLayer(selected); if (layer) { updateActiveLayer(this, selected.id, (current) => ({ ...current, fitMode: current.fitMode === "crop" ? "pad" : "crop", })); } else { selected.fitMode = selected.fitMode === "crop" ? "pad" : "crop"; const selectedIndex = selectedKeyframeIndex(this, keyframes); if (selectedIndex < 0) return true; keyframes[selectedIndex] = selected; this.properties.etk_ltxv_timeline_keyframes = keyframes; syncTimelineWidget(this); this.setDirtyCanvas(true, true); } return true; } if (x >= imageButton.x && x <= imageButton.x + imageButton.w && y >= imageButton.y && y <= imageButton.y + imageButton.h) { if (rejectLayerEditIfLocked(this, "Image upload")) return true; this.etkTimelineFileInput.click(); return true; } if (x >= textButton.x && x <= textButton.x + textButton.w && y >= textButton.y && y <= textButton.y + textButton.h) { addTextLayer(this, selected.id); return true; } if (x >= uploadButton.x && x <= uploadButton.x + uploadButton.w && y >= uploadButton.y && y <= uploadButton.y + uploadButton.h) { if (rejectLayerEditIfLocked(this, "Upload mode change")) return true; toggleUploadMode(this); return true; } if ( x >= deleteLayerButton.x && x <= deleteLayerButton.x + deleteLayerButton.w && y >= deleteLayerButton.y && y <= deleteLayerButton.y + deleteLayerButton.h ) { deleteActiveLayer(this, selected.id); return true; } const chip = layerChipAt(this, selected, x, y); if (chip) { setActiveLayerId(this, selected.id, chip.layerId); return true; } } else if (inControls && activeControlTab === "filters") { const layer = activeLayer(selected); const filterSlider = layer ? layerFilterSliderAt(this, x, y) : null; if (layer && filterSlider) { this.etkSliderDragging = { family: "filter", keyframeId: selected.id, layerId: layer.id, kind: filterSlider.kind }; window.etkTimelineActiveDragNode = this; updateTimelineSliderDrag(this, pos); return true; } } else if (inControls && activeControlTab === "strength") { const strengthSlider = keyframeStrengthSliderAt(this, x, y); if (strengthSlider) { this.etkSliderDragging = { family: "strength", keyframeId: selected.id, kind: strengthSlider.kind }; window.etkTimelineActiveDragNode = this; updateTimelineSliderDrag(this, pos); return true; } } const layer = activeLayer(selected); if (layer && inPreview) { if (layerEditingLocked(this)) { rejectLayerEditIfLocked(this, "Layer pan"); return true; } const { boxX, boxY, boxW, boxH } = previewImageBox(this); if (x >= boxX && x <= boxX + boxW && y >= boxY && y <= boxY + boxH) { this.etkPreviewPanning = { keyframeId: selected.id, layerId: layer.id, startX: x, startY: y, startPanX: normalizePan(layer.panX), startPanY: normalizePan(layer.panY), }; window.etkTimelineActiveDragNode = this; return true; } } else if (!layer && inPreview) { if (rejectLayerEditIfLocked(this, "Image upload")) return true; this.etkTimelineFileInput.click(); } return true; } } return originalMouseDown ? originalMouseDown.apply(this, arguments) : false; }; const originalMouseMove = nodeType.prototype.onMouseMove; nodeType.prototype.onMouseMove = function (event, pos) { if (this.etkSliderDragging) { if (event && event.buttons === 0) { releaseDrag(this); return true; } updateTimelineSliderDrag(this, pos); return true; } if (this.etkPreviewPanning) { if (event && event.buttons === 0) { releaseDrag(this); return true; } const keyframes = normalizeKeyframes(this.properties.etk_ltxv_timeline_keyframes, this); const keyframe = keyframes.find((candidate) => candidate.id === this.etkPreviewPanning.keyframeId); const layer = keyframe && (keyframe.layers || []).find((candidate) => candidate.id === this.etkPreviewPanning.layerId); const image = layer ? previewSourceForLayer(this, layer) : null; if (keyframe && layer && image && image.complete && image.naturalWidth > 0 && image.naturalHeight > 0) { const { boxW, boxH } = previewImageBox(this); const placement = previewPlacement( image, boxW, boxH, layer.fitMode, layer.panX, layer.panY, layer.zoom ); const dx = pos[0] - this.etkPreviewPanning.startX; const dy = pos[1] - this.etkPreviewPanning.startY; const nextPanX = placement.panRangeX > 0 ? this.etkPreviewPanning.startPanX + dx / (placement.panRangeX / 2) : 0; const nextPanY = placement.panRangeY > 0 ? this.etkPreviewPanning.startPanY + dy / (placement.panRangeY / 2) : 0; updateActiveLayer(this, keyframe.id, (current) => ({ ...current, panX: normalizePan(nextPanX), panY: normalizePan(nextPanY), })); } return true; } if (this.etkTimelineDragging) { if (event && event.buttons === 0) { releaseDrag(this); return true; } const keyframes = normalizeKeyframes(this.properties.etk_ltxv_timeline_keyframes, this); const draggingId = this.etkTimelineDraggingId || this.properties.etk_ltxv_selected_keyframe_id; const index = draggingId ? keyframes.findIndex((keyframe) => keyframe.id === draggingId) : selectedKeyframeIndex(this, keyframes); if (index >= 0 && index < keyframes.length) { const slot = xToSlot(this, pos[0]); if (slotConflict(this, slot, index) < 0) { const selected = keyframes[index]; selected.slot = slot; selected.time = slotToSeconds(this, slot); keyframes.sort((a, b) => a.time - b.time || a.slot - b.slot); this.properties.etk_ltxv_timeline_keyframes = keyframes; setSelectedKeyframe(this, keyframes.findIndex((keyframe) => keyframe.id === selected.id), keyframes); syncTimelineWidget(this); } this.setDirtyCanvas(true, true); } return true; } return originalMouseMove ? originalMouseMove.apply(this, arguments) : false; }; const originalMouseWheel = nodeType.prototype.onMouseWheel; nodeType.prototype.onMouseWheel = function () { const pos = Array.from(arguments).find((arg) => Array.isArray(arg) && arg.length >= 2); const event = Array.from(arguments).find((arg) => arg && typeof arg === "object" && !Array.isArray(arg)); if (zoomPreviewAt(this, event, pos)) { if (event && event.preventDefault) event.preventDefault(); if (event && event.stopPropagation) event.stopPropagation(); return true; } return originalMouseWheel ? originalMouseWheel.apply(this, arguments) : false; }; const originalMouseUp = nodeType.prototype.onMouseUp; nodeType.prototype.onMouseUp = function () { if (releaseDrag(this)) return true; return originalMouseUp ? originalMouseUp.apply(this, arguments) : false; }; }, });