Files
ComfyUI_ETK_LTXV_Timeline_E…/web/ltxv_timeline/extension.js
T
2026-08-26 09:55:12 -06:00

569 lines
22 KiB
JavaScript

// 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;
};
},
});