Release standalone timeline editor 1.0.0

This commit is contained in:
Hopping Mad Games
2026-08-26 09:55:12 -06:00
commit 869537cefd
27 changed files with 7739 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# ETK LTXV Timeline Image Editor
Build an LTXV/LTX 2.x image-guide timeline with composited image and text
layers, per-keyframe prompts, strengths, and frame positions.
## Inputs
- `width`, `height`: Output image dimensions, rounded to multiples of 32.
- `length`: Video frame count. LTX-compatible values are one plus a multiple
of eight.
- `timeline_json`: Serialized editor state. The browser editor maintains this
value automatically.
- `fps`: Timeline frame rate used to convert frames to seconds.
- `images`: Optional connected image batch used to populate keyframes.
- `prompts`, `strengths`, `times`: Optional aligned lists that replace the
corresponding keyframe metadata by index.
## Outputs
- `edited_images`: Composited RGB keyframe images.
- `unedited_images`: Matching RGBA source images before editor effects.
- `width`, `height`, `length`: Normalized timeline dimensions.
- `strengths`: Per-keyframe image-guide strengths.
- `frame_indexes`: Video-frame positions.
- `position_seconds`: Positions derived from the frame rate.
- `prompts`: Per-keyframe positive prompts.
The node performs image editing only. It does not load or execute a model,
text encoder, CLIP, or VAE.
+12
View File
@@ -0,0 +1,12 @@
import "./ltxv_timeline/extension.js";
import {
applyBackendTimelineSettingsToNode,
ensureUiInstanceId,
payloadsMatchNode,
} from "./ltxv_timeline/runtime.js";
export {
applyBackendTimelineSettingsToNode,
ensureUiInstanceId,
payloadsMatchNode,
};
+119
View File
@@ -0,0 +1,119 @@
// Shared constants and schema defaults for the timeline editor.
export const IMAGE_TIMELINE_NODES = new Set([
"ETKLTXVTimelineImageEditor",
]);
export const TARGET_NODES = IMAGE_TIMELINE_NODES;
export const CUSTOM_AREA_TOP = 186;
export const TIMELINE_TOP_PAD = 34;
export const TIMELINE_TRACK_HEIGHT = 30;
export const TIMELINE_GAP = 20;
export const TIMELINE_CONTROLS_HEIGHT = 126;
export const TIMELINE_CONTROLS_GAP = 10;
export const TIMELINE_BOTTOM_PAD = 20;
export const PREVIEW_MIN_HEIGHT = 336;
export const TIMELINE_EDITOR_WIDGET = "__etk_ltxv_timeline_editor";
export const KEYFRAME_POSITIVE_PROMPT_WIDGET = "__etk_ltxv_keyframe_positive_prompt";
export const TEXT_LAYER_EDITOR_WIDGET = "__etk_ltxv_text_layer_editor";
export const UPLOAD_MODE_PROPERTY = "etk_ltxv_upload_mode";
export const LAYER_LOCK_PROPERTY = "etk_ltxv_timeline_layers_locked";
export const CONTROL_TAB_PROPERTY = "etk_ltxv_controls_tab";
export const CANVAS_SIZE_PROPERTY = "etk_ltxv_timeline_canvas_size";
export const OUTPUT_SETTINGS_PROPERTY = "etk_ltxv_timeline_output_settings";
export const CONTROL_TABS = [
{ id: "strength", label: "Strength", w: 72 },
{ id: "layers", label: "Layers", w: 62 },
{ id: "filters", label: "Filters", w: 62 },
];
export const CONTROL_HEADER_HEIGHT = 24;
export const CONTROL_TABS_HEIGHT = 22;
export const TIMELINE_EDITOR_HEIGHT =
TIMELINE_TOP_PAD + TIMELINE_TRACK_HEIGHT + TIMELINE_GAP + TIMELINE_CONTROLS_HEIGHT + TIMELINE_CONTROLS_GAP + PREVIEW_MIN_HEIGHT + TIMELINE_BOTTOM_PAD;
export const KEYFRAME_PROMPT_EDITOR_HEIGHT = 98;
export const KEYFRAME_PROMPT_EDITOR_MAX_HEIGHT = 220;
export const TEXT_LAYER_EDITOR_HEIGHT = 164;
export const TEXT_LAYER_EDITOR_MAX_HEIGHT = 250;
export const UI_INSTANCE_WIDGET = "ui_instance_id";
export const UI_INSTANCE_PROPERTY = "etk_ltxv_timeline_ui_instance_id";
export const MIN_LAYER_ZOOM = 0.01;
export const MAX_LAYER_ZOOM = 20;
export const WHEEL_ZOOM_STEP = 0.05;
export const SHIFT_WHEEL_ZOOM_STEP = 0.02;
export const CTRL_WHEEL_ZOOM_STEP = 0.002;
export const KEYBOARD_PAN_STEP = 0.05;
export const CTRL_KEYBOARD_PAN_IMAGE_PX = 1;
export const FIXED_PROMPT_WIDGET_NAMES = new Set(["global_positive_prompt", "global_negative_prompt", "prompt_schedule"]);
export const PROMPT_WIDGET_FIXED_HEIGHT = 72;
export const CHAIN_INPUT_NAME = "chain_in";
export const LOCAL_MEDIA_MANAGER_DROP_TYPE = "application/x-local-media-manager-image";
export const CHAIN_INHERITED_WIDGET_NAMES = new Set([
"width",
"height",
"global_positive_prompt",
"global_negative_prompt",
"positive_audio_prompt_weight",
"negative_audio_prompt_weight",
]);
export const TIMELINE_GLOBAL_LISTENER_STATE = "__etkTimelineGlobalListenerState";
export const TIMELINE_GLOBAL_LISTENER_TOKEN = {};
export const TIMELINE_STATUS_DURATION_MS = 4500;
export const TIMELINE_SCHEMA_VERSION = 2;
export const DEFAULT_TEXT_LAYER = Object.freeze({
text: "Text",
fontFamily: "sans-serif",
fontSize: 72,
bold: false,
italic: false,
align: "center",
color: "#ffffff",
outlineColor: "#000000",
outlineWidth: 0,
shadowColor: "#000000",
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
});
export const TEXT_LAYER_FONT_OPTIONS = Object.freeze([
["sans-serif", "Sans"],
["serif", "Serif"],
["monospace", "Mono"],
["Arial", "Arial"],
["Helvetica", "Helvetica"],
["Verdana", "Verdana"],
["Georgia", "Georgia"],
["Times New Roman", "Times"],
["Courier New", "Courier"],
["Impact", "Impact"],
["DejaVu Sans", "DejaVu Sans"],
["DejaVu Serif", "DejaVu Serif"],
["DejaVu Sans Mono", "DejaVu Mono"],
["Liberation Sans", "Liberation Sans"],
["Liberation Serif", "Liberation Serif"],
["Liberation Mono", "Liberation Mono"],
]);
export const TEXT_LAYER_FIELDS = Object.freeze([
"text",
"fontFamily",
"font_family",
"fontSize",
"font_size",
"bold",
"italic",
"align",
"textAlign",
"text_align",
"color",
"outlineColor",
"outline_color",
"outlineWidth",
"outline_width",
"shadowColor",
"shadow_color",
"shadowBlur",
"shadow_blur",
"shadowOffsetX",
"shadow_offset_x",
"shadowOffsetY",
"shadow_offset_y",
]);
+717
View File
@@ -0,0 +1,717 @@
// Prompt and text-layer DOM editors plus widget synchronization.
import {
CHAIN_INHERITED_WIDGET_NAMES,
CHAIN_INPUT_NAME,
DEFAULT_TEXT_LAYER,
FIXED_PROMPT_WIDGET_NAMES,
KEYFRAME_POSITIVE_PROMPT_WIDGET,
KEYFRAME_PROMPT_EDITOR_HEIGHT,
KEYFRAME_PROMPT_EDITOR_MAX_HEIGHT,
PROMPT_WIDGET_FIXED_HEIGHT,
TEXT_LAYER_EDITOR_HEIGHT,
TEXT_LAYER_EDITOR_MAX_HEIGHT,
TEXT_LAYER_EDITOR_WIDGET,
TEXT_LAYER_FONT_OPTIONS,
TIMELINE_EDITOR_WIDGET,
} from "./constants.js";
import { getInput, getWidget, setTimelineStatus } from "./runtime.js";
import {
activeLayer,
ensureTextLayerFontOption,
isTextLayer,
layerEditingLocked,
normalizeKeyframes,
normalizePrompt,
normalizeTextLayerAlign,
normalizeTextLayerBold,
normalizeTextLayerColor,
normalizeTextLayerFontFamily,
normalizeTextLayerFontSize,
normalizeTextLayerItalic,
normalizeTextLayerNumber,
normalizeTextLayerString,
rejectLayerEditIfLocked,
selectedKeyframe,
stopDomEvent,
syncTimelineWidget,
updateActiveLayer,
} from "./model.js";
import { applyTimelineCanvasSize } from "./node_setup.js";
import { formatSlotSeconds } from "./timeline_ops.js";
import { editPromptModal } from "./rendering.js";
export function timelineEditorWidget(node) {
return node.widgets ? node.widgets.find((widget) => widget && widget.name === TIMELINE_EDITOR_WIDGET) : null;
}
export function keyframePromptEditorWidget(node) {
return node.widgets ? node.widgets.find((widget) => widget && widget.name === KEYFRAME_POSITIVE_PROMPT_WIDGET) : null;
}
export function fixPromptWidgetHeights(node) {
for (const widget of node.widgets || []) {
if (!FIXED_PROMPT_WIDGET_NAMES.has(widget.name)) continue;
widget.etkTimelineFixedHeight = PROMPT_WIDGET_FIXED_HEIGHT;
widget.last_h = PROMPT_WIDGET_FIXED_HEIGHT;
widget.computeLayoutSize = () => ({
minHeight: PROMPT_WIDGET_FIXED_HEIGHT,
maxHeight: PROMPT_WIDGET_FIXED_HEIGHT,
minWidth: 220,
});
widget.computeSize = (width) => [width, PROMPT_WIDGET_FIXED_HEIGHT];
if (widget.element) {
widget.element.rows = 3;
widget.element.style.height = `${PROMPT_WIDGET_FIXED_HEIGHT}px`;
widget.element.style.minHeight = `${PROMPT_WIDGET_FIXED_HEIGHT}px`;
widget.element.style.maxHeight = `${PROMPT_WIDGET_FIXED_HEIGHT}px`;
widget.element.style.resize = "none";
}
}
}
export function clarifyDefaultGuideStrengthWidget(node) {
const widget = getWidget(node, "strength");
if (!widget || widget.etkTimelineGuideStrengthRelabeled) return;
widget.etkTimelineGuideStrengthRelabeled = true;
widget.label = "default image guide strength";
if (!widget.options) widget.options = {};
widget.options.label = "default image guide strength";
widget.options.tooltip = "Default image/latent guide strength for keyframes that do not override latent strength.";
}
export function migrateDisableRelayWidgetOrder(node) {
const testWidget = getWidget(node, "test_this_chunk_only");
const disableWidget = getWidget(node, "disable_prompt_relay");
const globalPositive = getWidget(node, "global_positive_prompt");
const globalNegative = getWidget(node, "global_negative_prompt");
const epsilon = getWidget(node, "epsilon");
const timeline = getWidget(node, "timeline_json");
const promptSchedule = getWidget(node, "prompt_schedule");
const promptScheduleFps = getWidget(node, "prompt_schedule_fps");
const alphaMaskMode = getWidget(node, "alpha_mask_mode");
const alphaDownsampleMode = getWidget(node, "alpha_downsample_mode");
const alphaKeepThreshold = getWidget(node, "alpha_keep_threshold");
const alphaMaskGrowPx = getWidget(node, "alpha_mask_grow_px");
const transparentMaskValue = getWidget(node, "transparent_mask_value");
const opaqueMaskValue = getWidget(node, "opaque_mask_value");
const sendPixelMaskAttention = getWidget(node, "send_pixel_mask_attention");
const vaeAlphaRgbMode = getWidget(node, "vae_alpha_rgb_mode");
const vaeAlphaBleedPx = getWidget(node, "vae_alpha_bleed_px");
if (!testWidget || !disableWidget || !globalPositive || !globalNegative || !epsilon || !timeline) return false;
const oldWithoutToggles =
typeof testWidget.value === "string" &&
typeof disableWidget.value === "string" &&
(typeof timeline.value === "number" || String(globalNegative.value || "").trim().startsWith("["));
const oldWithTestOnly =
typeof testWidget.value === "boolean" &&
typeof disableWidget.value === "string" &&
typeof globalPositive.value === "string" &&
typeof globalNegative.value !== "string";
if (!oldWithoutToggles && !oldWithTestOnly) return false;
if (oldWithoutToggles) {
const oldGlobalPositive = testWidget.value;
const oldGlobalNegative = disableWidget.value;
const oldEpsilon = globalPositive.value;
const oldTimeline = globalNegative.value;
const oldPromptSchedule = epsilon.value;
const oldPromptScheduleFps = timeline.value;
const oldAlphaMaskMode = promptSchedule ? promptSchedule.value : undefined;
const oldAlphaDownsampleMode = promptScheduleFps ? promptScheduleFps.value : undefined;
const oldAlphaKeepThreshold = alphaMaskMode ? alphaMaskMode.value : undefined;
const oldAlphaMaskGrowPx = alphaDownsampleMode ? alphaDownsampleMode.value : undefined;
const oldTransparentMaskValue = alphaKeepThreshold ? alphaKeepThreshold.value : undefined;
const oldOpaqueMaskValue = alphaMaskGrowPx ? alphaMaskGrowPx.value : undefined;
const oldSendPixelMaskAttention = transparentMaskValue ? transparentMaskValue.value : undefined;
const oldVaeAlphaRgbMode = opaqueMaskValue ? opaqueMaskValue.value : undefined;
const oldVaeAlphaBleedPx = sendPixelMaskAttention ? sendPixelMaskAttention.value : undefined;
testWidget.value = false;
disableWidget.value = false;
globalPositive.value = oldGlobalPositive;
globalNegative.value = oldGlobalNegative;
epsilon.value = oldEpsilon;
timeline.value = oldTimeline;
if (promptSchedule) promptSchedule.value = oldPromptSchedule;
if (promptScheduleFps) promptScheduleFps.value = oldPromptScheduleFps;
if (alphaMaskMode) alphaMaskMode.value = oldAlphaMaskMode;
if (alphaDownsampleMode) alphaDownsampleMode.value = oldAlphaDownsampleMode;
if (alphaKeepThreshold) alphaKeepThreshold.value = oldAlphaKeepThreshold;
if (alphaMaskGrowPx) alphaMaskGrowPx.value = oldAlphaMaskGrowPx;
if (transparentMaskValue) transparentMaskValue.value = oldTransparentMaskValue;
if (opaqueMaskValue) opaqueMaskValue.value = oldOpaqueMaskValue;
if (sendPixelMaskAttention) sendPixelMaskAttention.value = oldSendPixelMaskAttention;
if (vaeAlphaRgbMode) vaeAlphaRgbMode.value = oldVaeAlphaRgbMode;
if (vaeAlphaBleedPx) vaeAlphaBleedPx.value = oldVaeAlphaBleedPx;
} else {
const oldDisableValue = disableWidget.value;
const oldGlobalPositive = globalPositive.value;
const oldGlobalNegative = globalNegative.value;
const oldEpsilon = epsilon.value;
const oldTimeline = timeline.value;
const oldPromptSchedule = promptSchedule ? promptSchedule.value : undefined;
const oldPromptScheduleFps = promptScheduleFps ? promptScheduleFps.value : undefined;
const oldAlphaMaskMode = alphaMaskMode ? alphaMaskMode.value : undefined;
const oldAlphaDownsampleMode = alphaDownsampleMode ? alphaDownsampleMode.value : undefined;
const oldAlphaKeepThreshold = alphaKeepThreshold ? alphaKeepThreshold.value : undefined;
const oldAlphaMaskGrowPx = alphaMaskGrowPx ? alphaMaskGrowPx.value : undefined;
const oldTransparentMaskValue = transparentMaskValue ? transparentMaskValue.value : undefined;
const oldOpaqueMaskValue = opaqueMaskValue ? opaqueMaskValue.value : undefined;
const oldSendPixelMaskAttention = sendPixelMaskAttention ? sendPixelMaskAttention.value : undefined;
const oldVaeAlphaRgbMode = vaeAlphaRgbMode ? vaeAlphaRgbMode.value : undefined;
disableWidget.value = false;
globalPositive.value = oldDisableValue;
globalNegative.value = oldGlobalPositive;
epsilon.value = oldGlobalNegative;
timeline.value = oldEpsilon;
if (promptSchedule) promptSchedule.value = oldTimeline;
if (promptScheduleFps) promptScheduleFps.value = oldPromptSchedule;
if (alphaMaskMode) alphaMaskMode.value = oldPromptScheduleFps;
if (alphaDownsampleMode) alphaDownsampleMode.value = oldAlphaMaskMode;
if (alphaKeepThreshold) alphaKeepThreshold.value = oldAlphaDownsampleMode;
if (alphaMaskGrowPx) alphaMaskGrowPx.value = oldAlphaKeepThreshold;
if (transparentMaskValue) transparentMaskValue.value = oldAlphaMaskGrowPx;
if (opaqueMaskValue) opaqueMaskValue.value = oldTransparentMaskValue;
if (sendPixelMaskAttention) sendPixelMaskAttention.value = oldOpaqueMaskValue;
if (vaeAlphaRgbMode) vaeAlphaRgbMode.value = oldSendPixelMaskAttention;
if (vaeAlphaBleedPx) vaeAlphaBleedPx.value = oldVaeAlphaRgbMode;
}
node.properties.etk_ltxv_disable_relay_widget_migrated = true;
return true;
}
export function chainInputConnected(node) {
const input = getInput(node, CHAIN_INPUT_NAME);
return Boolean(input && input.link != null);
}
export function applyWidgetInheritedState(widget, inherited) {
if (!widget) return;
widget.disabled = inherited;
widget.etkTimelineChainInherited = inherited;
if (!widget.options) widget.options = {};
widget.options.disabled = inherited;
const opacity = inherited ? "0.45" : "";
if (widget.element && widget.element.style) {
widget.element.style.opacity = opacity;
if ("readOnly" in widget.element) widget.element.readOnly = inherited;
if ("disabled" in widget.element) widget.element.disabled = false;
}
if (widget.inputEl) {
widget.inputEl.readOnly = inherited;
widget.inputEl.disabled = false;
if (widget.inputEl.style) widget.inputEl.style.opacity = opacity;
}
}
export function syncChainInheritedWidgets(node) {
const inherited = chainInputConnected(node);
for (const widget of node.widgets || []) {
if (CHAIN_INHERITED_WIDGET_NAMES.has(widget.name)) {
applyWidgetInheritedState(widget, inherited);
}
}
node.etkTimelineChainInherited = inherited;
}
export function syncKeyframePromptEditor(node, keyframe = null) {
const widget = keyframePromptEditorWidget(node);
if (!widget || !widget.inputEl) return;
const selected = keyframe || selectedKeyframe(node);
const title = widget.titleEl;
node.etkTimelineSyncingPromptEditor = true;
if (!selected) {
widget.inputEl.value = "";
widget.inputEl.disabled = true;
widget.inputEl.placeholder = "Select or add a keyframe to edit its positive prompt";
if (title) title.textContent = "Selected keyframe positive prompt";
} else {
widget.inputEl.disabled = false;
widget.inputEl.value = normalizePrompt(selected.positivePrompt);
widget.inputEl.placeholder = "Positive prompt for selected keyframe";
if (title) title.textContent = `Selected keyframe positive prompt - ${formatSlotSeconds(node, selected.slot)}`;
}
widget.value = widget.inputEl.value;
node.etkTimelineSyncingPromptEditor = false;
}
export function commitKeyframePromptEditor(node) {
const widget = keyframePromptEditorWidget(node);
const selected = selectedKeyframe(node);
if (!widget || !widget.inputEl || !selected || node.etkTimelineSyncingPromptEditor) return;
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const index = keyframes.findIndex((keyframe) => keyframe.id === selected.id);
if (index < 0) return;
keyframes[index] = { ...keyframes[index], positivePrompt: widget.inputEl.value };
node.properties.etk_ltxv_timeline_keyframes = keyframes;
node.properties.etk_ltxv_selected_keyframe = index;
node.properties.etk_ltxv_selected_keyframe_id = keyframes[index].id;
widget.value = widget.inputEl.value;
syncTimelineWidget(node);
node.setDirtyCanvas(true, true);
}
export function makeKeyframePromptEditorElement(node) {
const container = document.createElement("div");
container.style.cssText = `
box-sizing: border-box; width: 100%; height: 100%; min-height: 0;
padding: 6px 10px 8px 10px; color: #dce7f5;
font-family: sans-serif; background: rgba(13, 17, 24, 0.72);
border: 1px solid #30394a; border-radius: 6px;
display: flex; flex-direction: column; gap: 4px; overflow: hidden;
`;
const title = document.createElement("div");
title.textContent = "Selected keyframe positive prompt";
title.style.cssText = "font-size: 11px; font-weight: 700; color: #b9c7d9; flex: 0 0 auto;";
container.appendChild(title);
const textarea = document.createElement("textarea");
textarea.rows = 3;
textarea.style.cssText = `
box-sizing: border-box; width: 100%; height: auto; resize: none;
flex: 1 1 auto; min-height: 0; overflow: auto;
background: #111820; color: #f3f7ff; border: 1px solid #485668;
border-radius: 5px; padding: 6px 8px; font: 12px/1.25 sans-serif;
`;
container.appendChild(textarea);
for (const eventName of ["pointerdown", "mousedown", "mouseup", "click", "dblclick", "wheel", "keydown", "keyup"]) {
container.addEventListener(eventName, stopDomEvent);
textarea.addEventListener(eventName, stopDomEvent);
}
textarea.addEventListener("input", () => commitKeyframePromptEditor(node));
return { container, textarea, title };
}
export function ensureKeyframePromptEditorWidget(node) {
if (keyframePromptEditorWidget(node)) {
syncKeyframePromptEditor(node);
return;
}
const { container, textarea, title } = makeKeyframePromptEditorElement(node);
const widgetOptions = {
serialize: false,
hideOnZoom: false,
selectOn: ["focus", "click"],
getMinHeight: () => KEYFRAME_PROMPT_EDITOR_HEIGHT,
getMaxHeight: () => KEYFRAME_PROMPT_EDITOR_MAX_HEIGHT,
getValue: () => textarea.value,
setValue: (value) => {
textarea.value = normalizePrompt(value);
},
};
const widget =
typeof node.addDOMWidget === "function"
? node.addDOMWidget(KEYFRAME_POSITIVE_PROMPT_WIDGET, "customtext", container, widgetOptions)
: node.addCustomWidget
? node.addCustomWidget({
type: "customtext",
name: KEYFRAME_POSITIVE_PROMPT_WIDGET,
value: "",
serialize: false,
options: widgetOptions,
computeLayoutSize: () => ({
minHeight: KEYFRAME_PROMPT_EDITOR_HEIGHT,
maxHeight: KEYFRAME_PROMPT_EDITOR_MAX_HEIGHT,
minWidth: 460,
}),
draw(ctx, owner, width, y, height) {
const keyframe = selectedKeyframe(owner);
this.last_y = y;
this.last_h = height;
this.computedHeight = height;
ctx.save();
ctx.fillStyle = "rgba(13, 17, 24, 0.72)";
ctx.fillRect(10, y + 4, Math.max(1, width - 20), Math.max(1, height - 8));
ctx.strokeStyle = "#30394a";
ctx.strokeRect(10, y + 4, Math.max(1, width - 20), Math.max(1, height - 8));
ctx.fillStyle = "#b9c7d9";
ctx.font = "bold 11px sans-serif";
ctx.textAlign = "left";
ctx.fillText(
keyframe ? `Selected keyframe prompt - ${formatSlotSeconds(owner, keyframe.slot)}` : "Selected keyframe positive prompt",
20,
y + 22
);
ctx.fillStyle = keyframe ? "#f3f7ff" : "#7d8797";
ctx.font = "12px sans-serif";
const prompt = keyframe ? normalizePrompt(keyframe.positivePrompt) || "Click to edit prompt" : "Select or add a keyframe to edit its prompt";
ctx.fillText(prompt.length > 120 ? `${prompt.slice(0, 117)}...` : prompt, 20, y + 44);
ctx.restore();
},
mouse(event, _pos, owner) {
const type = event && event.type ? event.type : "";
if (!(type.includes("down") || type === "mousedown" || !type)) return false;
const keyframe = selectedKeyframe(owner);
if (!keyframe) {
setTimelineStatus(owner, "Select or add a keyframe before editing its prompt.", "error");
return true;
}
editPromptModal(owner, keyframe.id, "positivePrompt", "Positive");
return true;
},
})
: null;
if (!widget) return;
widget.serialize = false;
widget.element = container;
widget.inputEl = textarea;
widget.titleEl = title;
if (typeof widget.computeLayoutSize !== "function") {
widget.computeLayoutSize = () => ({
minHeight: KEYFRAME_PROMPT_EDITOR_HEIGHT,
maxHeight: KEYFRAME_PROMPT_EDITOR_MAX_HEIGHT,
minWidth: 460,
});
}
syncKeyframePromptEditor(node);
}
export function textLayerEditorWidget(node) {
return node.widgets ? node.widgets.find((widget) => widget && widget.name === TEXT_LAYER_EDITOR_WIDGET) : null;
}
export function selectedTextLayerInfo(node) {
const keyframe = selectedKeyframe(node);
const layer = keyframe ? activeLayer(keyframe) : null;
if (!keyframe || !layer || !isTextLayer(layer)) return null;
return { keyframe, layer };
}
export function syncTextLayerEditor(node) {
const widget = textLayerEditorWidget(node);
if (!widget || !widget.textEl) return;
const selected = selectedTextLayerInfo(node);
const locked = layerEditingLocked(node);
const visible = Boolean(selected);
widget.hidden = !visible;
if (widget.element && widget.element.style) widget.element.style.display = visible ? "" : "none";
if (visible && node.size && typeof node.computeSize === "function") {
applyTimelineCanvasSize(node);
}
node.etkTimelineSyncingTextLayerEditor = true;
const disabled = !selected || locked;
const controls = [
widget.textEl,
widget.fontEl,
widget.sizeEl,
widget.boldEl,
widget.italicEl,
widget.alignEl,
widget.colorEl,
widget.outlineColorEl,
widget.outlineWidthEl,
widget.shadowColorEl,
widget.shadowBlurEl,
widget.shadowOffsetXEl,
widget.shadowOffsetYEl,
].filter(Boolean);
for (const control of controls) control.disabled = disabled;
widget.textEl.placeholder = selected ? "Transparent text for the active layer" : "Select or add a text layer to edit text";
if (widget.titleEl) {
widget.titleEl.textContent = selected
? `Selected text layer - ${formatSlotSeconds(node, selected.keyframe.slot)}${locked ? " (locked)" : ""}`
: "Selected text layer";
}
if (selected) {
const fontFamily = normalizeTextLayerFontFamily(selected.layer.fontFamily, DEFAULT_TEXT_LAYER.fontFamily);
ensureTextLayerFontOption(widget.fontEl, fontFamily);
widget.textEl.value = normalizeTextLayerString(selected.layer.text, DEFAULT_TEXT_LAYER.text);
widget.fontEl.value = fontFamily;
widget.sizeEl.value = String(Math.round(normalizeTextLayerFontSize(selected.layer.fontSize, DEFAULT_TEXT_LAYER.fontSize)));
widget.boldEl.checked = normalizeTextLayerBold(selected.layer.bold, false);
widget.italicEl.checked = normalizeTextLayerItalic(selected.layer.italic, false);
widget.alignEl.value = normalizeTextLayerAlign(selected.layer.align, DEFAULT_TEXT_LAYER.align);
widget.colorEl.value = normalizeTextLayerColor(selected.layer.color, DEFAULT_TEXT_LAYER.color).slice(0, 7);
widget.outlineColorEl.value = normalizeTextLayerColor(selected.layer.outlineColor, DEFAULT_TEXT_LAYER.outlineColor).slice(0, 7);
widget.outlineWidthEl.value = String(normalizeTextLayerNumber(selected.layer.outlineWidth, DEFAULT_TEXT_LAYER.outlineWidth, 0, 64));
widget.shadowColorEl.value = normalizeTextLayerColor(selected.layer.shadowColor, DEFAULT_TEXT_LAYER.shadowColor).slice(0, 7);
widget.shadowBlurEl.value = String(normalizeTextLayerNumber(selected.layer.shadowBlur, DEFAULT_TEXT_LAYER.shadowBlur, 0, 128));
widget.shadowOffsetXEl.value = String(normalizeTextLayerNumber(selected.layer.shadowOffsetX, DEFAULT_TEXT_LAYER.shadowOffsetX, -512, 512));
widget.shadowOffsetYEl.value = String(normalizeTextLayerNumber(selected.layer.shadowOffsetY, DEFAULT_TEXT_LAYER.shadowOffsetY, -512, 512));
} else {
ensureTextLayerFontOption(widget.fontEl, DEFAULT_TEXT_LAYER.fontFamily);
widget.textEl.value = "";
widget.fontEl.value = DEFAULT_TEXT_LAYER.fontFamily;
widget.sizeEl.value = String(DEFAULT_TEXT_LAYER.fontSize);
widget.boldEl.checked = false;
widget.italicEl.checked = false;
widget.alignEl.value = DEFAULT_TEXT_LAYER.align;
widget.colorEl.value = DEFAULT_TEXT_LAYER.color;
widget.outlineColorEl.value = DEFAULT_TEXT_LAYER.outlineColor;
widget.outlineWidthEl.value = String(DEFAULT_TEXT_LAYER.outlineWidth);
widget.shadowColorEl.value = DEFAULT_TEXT_LAYER.shadowColor;
widget.shadowBlurEl.value = String(DEFAULT_TEXT_LAYER.shadowBlur);
widget.shadowOffsetXEl.value = String(DEFAULT_TEXT_LAYER.shadowOffsetX);
widget.shadowOffsetYEl.value = String(DEFAULT_TEXT_LAYER.shadowOffsetY);
}
widget.value = widget.textEl.value;
node.etkTimelineSyncingTextLayerEditor = false;
}
export function commitTextLayerEditor(node) {
const widget = textLayerEditorWidget(node);
const selected = selectedTextLayerInfo(node);
if (!widget || !widget.textEl || !selected || node.etkTimelineSyncingTextLayerEditor) return;
if (rejectLayerEditIfLocked(node, "Text layer edit")) {
syncTextLayerEditor(node);
return;
}
updateActiveLayer(node, selected.keyframe.id, (current) => ({
...current,
type: "text",
image: null,
empty: false,
text: normalizeTextLayerString(widget.textEl.value, DEFAULT_TEXT_LAYER.text),
fontFamily: normalizeTextLayerFontFamily(widget.fontEl.value, DEFAULT_TEXT_LAYER.fontFamily),
fontSize: normalizeTextLayerFontSize(widget.sizeEl.value, DEFAULT_TEXT_LAYER.fontSize),
bold: Boolean(widget.boldEl.checked),
italic: Boolean(widget.italicEl.checked),
align: normalizeTextLayerAlign(widget.alignEl.value, DEFAULT_TEXT_LAYER.align),
color: normalizeTextLayerColor(widget.colorEl.value, DEFAULT_TEXT_LAYER.color),
outlineColor: normalizeTextLayerColor(widget.outlineColorEl.value, DEFAULT_TEXT_LAYER.outlineColor),
outlineWidth: normalizeTextLayerNumber(widget.outlineWidthEl.value, DEFAULT_TEXT_LAYER.outlineWidth, 0, 64),
shadowColor: normalizeTextLayerColor(widget.shadowColorEl.value, DEFAULT_TEXT_LAYER.shadowColor),
shadowBlur: normalizeTextLayerNumber(widget.shadowBlurEl.value, DEFAULT_TEXT_LAYER.shadowBlur, 0, 128),
shadowOffsetX: normalizeTextLayerNumber(widget.shadowOffsetXEl.value, DEFAULT_TEXT_LAYER.shadowOffsetX, -512, 512),
shadowOffsetY: normalizeTextLayerNumber(widget.shadowOffsetYEl.value, DEFAULT_TEXT_LAYER.shadowOffsetY, -512, 512),
}));
}
export function makeTextLayerEditorElement(node) {
const container = document.createElement("div");
container.style.cssText = `
box-sizing: border-box; width: 100%; height: 100%; min-height: 0;
padding: 6px 10px 8px 10px; color: #dce7f5;
font-family: sans-serif; background: rgba(13, 17, 24, 0.72);
border: 1px solid #30394a; border-radius: 6px;
display: flex; flex-direction: column; gap: 5px; overflow: hidden;
`;
const title = document.createElement("div");
title.textContent = "Selected text layer";
title.style.cssText = "font-size: 11px; font-weight: 700; color: #b9c7d9; flex: 0 0 auto;";
container.appendChild(title);
const textarea = document.createElement("textarea");
textarea.rows = 2;
textarea.style.cssText = `
box-sizing: border-box; width: 100%; resize: none;
flex: 1 1 auto; min-height: 30px; overflow: auto;
background: #111820; color: #f3f7ff; border: 1px solid #485668;
border-radius: 5px; padding: 5px 7px; font: 12px/1.25 sans-serif;
`;
container.appendChild(textarea);
const makeRow = () => {
const row = document.createElement("div");
row.style.cssText = "display: flex; gap: 6px; align-items: center; flex: 0 0 auto; min-height: 24px;";
container.appendChild(row);
return row;
};
const makeLabel = (text) => {
const label = document.createElement("label");
label.textContent = text;
label.style.cssText = "display: flex; align-items: center; gap: 3px; color: #dce7f5; font: bold 10px sans-serif; white-space: nowrap;";
return label;
};
const inputStyle = "box-sizing: border-box; background: #111820; color: #f3f7ff; border: 1px solid #485668; border-radius: 4px; padding: 3px 5px; font: 11px sans-serif;";
const colorStyle = "width: 32px; height: 22px; padding: 0; border: 1px solid #485668; border-radius: 4px; background: #111820;";
const numberStyle = `${inputStyle} width: 48px;`;
const row = makeRow();
const font = document.createElement("select");
font.style.cssText = `${inputStyle} flex: 1 1 auto; min-width: 102px;`;
for (const [value, label] of TEXT_LAYER_FONT_OPTIONS) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
font.appendChild(option);
}
row.appendChild(font);
const size = document.createElement("input");
size.type = "number";
size.min = "1";
size.max = "512";
size.step = "1";
size.title = "Font size";
size.style.cssText = numberStyle;
row.appendChild(size);
const color = document.createElement("input");
color.type = "color";
color.title = "Text color";
color.style.cssText = colorStyle;
row.appendChild(color);
const boldLabel = makeLabel("B");
const bold = document.createElement("input");
bold.type = "checkbox";
boldLabel.appendChild(bold);
row.appendChild(boldLabel);
const italicLabel = makeLabel("I");
const italic = document.createElement("input");
italic.type = "checkbox";
italicLabel.appendChild(italic);
row.appendChild(italicLabel);
const align = document.createElement("select");
align.title = "Text alignment";
align.style.cssText = `${inputStyle} width: 68px;`;
for (const value of ["left", "center", "right"]) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
align.appendChild(option);
}
row.appendChild(align);
const styleRow = makeRow();
const outlineLabel = makeLabel("Outline");
const outlineColor = document.createElement("input");
outlineColor.type = "color";
outlineColor.title = "Outline color";
outlineColor.style.cssText = colorStyle;
outlineLabel.appendChild(outlineColor);
const outlineWidth = document.createElement("input");
outlineWidth.type = "number";
outlineWidth.min = "0";
outlineWidth.max = "64";
outlineWidth.step = "1";
outlineWidth.title = "Outline width";
outlineWidth.style.cssText = numberStyle;
outlineLabel.appendChild(outlineWidth);
styleRow.appendChild(outlineLabel);
const shadowLabel = makeLabel("Shadow");
const shadowColor = document.createElement("input");
shadowColor.type = "color";
shadowColor.title = "Shadow color";
shadowColor.style.cssText = colorStyle;
shadowLabel.appendChild(shadowColor);
const shadowBlur = document.createElement("input");
shadowBlur.type = "number";
shadowBlur.min = "0";
shadowBlur.max = "128";
shadowBlur.step = "1";
shadowBlur.title = "Shadow blur";
shadowBlur.style.cssText = numberStyle;
shadowLabel.appendChild(shadowBlur);
const shadowOffsetX = document.createElement("input");
shadowOffsetX.type = "number";
shadowOffsetX.min = "-512";
shadowOffsetX.max = "512";
shadowOffsetX.step = "1";
shadowOffsetX.title = "Shadow X";
shadowOffsetX.style.cssText = numberStyle;
shadowLabel.appendChild(shadowOffsetX);
const shadowOffsetY = document.createElement("input");
shadowOffsetY.type = "number";
shadowOffsetY.min = "-512";
shadowOffsetY.max = "512";
shadowOffsetY.step = "1";
shadowOffsetY.title = "Shadow Y";
shadowOffsetY.style.cssText = numberStyle;
shadowLabel.appendChild(shadowOffsetY);
styleRow.appendChild(shadowLabel);
const controls = [textarea, font, size, bold, italic, align, color, outlineColor, outlineWidth, shadowColor, shadowBlur, shadowOffsetX, shadowOffsetY];
for (const eventName of ["pointerdown", "mousedown", "mouseup", "click", "dblclick", "wheel", "keydown", "keyup"]) {
container.addEventListener(eventName, stopDomEvent);
for (const element of controls) element.addEventListener(eventName, stopDomEvent);
}
for (const element of controls) {
element.addEventListener("input", () => commitTextLayerEditor(node));
element.addEventListener("change", () => commitTextLayerEditor(node));
}
return { container, textarea, font, size, bold, italic, align, color, outlineColor, outlineWidth, shadowColor, shadowBlur, shadowOffsetX, shadowOffsetY, title };
}
export function ensureTextLayerEditorWidget(node) {
if (textLayerEditorWidget(node)) {
syncTextLayerEditor(node);
return;
}
const { container, textarea, font, size, bold, italic, align, color, outlineColor, outlineWidth, shadowColor, shadowBlur, shadowOffsetX, shadowOffsetY, title } = makeTextLayerEditorElement(node);
const widgetOptions = {
serialize: false,
hideOnZoom: false,
selectOn: ["focus", "click"],
getMinHeight: () => TEXT_LAYER_EDITOR_HEIGHT,
getMaxHeight: () => TEXT_LAYER_EDITOR_MAX_HEIGHT,
getValue: () => textarea.value,
setValue: (value) => {
textarea.value = normalizeTextLayerString(value);
},
};
const widget =
typeof node.addDOMWidget === "function"
? node.addDOMWidget(TEXT_LAYER_EDITOR_WIDGET, "customtext", container, widgetOptions)
: node.addCustomWidget
? node.addCustomWidget({
type: "customtext",
name: TEXT_LAYER_EDITOR_WIDGET,
value: "",
serialize: false,
options: widgetOptions,
computeLayoutSize: () => ({
minHeight: TEXT_LAYER_EDITOR_HEIGHT,
maxHeight: TEXT_LAYER_EDITOR_MAX_HEIGHT,
minWidth: 460,
}),
draw(ctx, owner, width, y, height) {
const selected = selectedTextLayerInfo(owner);
this.last_y = y;
this.last_h = height;
this.computedHeight = height;
ctx.save();
ctx.fillStyle = "rgba(13, 17, 24, 0.72)";
ctx.fillRect(10, y + 4, Math.max(1, width - 20), Math.max(1, height - 8));
ctx.strokeStyle = "#30394a";
ctx.strokeRect(10, y + 4, Math.max(1, width - 20), Math.max(1, height - 8));
ctx.fillStyle = "#b9c7d9";
ctx.font = "bold 11px sans-serif";
ctx.textAlign = "left";
ctx.fillText("Selected text layer", 20, y + 22);
ctx.fillStyle = selected ? "#f3f7ff" : "#7d8797";
ctx.font = "12px sans-serif";
const text = selected ? normalizeTextLayerString(selected.layer.text, DEFAULT_TEXT_LAYER.text) : "Select or add a text layer to edit text";
ctx.fillText(text.length > 120 ? `${text.slice(0, 117)}...` : text, 20, y + 44);
ctx.restore();
},
})
: null;
if (!widget) return;
widget.serialize = false;
widget.element = container;
widget.textEl = textarea;
widget.fontEl = font;
widget.sizeEl = size;
widget.boldEl = bold;
widget.italicEl = italic;
widget.alignEl = align;
widget.colorEl = color;
widget.outlineColorEl = outlineColor;
widget.outlineWidthEl = outlineWidth;
widget.shadowColorEl = shadowColor;
widget.shadowBlurEl = shadowBlur;
widget.shadowOffsetXEl = shadowOffsetX;
widget.shadowOffsetYEl = shadowOffsetY;
widget.titleEl = title;
if (typeof widget.computeLayoutSize !== "function") {
widget.computeLayoutSize = () => ({
minHeight: TEXT_LAYER_EDITOR_HEIGHT,
maxHeight: TEXT_LAYER_EDITOR_MAX_HEIGHT,
minWidth: 460,
});
}
syncTextLayerEditor(node);
}
+568
View File
@@ -0,0 +1,568 @@
// 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;
};
},
});
+361
View File
@@ -0,0 +1,361 @@
// Layer controls, pointer/keyboard interaction, zoom, and global input listeners.
import { app } from "../../../../scripts/app.js";
import {
CTRL_KEYBOARD_PAN_IMAGE_PX,
CTRL_WHEEL_ZOOM_STEP,
DEFAULT_TEXT_LAYER,
KEYBOARD_PAN_STEP,
SHIFT_WHEEL_ZOOM_STEP,
WHEEL_ZOOM_STEP,
} from "./constants.js";
import {
activeCanvasGraph,
addTimelineWindowListener,
graphNodes,
isTargetNode,
makeId,
setTimelineStatus,
} from "./runtime.js";
import {
activeLayer,
activeLayerIndex,
ensureSelectedKeyframe,
isEmptyLayer,
mirrorBaseLayer,
normalizeKeyframes,
normalizeLayer,
normalizePan,
normalizeZoom,
rejectLayerEditIfLocked,
selectedKeyframe,
selectedKeyframeIndex,
setSelectedKeyframe,
syncTimelineWidget,
updateActiveLayer,
viewUrl,
} from "./model.js";
import { syncTextLayerEditor } from "./editors.js";
import { setupNode } from "./node_setup.js";
import {
deleteKeyframeAtIndex,
duplicateKeyframeAtIndex,
findKeyframeAt,
formatSlotSeconds,
previewImageBox,
} from "./timeline_ops.js";
import { controlContentBounds, previewPlacement } from "./rendering.js";
import { nodeFromGraphMouse, stopKeyboardShortcutEvent } from "./media.js";
export function modeButtonBounds(node, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
return { x: content.x + 192, y: content.y + 4, w: 68, h: 22 };
}
export function lockLayerButtonBounds(node, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
return { x: content.x + 268, y: content.y + 4, w: 56, h: 22 };
}
export function imageButtonBounds(node, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
return { x: content.x + 136, y: content.y + 4, w: 50, h: 22 };
}
export function textLayerButtonBounds(node, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
return { x: content.x + 332, y: content.y + 4, w: 50, h: 22 };
}
export function uploadModeButtonBounds(node, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
return { x: content.x + 50, y: content.y + 4, w: 78, h: 22 };
}
export function deleteLayerButtonBounds(node, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
return { x: content.x, y: content.y + 4, w: 42, h: 22 };
}
export function keyframeStrengthSliderBounds(node, kind, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
const sliderW = Math.min(520, Math.max(220, content.w));
const x = content.x;
const y = content.y + (kind === "latent" ? 0 : 25);
return {
kind,
label: kind === "latent" ? "latent strength" : "prompt strength",
min: 0,
max: kind === "latent" ? 1 : 4,
color: kind === "latent" ? "#42c06e" : "#7d6ccf",
x,
y,
w: sliderW,
h: 19,
trackX: x + 104,
trackY: y + 8,
trackW: Math.max(50, sliderW - 116),
trackH: 4,
};
}
export function keyframeStrengthSliderAt(node, x, y) {
for (const kind of ["latent", "prompt"]) {
const slider = keyframeStrengthSliderBounds(node, kind);
if (x >= slider.x && x <= slider.x + slider.w && y >= slider.y && y <= slider.y + slider.h) {
return slider;
}
}
return null;
}
export function layerFilterSliderBounds(node, kind, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
const sliderW = Math.min(520, Math.max(220, content.w));
const x = content.x;
const y = content.y + (kind === "brightness" ? 0 : 25);
return {
kind,
label: kind,
min: 0,
max: 2,
color: kind === "brightness" ? "#f0c45c" : "#5fb4f0",
x,
y,
w: sliderW,
h: 19,
trackX: x + 86,
trackY: y + 8,
trackW: Math.max(50, sliderW - 98),
trackH: 4,
};
}
export function layerFilterSliderAt(node, x, y) {
for (const kind of ["brightness", "contrast"]) {
const slider = layerFilterSliderBounds(node, kind);
if (x >= slider.x && x <= slider.x + slider.w && y >= slider.y && y <= slider.y + slider.h) {
return slider;
}
}
return null;
}
export function addTextLayer(node, keyframeId = null) {
if (rejectLayerEditIfLocked(node, "Text layer add")) return false;
const keyframe = keyframeId == null ? ensureSelectedKeyframe(node) : selectedKeyframe(node);
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const index = keyframeId == null
? selectedKeyframeIndex(node, keyframes)
: keyframes.findIndex((candidate) => candidate.id === keyframeId);
if (!keyframe || index < 0) return false;
const target = keyframes[index];
const layers = Array.isArray(target.layers) ? target.layers.slice() : [];
const activeIndex = activeLayerIndex(target);
const textLayer = normalizeLayer({
id: makeId(),
type: "text",
text: DEFAULT_TEXT_LAYER.text,
fontFamily: DEFAULT_TEXT_LAYER.fontFamily,
fontSize: DEFAULT_TEXT_LAYER.fontSize,
bold: DEFAULT_TEXT_LAYER.bold,
italic: DEFAULT_TEXT_LAYER.italic,
align: DEFAULT_TEXT_LAYER.align,
color: DEFAULT_TEXT_LAYER.color,
outlineColor: DEFAULT_TEXT_LAYER.outlineColor,
outlineWidth: DEFAULT_TEXT_LAYER.outlineWidth,
shadowColor: DEFAULT_TEXT_LAYER.shadowColor,
shadowBlur: DEFAULT_TEXT_LAYER.shadowBlur,
shadowOffsetX: DEFAULT_TEXT_LAYER.shadowOffsetX,
shadowOffsetY: DEFAULT_TEXT_LAYER.shadowOffsetY,
fitMode: "pad",
panX: 0,
panY: 0,
zoom: 1,
});
if (activeIndex >= 0 && isEmptyLayer(layers[activeIndex])) {
textLayer.id = layers[activeIndex].id || textLayer.id;
layers[activeIndex] = textLayer;
} else {
layers.push(textLayer);
}
target.layers = layers;
target.selectedLayerId = textLayer.id;
mirrorBaseLayer(target);
keyframes[index] = target;
node.properties.etk_ltxv_timeline_keyframes = keyframes;
setSelectedKeyframe(node, index, keyframes);
syncTimelineWidget(node);
syncTextLayerEditor(node);
setTimelineStatus(node, `Added text layer at ${formatSlotSeconds(node, target.slot)}.`, "success");
node.setDirtyCanvas(true, true);
return true;
}
export function releaseDrag(node) {
if (!node || (!node.etkTimelineDragging && !node.etkPreviewPanning && !node.etkSliderDragging)) return false;
node.etkTimelineDragging = false;
node.etkTimelineDraggingId = null;
node.etkPreviewPanning = null;
node.etkSliderDragging = null;
if (window.etkTimelineActiveDragNode === node) window.etkTimelineActiveDragNode = null;
syncTimelineWidget(node);
node.setDirtyCanvas(true, true);
return true;
}
export function wheelZoomStep(event) {
if (event && (event.ctrlKey || event.metaKey)) return CTRL_WHEEL_ZOOM_STEP;
if (event && event.shiftKey) return SHIFT_WHEEL_ZOOM_STEP;
return WHEEL_ZOOM_STEP;
}
export function zoomPreviewAt(node, event, pos) {
setupNode(node);
const selected = selectedKeyframe(node);
const layer = selected ? activeLayer(selected) : null;
if (!selected || !layer || !pos) return false;
const { boxX, boxY, boxW, boxH } = previewImageBox(node);
const [x, y] = pos;
if (x < boxX || x > boxX + boxW || y < boxY || y > boxY + boxH) return false;
if (rejectLayerEditIfLocked(node, "Layer zoom")) return true;
const delta = event && Number.isFinite(event.deltaY) ? event.deltaY : 0;
const step = wheelZoomStep(event);
const factor = delta < 0 ? 1 + step : 1 - step;
updateActiveLayer(node, selected.id, (current) => ({
...current,
zoom: normalizeZoom(normalizeZoom(current.zoom) * factor),
}));
return true;
}
export function isNodeSelected(node) {
const canvas = app.canvas;
if (!node || !canvas) return false;
if (canvas.selected_node === node) return true;
const selected = canvas.selected_nodes || canvas.selectedNodes;
if (Array.isArray(selected)) return selected.includes(node);
if (selected && typeof selected === "object") {
if (node.id != null && (selected[node.id] === node || selected[node.id] === true)) return true;
if (Object.values(selected).includes(node)) return true;
}
return Boolean(node.selected || node.is_selected || (node.flags && node.flags.selected));
}
export function panPreviewWithKeyboard(node, event, pos) {
setupNode(node);
const key = String(event && event.key ? event.key : "");
const directions = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
};
const direction = directions[key];
if (!direction) return false;
const selected = selectedKeyframe(node);
const layer = selected ? activeLayer(selected) : null;
if (!selected || !layer || isEmptyLayer(layer) || !pos) return false;
const { boxX, boxY, boxW, boxH } = previewImageBox(node);
const [x, y] = pos;
if (x < boxX || x > boxX + boxW || y < boxY || y > boxY + boxH) return false;
if (rejectLayerEditIfLocked(node, "Layer pan")) return true;
let deltaX = direction[0] * KEYBOARD_PAN_STEP;
let deltaY = direction[1] * KEYBOARD_PAN_STEP;
if (event && event.ctrlKey) {
const url = viewUrl(layer.image);
const image = url && node.etkTimelineImageCache ? node.etkTimelineImageCache.get(url) : null;
if (!image || !image.complete || image.naturalWidth <= 0 || image.naturalHeight <= 0) return false;
const placement = previewPlacement(
image,
boxW,
boxH,
layer.fitMode,
layer.panX,
layer.panY,
layer.zoom
);
const sourcePixelW = placement.drawW / image.naturalWidth;
const sourcePixelH = placement.drawH / image.naturalHeight;
deltaX = direction[0] ? direction[0] * CTRL_KEYBOARD_PAN_IMAGE_PX * sourcePixelW / (placement.panRangeX / 2) : 0;
deltaY = direction[1] ? direction[1] * CTRL_KEYBOARD_PAN_IMAGE_PX * sourcePixelH / (placement.panRangeY / 2) : 0;
}
updateActiveLayer(node, selected.id, (current) => ({
...current,
panX: normalizePan(current.panX) + deltaX,
panY: normalizePan(current.panY) + deltaY,
}));
return true;
}
export function installWheelCapture() {
addTimelineWindowListener(
"wheelCapture.wheel",
"wheel",
(event) => {
const canvas = app.canvas;
const graphMouse = canvas ? canvas.graph_mouse : null;
const node = nodeFromGraphMouse(graphMouse);
if (!node) return;
const localPos = [graphMouse[0] - node.pos[0], graphMouse[1] - node.pos[1]];
if (!zoomPreviewAt(node, event, localPos)) return;
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
},
{ capture: true, passive: false }
);
}
export function installPointerRelease() {
const releaseActiveDrag = () => {
releaseDrag(window.etkTimelineActiveDragNode);
};
addTimelineWindowListener("pointerRelease.mouseup", "mouseup", releaseActiveDrag, true);
addTimelineWindowListener("pointerRelease.pointerup", "pointerup", releaseActiveDrag, true);
addTimelineWindowListener("pointerRelease.pointercancel", "pointercancel", releaseActiveDrag, true);
addTimelineWindowListener("pointerRelease.blur", "blur", releaseActiveDrag, true);
}
export function installKeyboardShortcuts() {
addTimelineWindowListener("keyboardShortcuts.keydown", "keydown", (event) => {
const rawKey = String(event.key || "");
const key = rawKey.toLowerCase();
const arrowKey = rawKey === "ArrowLeft" || rawKey === "ArrowRight" || rawKey === "ArrowUp" || rawKey === "ArrowDown";
if (key !== "d" && key !== "x" && !arrowKey) return;
if (event.target && ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName)) return;
const canvas = app.canvas;
const graphMouse = canvas ? canvas.graph_mouse : null;
if (!canvas || !graphMouse) return;
if (arrowKey) {
const node = nodeFromGraphMouse(graphMouse);
const localPos = node ? [graphMouse[0] - node.pos[0], graphMouse[1] - node.pos[1]] : null;
if (node && isNodeSelected(node) && panPreviewWithKeyboard(node, event, localPos)) {
stopKeyboardShortcutEvent(event);
}
return;
}
for (const node of graphNodes(activeCanvasGraph())) {
if (!isTargetNode(node)) continue;
if (
graphMouse[0] < node.pos[0] ||
graphMouse[0] > node.pos[0] + node.size[0] ||
graphMouse[1] < node.pos[1] ||
graphMouse[1] > node.pos[1] + node.size[1]
) {
continue;
}
const index = findKeyframeAt(node, [graphMouse[0] - node.pos[0], graphMouse[1] - node.pos[1]]);
if (index >= 0) {
const handled = key === "d" ? duplicateKeyframeAtIndex(node, index) : deleteKeyframeAtIndex(node, index);
if (handled) {
stopKeyboardShortcutEvent(event);
return;
}
}
}
}, { capture: true, passive: false });
}
+603
View File
@@ -0,0 +1,603 @@
// Image upload, drag/drop, paste, and graph-position media targeting.
import { api } from "../../../../scripts/api.js";
import { app } from "../../../../scripts/app.js";
import { LOCAL_MEDIA_MANAGER_DROP_TYPE } from "./constants.js";
import {
activeCanvasGraph,
addTimelineWindowListener,
errorText,
graphNodes,
isTargetNode,
makeId,
setTimelineStatus,
} from "./runtime.js";
import {
activeLayerIndex,
ensureSelectedKeyframe,
isEmptyLayer,
mirrorBaseLayer,
normalizeFileInfo,
normalizeKeyframes,
normalizeLayer,
preloadImage,
rejectLayerEditIfLocked,
selectedKeyframeIndex,
setSelectedKeyframe,
syncTimelineWidget,
uploadMode,
viewUrl,
} from "./model.js";
import { bounds, formatSlotSeconds } from "./timeline_ops.js";
export async function uploadSelectedImage(node, file, keyframeId, options = {}) {
if (rejectLayerEditIfLocked(node, "Image upload")) return false;
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const index =
keyframeId == null
? selectedKeyframeIndex(node, keyframes)
: keyframes.findIndex((keyframe) => keyframe.id === keyframeId);
if (index == null || index < 0 || index >= keyframes.length) return;
const body = new FormData();
body.append("image", file);
body.append("type", "input");
if (options.pasted) {
body.append("subfolder", "pasted");
} else {
body.append("overwrite", "true");
}
const response = await api.fetchApi("/upload/image", { method: "POST", body });
if (!response.ok) throw new Error(`Image upload failed: ${response.status}`);
const payload = await response.json();
const currentKeyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const currentIndex =
keyframeId == null
? selectedKeyframeIndex(node, currentKeyframes)
: currentKeyframes.findIndex((keyframe) => keyframe.id === keyframeId);
if (currentIndex == null || currentIndex < 0 || currentIndex >= currentKeyframes.length) return;
const uploaded = normalizeFileInfo(payload) || {
filename: payload.name || file.name,
subfolder: payload.subfolder || "",
type: payload.type || "input",
};
const keyframe = currentKeyframes[currentIndex];
const layers = Array.isArray(keyframe.layers) ? keyframe.layers : [];
const currentMode = uploadMode(node);
const layerIndex = activeLayerIndex(keyframe);
const activeEmptyIndex = layerIndex >= 0 && isEmptyLayer(layers[layerIndex]) ? layerIndex : -1;
if (activeEmptyIndex >= 0) {
const currentLayer = layers[activeEmptyIndex];
const layer = normalizeLayer({ ...currentLayer, image: uploaded, empty: false, fitMode: "crop", panX: 0, panY: 0, zoom: 1 });
layers[activeEmptyIndex] = layer;
keyframe.layers = layers;
keyframe.selectedLayerId = layer.id;
} else if (currentMode === "layer" && layers.length) {
const layer = normalizeLayer({ id: makeId(), image: uploaded, fitMode: "crop", panX: 0, panY: 0, zoom: 1 });
layers.push(layer);
keyframe.layers = layers;
keyframe.selectedLayerId = layer.id;
} else if (currentMode === "layer") {
const base = normalizeLayer({ id: makeId(), image: uploaded, fitMode: "crop", panX: 0, panY: 0, zoom: 1 });
keyframe.layers = [base];
keyframe.selectedLayerId = base.id;
} else if (layers.length) {
const replaceIndex = layerIndex >= 0 ? layerIndex : 0;
const nextLayer = normalizeLayer({
...layers[replaceIndex],
image: uploaded,
fitMode: "crop",
panX: 0,
panY: 0,
zoom: 1,
});
layers[replaceIndex] = nextLayer;
keyframe.layers = layers;
keyframe.selectedLayerId = nextLayer.id;
} else {
const base = normalizeLayer({ id: makeId(), image: uploaded, fitMode: "crop", panX: 0, panY: 0, zoom: 1 });
keyframe.layers = [base];
keyframe.selectedLayerId = base.id;
}
mirrorBaseLayer(keyframe);
currentKeyframes[currentIndex] = keyframe;
node.properties.etk_ltxv_timeline_keyframes = currentKeyframes;
setSelectedKeyframe(node, currentIndex, currentKeyframes);
preloadImage(node, currentKeyframes[currentIndex]);
syncTimelineWidget(node);
const action = options.pasted ? "Pasted" : "Uploaded";
const target = currentMode === "layer" ? "layer" : activeEmptyIndex >= 0 ? "empty layer" : "active layer";
setTimelineStatus(node, `${action} ${uploaded.filename || uploaded.name || "image"} to ${target} at ${formatSlotSeconds(node, keyframe.slot)}.`, "success");
node.setDirtyCanvas(true, true);
}
export function nodeFromGraphMouse(graphMouse) {
if (!graphMouse) return null;
for (const node of graphNodes(activeCanvasGraph())) {
if (!isTargetNode(node)) continue;
if (
graphMouse[0] >= node.pos[0] &&
graphMouse[0] <= node.pos[0] + node.size[0] &&
graphMouse[1] >= node.pos[1] &&
graphMouse[1] <= node.pos[1] + node.size[1]
) {
return node;
}
}
return null;
}
export function eventToGraphPosition(event) {
const canvas = app.canvas;
const canvasElement = canvas ? canvas.canvas || canvas.canvas_element : null;
if (!canvas) return null;
if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) {
return canvas.graph_mouse ? [canvas.graph_mouse[0], canvas.graph_mouse[1]] : null;
}
let offset = null;
if (typeof canvas.convertEventToCanvasOffset === "function") {
offset = canvas.convertEventToCanvasOffset(event);
}
if (!offset) {
const rect = canvasElement ? canvasElement.getBoundingClientRect() : { left: 0, top: 0 };
offset = [event.clientX - rect.left, event.clientY - rect.top];
}
if (!Array.isArray(offset)) {
const offsetX = offset[0] != null ? offset[0] : offset.x;
const offsetY = offset[1] != null ? offset[1] : offset.y;
offset = [offsetX, offsetY];
}
if (!Number.isFinite(offset[0]) || !Number.isFinite(offset[1])) return null;
if (canvas.ds && typeof canvas.ds.convertOffsetToCanvas === "function") {
return canvas.ds.convertOffsetToCanvas(offset);
}
if (typeof canvas.convertOffsetToCanvas === "function") {
return canvas.convertOffsetToCanvas(offset);
}
if (canvas.ds && canvas.ds.offset && canvas.ds.scale) {
return [(offset[0] - canvas.ds.offset[0]) / canvas.ds.scale, (offset[1] - canvas.ds.offset[1]) / canvas.ds.scale];
}
return offset;
}
export function previewTargetFromGraphPosition(graphPos) {
if (!graphPos) return null;
for (const node of graphNodes(activeCanvasGraph())) {
if (!isTargetNode(node)) continue;
const localPos = [graphPos[0] - node.pos[0], graphPos[1] - node.pos[1]];
if (isPreviewPosition(node, localPos) || isTimelineImageInputPosition(node, graphPos)) {
return { node, localPos };
}
}
return null;
}
export function isPreviewPosition(node, localPos) {
if (!node || !localPos) return false;
const { padX, width, previewY, previewH } = bounds(node);
const [x, y] = localPos;
return x >= padX && x <= padX + width && y >= previewY && y <= previewY + previewH;
}
export function isTimelineImageInput(input) {
if (!input) return false;
const name = String(input.name || "").toLowerCase();
const type = String(input.type || "").toUpperCase();
return type === "IMAGE" || name === "image" || name === "images";
}
export function graphInputSlotPosition(node, input, index) {
if (typeof node.getInputPos === "function") return node.getInputPos(index);
if (typeof node.getConnectionPos === "function") return node.getConnectionPos(true, index);
if (Array.isArray(input.pos)) return [node.pos[0] + input.pos[0], node.pos[1] + input.pos[1]];
return [node.pos[0] + 10, node.pos[1] + (index + 0.7) * 20];
}
export function isTimelineImageInputPosition(node, graphPos) {
if (!node || !graphPos || !node.inputs) return false;
if (typeof node.getSlotInPosition === "function") {
const slot = node.getSlotInPosition(graphPos[0], graphPos[1]);
if (slot && isTimelineImageInput(slot.input)) return true;
}
if (typeof node.getInputOnPos === "function") {
const input = node.getInputOnPos(graphPos);
if (isTimelineImageInput(input)) return true;
}
for (let i = 0; i < node.inputs.length; i++) {
const input = node.inputs[i];
if (!isTimelineImageInput(input)) continue;
const pos = graphInputSlotPosition(node, input, i);
if (Math.abs(graphPos[0] - pos[0]) <= 18 && Math.abs(graphPos[1] - pos[1]) <= 14) return true;
}
return false;
}
export function previewTargetFromEvent(event) {
const graphPos = eventToGraphPosition(event);
const target = previewTargetFromGraphPosition(graphPos);
if (target) window.etkTimelineLastPreviewTarget = target;
return target;
}
export function currentPreviewTargetFromPointer() {
const canvas = app.canvas;
const graphMouse = canvas ? canvas.graph_mouse : null;
return previewTargetFromGraphPosition(graphMouse);
}
export function isSupportedImageFile(file) {
if (!file) return false;
if (file.type && /^image\/(png|jpe?g|webp)$/i.test(file.type)) return true;
return /\.(png|jpe?g|webp)$/i.test(file.name || "");
}
export function dragUriTypes(dataTransfer) {
if (!dataTransfer || !dataTransfer.types) return [];
const types = Array.from(dataTransfer.types);
return types.filter(
(type) =>
type === "text/uri-list" ||
type === "text/x-moz-url" ||
type === "text/plain" ||
type === LOCAL_MEDIA_MANAGER_DROP_TYPE
);
}
export function localMediaManagerUri(raw) {
if (!raw) return "";
try {
const payload = JSON.parse(raw);
const path = payload && (payload.path || payload.filepath || payload.filePath);
if (!path) return payload && payload.url ? String(payload.url) : "";
const base = typeof location !== "undefined" && location.href ? location.href : window.location && window.location.href;
const url = new URL("/local_image_gallery/view", base);
url.searchParams.set("filepath", path);
return url.toString();
} catch {
return String(raw || "").trim();
}
}
export function dragUriFromPayload(dataTransfer) {
const types = dragUriTypes(dataTransfer);
if (types.includes(LOCAL_MEDIA_MANAGER_DROP_TYPE)) {
const raw = dataTransfer.getData ? dataTransfer.getData(LOCAL_MEDIA_MANAGER_DROP_TYPE) : "";
const uri = localMediaManagerUri(raw);
if (uri) return uri;
}
for (const type of types) {
if (type === LOCAL_MEDIA_MANAGER_DROP_TYPE) continue;
const raw = dataTransfer.getData ? dataTransfer.getData(type) : "";
const uri = String(raw || "").split("\n").find((line) => line.trim() && !line.trim().startsWith("#"));
if (uri) return uri.trim();
}
return "";
}
export function normalizeDropUrl(url) {
if (!url) return null;
if (/\/local_image_gallery\/thumbnail$/i.test(url.pathname) && url.searchParams.has("filepath")) {
const viewUrl = new URL("/local_image_gallery/view", url);
viewUrl.searchParams.set("filepath", url.searchParams.get("filepath"));
return viewUrl;
}
return url;
}
export function isSupportedDropUrl(url) {
if (!url) return false;
if (/\/local_image_gallery\/view$/i.test(url.pathname)) return url.searchParams.has("filepath");
if (/\/view$/i.test(url.pathname)) return url.searchParams.has("filepath") || url.searchParams.has("filename");
if (url.searchParams.has("filename")) return true;
return /\.(png|jpe?g|webp)$/i.test(url.pathname);
}
export function sameOriginDropUrl(dataTransfer) {
const uri = dragUriFromPayload(dataTransfer);
if (!uri) return null;
try {
const base = typeof location !== "undefined" && location.href ? location.href : window.location && window.location.href;
const url = new URL(uri, base);
const origin = typeof location !== "undefined" && location.origin ? location.origin : window.location && window.location.origin;
if (origin && url.origin !== origin) return null;
const normalizedUrl = normalizeDropUrl(url);
if (!isSupportedDropUrl(normalizedUrl)) return null;
return normalizedUrl;
} catch {
return null;
}
}
export function fileNameFromDropUrl(url) {
const filepath = url.searchParams.get("filepath") || url.searchParams.get("filename") || url.pathname;
const cleanPath = String(filepath || "image.png").split(/[\\/]/).filter(Boolean).pop() || "image.png";
return /\.(png|jpe?g|webp)$/i.test(cleanPath) ? cleanPath : `${cleanPath}.png`;
}
export async function imageFileFromDropUrl(url) {
if (!url || typeof fetch !== "function") return null;
try {
const response = await fetch(url.toString());
if (!response || !response.ok) return null;
const blob = await response.blob();
const file = new File([blob], fileNameFromDropUrl(url), { type: blob.type || "image/png" });
return isSupportedImageFile(file) ? file : null;
} catch {
return null;
}
}
export function hasImageFilePayload(dataTransfer) {
if (!dataTransfer) return false;
if (dataTransfer.items && dataTransfer.items.length) {
const items = Array.from(dataTransfer.items);
if (items.some(
(item) =>
(item.kind === "file" && (!item.type || /^image\/(png|jpe?g|webp)$/i.test(item.type))) ||
(item.kind === "string" &&
(item.type === "text/uri-list" ||
item.type === "text/x-moz-url" ||
item.type === "text/plain" ||
item.type === LOCAL_MEDIA_MANAGER_DROP_TYPE))
)) {
return true;
}
}
if (dataTransfer.files && dataTransfer.files.length) return Array.from(dataTransfer.files).some(isSupportedImageFile);
if (dataTransfer.types && Array.from(dataTransfer.types).includes("Files")) return true;
return dragUriTypes(dataTransfer).length > 0;
}
export function imageFileFromPayload(payload) {
if (!payload) return null;
if (payload.files && payload.files.length) {
const file = Array.from(payload.files).find(isSupportedImageFile);
if (file) return file;
}
if (payload.items && payload.items.length) {
for (const item of Array.from(payload.items)) {
if (item.kind !== "file") continue;
const file = item.getAsFile ? item.getAsFile() : null;
if (isSupportedImageFile(file)) return file;
}
}
return null;
}
export function shouldPreferDropUrl(payload, dropUrl) {
if (!dropUrl) return false;
const types = payload && payload.types ? Array.from(payload.types) : [];
if (types.includes(LOCAL_MEDIA_MANAGER_DROP_TYPE)) return true;
return /\/local_image_gallery\/view$/i.test(dropUrl.pathname) && dropUrl.searchParams.has("filepath");
}
export async function imageFileFromDropPayload(payload, dropUrl = null) {
const resolvedDropUrl = dropUrl || sameOriginDropUrl(payload);
if (shouldPreferDropUrl(payload, resolvedDropUrl)) {
return (await imageFileFromDropUrl(resolvedDropUrl)) || imageFileFromPayload(payload);
}
return imageFileFromPayload(payload) || imageFileFromDropUrl(resolvedDropUrl);
}
export function setDropHoverNode(node) {
if (window.etkTimelineDropHoverNode === node) return;
if (window.etkTimelineDropHoverNode) {
window.etkTimelineDropHoverNode.etkTimelineDropHover = false;
window.etkTimelineDropHoverNode.setDirtyCanvas(true, true);
}
window.etkTimelineDropHoverNode = node || null;
if (node) {
node.etkTimelineDropHover = true;
node.setDirtyCanvas(true, true);
}
}
export function clearDropHoverNode() {
setDropHoverNode(null);
}
export function lastPreviewTarget() {
const target = window.etkTimelineLastPreviewTarget;
if (!target || !target.node || !target.node.graph) return null;
return target;
}
export function isEditableClipboardTarget(target) {
if (!target) return false;
const tagName = String(target.tagName || "").toUpperCase();
if (target.isContentEditable) return true;
if (tagName === "TEXTAREA" || tagName === "SELECT") return true;
if (tagName !== "INPUT") return false;
const inputType = String(target.type || "text").toLowerCase();
return !["button", "checkbox", "file", "hidden", "image", "radio", "range", "reset", "search", "submit"].includes(inputType);
}
export function hasSelectedDocumentText() {
if (typeof window.getSelection !== "function") return false;
const selection = window.getSelection();
return !!selection && String(selection.toString ? selection.toString() : "").trim().length > 0;
}
export function shouldIgnoreClipboardEvent(event) {
if (!event || event.defaultPrevented) return true;
if (event.shiftKey) return true;
return isEditableClipboardTarget(event.target) || hasSelectedDocumentText();
}
export function stopDropOrPasteEvent(event) {
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
}
export function stopKeyboardShortcutEvent(event) {
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
}
export async function uploadImageToNode(node, file, options = {}) {
if (!node || !file) return false;
if (rejectLayerEditIfLocked(node, "Image upload")) return false;
const keyframe = ensureSelectedKeyframe(node);
if (!keyframe) {
setTimelineStatus(node, "No free keyframe positions are available for this image.", "error");
return false;
}
await uploadSelectedImage(node, file, keyframe.id, options);
return true;
}
export async function uploadImageToPreviewTarget(target, file, options = {}) {
if (!target || !file) return false;
return uploadImageToNode(target.node, file, options);
}
export function installTimelinePasteFiles(node) {
if (node.etkTimelinePasteFilesInstalled) return;
const previousPasteFiles = typeof node.pasteFiles === "function" ? node.pasteFiles : null;
const installedPasteFiles = function (files) {
const file = Array.from(files || []).find(isSupportedImageFile);
if (!file) return previousPasteFiles ? previousPasteFiles.call(this, files) : false;
void uploadImageToNode(this || node, file, { pasted: true }).catch((error) => {
setTimelineStatus(this || node, `Pasted image upload failed: ${errorText(error)}`, "error");
console.error("[ETK timeline] Pasted image upload failed", error);
});
return true;
};
node.pasteFiles = installedPasteFiles;
node.etkTimelinePasteFilesInstalled = true;
const previousOnRemoved = node.onRemoved;
node.onRemoved = function () {
if (this.pasteFiles === installedPasteFiles) this.pasteFiles = previousPasteFiles || undefined;
if (previousOnRemoved) return previousOnRemoved.apply(this, arguments);
};
}
export function installTimelineDragDropHooks(node) {
if (node.etkTimelineDragDropInstalled) return;
const previousOnDragOver = typeof node.onDragOver === "function" ? node.onDragOver : null;
const previousOnDragDrop = typeof node.onDragDrop === "function" ? node.onDragDrop : null;
const installedOnDragOver = function (event) {
if (hasImageFilePayload(event && event.dataTransfer)) {
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
return true;
}
return previousOnDragOver ? previousOnDragOver.apply(this, arguments) : false;
};
const installedOnDragDrop = async function (event, claimEvent = true) {
const payload = event && event.dataTransfer;
const file = imageFileFromPayload(payload);
const dropUrl = sameOriginDropUrl(payload);
if (!file && !dropUrl) {
return previousOnDragDrop ? previousOnDragDrop.apply(this, arguments) : false;
}
if (claimEvent) stopDropOrPasteEvent(event);
try {
const resolvedFile = await imageFileFromDropPayload(payload, dropUrl);
if (!resolvedFile) return false;
await uploadImageToNode(this || node, resolvedFile);
return true;
} catch (error) {
setTimelineStatus(this || node, `Dropped image upload failed: ${errorText(error)}`, "error");
console.error("[ETK timeline] Dropped image upload failed", error);
return false;
}
};
node.onDragOver = installedOnDragOver;
node.onDragDrop = installedOnDragDrop;
node.etkTimelineDragDropInstalled = true;
const previousOnRemoved = node.onRemoved;
node.onRemoved = function () {
if (this.onDragOver === installedOnDragOver) this.onDragOver = previousOnDragOver || undefined;
if (this.onDragDrop === installedOnDragDrop) this.onDragDrop = previousOnDragDrop || undefined;
if (previousOnRemoved) return previousOnRemoved.apply(this, arguments);
};
}
export function installFileDropCapture() {
addTimelineWindowListener(
"fileDrop.mousemove",
"mousemove",
(event) => {
const target = previewTargetFromEvent(event);
if (target) window.etkTimelineLastPreviewTarget = target;
},
{ capture: true, passive: true }
);
addTimelineWindowListener(
"fileDrop.dragover",
"dragover",
(event) => {
const target = hasImageFilePayload(event.dataTransfer) ? previewTargetFromEvent(event) : null;
if (!target) {
clearDropHoverNode();
return;
}
setDropHoverNode(target.node);
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
stopDropOrPasteEvent(event);
},
{ capture: true, passive: false }
);
addTimelineWindowListener(
"fileDrop.drop",
"drop",
async (event) => {
const target = previewTargetFromEvent(event);
const file = target ? imageFileFromPayload(event.dataTransfer) : null;
const dropUrl = target ? sameOriginDropUrl(event.dataTransfer) : null;
clearDropHoverNode();
if (!target || (!file && !dropUrl)) return;
stopDropOrPasteEvent(event);
try {
const resolvedFile = await imageFileFromDropPayload(event.dataTransfer, dropUrl);
if (!resolvedFile) return;
await uploadImageToPreviewTarget(target, resolvedFile);
} catch (error) {
setTimelineStatus(target.node, `Dropped image upload failed: ${errorText(error)}`, "error");
console.error("[ETK timeline] Dropped image upload failed", error);
}
},
{ capture: true, passive: false }
);
addTimelineWindowListener("fileDrop.dragend", "dragend", clearDropHoverNode, true);
addTimelineWindowListener(
"fileDrop.dragleave",
"dragleave",
(event) => {
if (event.clientX <= 0 || event.clientY <= 0 || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight) {
clearDropHoverNode();
}
},
true
);
}
export function installClipboardPasteCapture() {
addTimelineWindowListener(
"clipboardPaste.paste",
"paste",
async (event) => {
if (shouldIgnoreClipboardEvent(event)) return;
const target = currentPreviewTargetFromPointer();
const file = target ? imageFileFromPayload(event.clipboardData) : null;
const dropUrl = target ? sameOriginDropUrl(event.clipboardData) : null;
if (!target || (!file && !dropUrl)) return;
stopDropOrPasteEvent(event);
try {
const resolvedFile = await imageFileFromDropPayload(event.clipboardData, dropUrl);
if (!resolvedFile) return;
await uploadImageToPreviewTarget(target, resolvedFile, { pasted: true });
} catch (error) {
setTimelineStatus(target.node, `Pasted image upload failed: ${errorText(error)}`, "error");
console.error("[ETK timeline] Pasted image upload failed", error);
}
},
{ capture: true, passive: false }
);
}
+753
View File
@@ -0,0 +1,753 @@
// Timeline/keyframe/layer normalization, serialization, selection, and mutation.
import { api } from "../../../../scripts/api.js";
import {
CONTROL_TABS,
CONTROL_TAB_PROPERTY,
DEFAULT_TEXT_LAYER,
LAYER_LOCK_PROPERTY,
MAX_LAYER_ZOOM,
MIN_LAYER_ZOOM,
TEXT_LAYER_FIELDS,
TIMELINE_SCHEMA_VERSION,
UPLOAD_MODE_PROPERTY,
} from "./constants.js";
import {
clamp,
getNumericControlValue,
getWidgetValue,
makeId,
setTimelineStatus,
setWidgetValue,
} from "./runtime.js";
import { syncKeyframePromptEditor, syncTextLayerEditor } from "./editors.js";
import { setupNode } from "./node_setup.js";
import { nearestFreeSlot, slotToSeconds, timelineFps } from "./timeline_ops.js";
export function stopDomEvent(event) {
if (!event) return;
event.stopPropagation();
}
export function normalizeFileInfo(fileInfo) {
if (!fileInfo) return null;
if (typeof fileInfo === "string") {
return { filename: fileInfo, subfolder: "", type: "input" };
}
const filename = fileInfo.filename || fileInfo.name;
if (!filename) return null;
return {
filename,
subfolder: fileInfo.subfolder || "",
type: fileInfo.type || "input",
};
}
export function normalizeTime(value) {
const seconds = Number(value);
return Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
}
export function keyframeTime(keyframe, node = null) {
if (!keyframe || typeof keyframe !== "object") return 0;
if (keyframe.time != null) return normalizeTime(keyframe.time);
if (keyframe.slot != null) {
const slot = Number(keyframe.slot);
return Number.isFinite(slot) ? Math.max(0, slot) * 8 / timelineFps(node) : 0;
}
return 0;
}
export function normalizeSlot(keyframe, node = null) {
if (keyframe && keyframe.time == null && keyframe.slot != null) {
const slot = Number(keyframe.slot);
return Number.isFinite(slot) ? Math.max(0, Math.round(slot)) : 0;
}
return Math.max(0, Math.round(keyframeTime(keyframe, node) * timelineFps(node) / 8));
}
export function normalizePan(value) {
const pan = Number(value);
return Number.isFinite(pan) ? pan : 0;
}
export function normalizeZoom(value) {
const zoom = Number(value);
return Number.isFinite(zoom) ? clamp(zoom, MIN_LAYER_ZOOM, MAX_LAYER_ZOOM) : 1;
}
export function normalizeFilterValue(value) {
const amount = Number(value);
return Number.isFinite(amount) ? clamp(amount, 0, 2) : 1;
}
export function rawFilterFieldProvided(value, field) {
return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, field) && value[field] != null);
}
export function parsedTimelinePayload(value, { unwrapUiArray = false } = {}) {
let source = value;
if (unwrapUiArray && Array.isArray(source)) source = source.length ? source[0] : null;
if (typeof source === "string") {
try {
source = JSON.parse(source || "[]");
} catch {
source = [];
}
}
return source;
}
export function timelinePayloadKeyframes(value, options = {}) {
const source = parsedTimelinePayload(value, options);
if (Array.isArray(source)) return source;
if (source && typeof source === "object" && Array.isArray(source.keyframes)) return source.keyframes;
return [];
}
export function rawTimelineKeyframes(payload) {
return timelinePayloadKeyframes(payload, { unwrapUiArray: true });
}
export function timelineReloadSourceScore(value) {
const keyframes = rawTimelineKeyframes(value);
let layers = 0;
let textLayers = 0;
let textStyleFields = 0;
for (const keyframe of keyframes) {
if (!keyframe || typeof keyframe !== "object" || !Array.isArray(keyframe.layers)) continue;
for (const layer of keyframe.layers) {
if (!layer || typeof layer !== "object") continue;
layers += 1;
if (!isTextLayer(layer)) continue;
textLayers += 1;
for (const field of TEXT_LAYER_FIELDS) {
if (Object.prototype.hasOwnProperty.call(layer, field) && layer[field] != null) textStyleFields += 1;
}
}
}
return { keyframes: keyframes.length, layers, textLayers, textStyleFields };
}
export function timelineReloadSource(node, useWidgetValue = false) {
const widgetSource = getWidgetValue(node, "timeline_json", "[]");
const propertySource = node.properties ? node.properties.etk_ltxv_timeline_keyframes : null;
if (useWidgetValue || propertySource == null) return widgetSource;
const propertyScore = timelineReloadSourceScore(propertySource);
const widgetScore = timelineReloadSourceScore(widgetSource);
if (propertyScore.keyframes === 0 && widgetScore.keyframes > 0) return widgetSource;
if (widgetScore.textLayers > propertyScore.textLayers && widgetScore.keyframes >= propertyScore.keyframes) return widgetSource;
if (widgetScore.textLayers === propertyScore.textLayers && widgetScore.textStyleFields > propertyScore.textStyleFields && widgetScore.keyframes >= propertyScore.keyframes) {
return widgetSource;
}
return propertySource;
}
export function fileInfoKey(fileInfo) {
const normalized = normalizeFileInfo(fileInfo);
return normalized ? `${normalized.type}\n${normalized.subfolder}\n${normalized.filename}` : "";
}
export function layerMatchKey(layer) {
if (!layer || typeof layer !== "object") return "";
if (layer.id) return `id:${layer.id}`;
const key = fileInfoKey(layer.image || layer.file || layer.filename);
return key ? `image:${key}` : "";
}
export function matchingExistingLayer(existingLayers, layer, index) {
const key = layerMatchKey(layer);
if (key) {
const matched = existingLayers.find((candidate) => layerMatchKey(candidate) === key);
if (matched) return matched;
}
return existingLayers[index] || null;
}
export function matchingExistingKeyframe(existingKeyframes, keyframe, index) {
if (keyframe && typeof keyframe === "object" && keyframe.id) {
const matched = existingKeyframes.find((candidate) => candidate.id === keyframe.id);
if (matched) return matched;
}
const time = keyframeTime(keyframe);
const matchedByTime = existingKeyframes.find((candidate) => Math.abs(keyframeTime(candidate) - time) < 1e-6);
if (matchedByTime) return matchedByTime;
const slot = normalizeSlot(keyframe);
const matchedBySlot = existingKeyframes.find((candidate) => candidate.slot === slot);
if (matchedBySlot) return matchedBySlot;
return existingKeyframes[index] || null;
}
export function preserveExecutedFilterFields(node, payload) {
const existingKeyframes = normalizeKeyframes(node.properties ? node.properties.etk_ltxv_timeline_keyframes : [], node);
const keyframes = rawTimelineKeyframes(payload);
if (!existingKeyframes.length || !keyframes.length) return normalizeKeyframes(payload, node);
const merged = keyframes.map((keyframe, keyframeIndex) => {
if (!keyframe || typeof keyframe !== "object") return keyframe;
const existing = matchingExistingKeyframe(existingKeyframes, keyframe, keyframeIndex);
if (!existing) return keyframe;
const mergedKeyframe = { ...keyframe };
const keyframeHasBrightness = rawFilterFieldProvided(keyframe, "brightness");
const keyframeHasContrast = rawFilterFieldProvided(keyframe, "contrast");
if (!keyframeHasBrightness && existing.brightness != null) mergedKeyframe.brightness = existing.brightness;
if (!keyframeHasContrast && existing.contrast != null) mergedKeyframe.contrast = existing.contrast;
if (Array.isArray(keyframe.layers)) {
const existingLayers = Array.isArray(existing.layers) ? existing.layers : [];
mergedKeyframe.layers = keyframe.layers.map((layer, layerIndex) => {
if (!layer || typeof layer !== "object") return layer;
const existingLayer = matchingExistingLayer(existingLayers, layer, layerIndex);
if (!existingLayer) return layer;
const mergedLayer = { ...layer };
if (!rawFilterFieldProvided(layer, "brightness") && !keyframeHasBrightness && existingLayer.brightness != null) {
mergedLayer.brightness = existingLayer.brightness;
}
if (!rawFilterFieldProvided(layer, "contrast") && !keyframeHasContrast && existingLayer.contrast != null) {
mergedLayer.contrast = existingLayer.contrast;
}
return mergedLayer;
});
}
return mergedKeyframe;
});
return normalizeKeyframes(merged, node);
}
export function normalizePromptStrength(value) {
const strength = Number(value);
return Number.isFinite(strength) ? clamp(strength, 0, 4) : 1;
}
export function normalizeOptionalLatentStrength(keyframe) {
if (!keyframe || (keyframe.latentStrength == null && keyframe.latent_strength == null && keyframe.strength == null)) {
return undefined;
}
const strength = Number(keyframe.latentStrength != null ? keyframe.latentStrength : keyframe.latent_strength != null ? keyframe.latent_strength : keyframe.strength);
return Number.isFinite(strength) ? clamp(strength, 0, 1) : undefined;
}
export function normalizeLatentStrengthValue(value, fallback = 1) {
const strength = Number(value);
if (Number.isFinite(strength)) return clamp(strength, 0, 1);
return clamp(Number(fallback) || 1, 0, 1);
}
export function displayLatentStrength(node, keyframe) {
const explicit = normalizeOptionalLatentStrength(keyframe);
if (explicit != null) return explicit;
return normalizeLatentStrengthValue(getNumericControlValue(node, "strength", 1, { min: 0 }), 1);
}
export function normalizePrompt(value) {
return typeof value === "string" ? value : value == null ? "" : String(value);
}
export function normalizeTextLayerString(value, fallback = "") {
if (value == null) return String(fallback || "");
return String(value);
}
export function normalizeTextLayerFontFamily(value, fallback = DEFAULT_TEXT_LAYER.fontFamily) {
const family = normalizeTextLayerString(value, fallback).trim();
return family || DEFAULT_TEXT_LAYER.fontFamily;
}
export function normalizeTextLayerFontSize(value, fallback = DEFAULT_TEXT_LAYER.fontSize) {
const size = Number(value);
return Number.isFinite(size) ? clamp(size, 1, 512) : clamp(Number(fallback) || DEFAULT_TEXT_LAYER.fontSize, 1, 512);
}
export function normalizeTextLayerBool(value, fallback = false) {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "bold", "italic", "on"].includes(normalized)) return true;
if (["false", "0", "no", "normal", "off"].includes(normalized)) return false;
}
if (typeof value === "number") return value !== 0;
return Boolean(fallback);
}
export function normalizeTextLayerBold(value, fallback = DEFAULT_TEXT_LAYER.bold) {
return normalizeTextLayerBool(value, fallback);
}
export function normalizeTextLayerItalic(value, fallback = DEFAULT_TEXT_LAYER.italic) {
return normalizeTextLayerBool(value, fallback);
}
export function normalizeTextLayerColor(value, fallback = DEFAULT_TEXT_LAYER.color) {
const text = normalizeTextLayerString(value, fallback).trim();
const match = text.match(/^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/);
return match ? `#${match[1].toLowerCase()}` : fallback;
}
export function normalizeTextLayerAlign(value, fallback = DEFAULT_TEXT_LAYER.align) {
const normalized = normalizeTextLayerString(value, fallback).trim().toLowerCase();
return ["left", "center", "right"].includes(normalized) ? normalized : fallback;
}
export function normalizeTextLayerNumber(value, fallback, min, max) {
const number = Number(value);
const base = Number.isFinite(number) ? number : Number(fallback);
return clamp(Number.isFinite(base) ? base : min, min, max);
}
export function ensureTextLayerFontOption(select, value) {
if (!select) return;
const font = normalizeTextLayerFontFamily(value, DEFAULT_TEXT_LAYER.fontFamily);
const options = Array.from(select.options || select.children || []);
if (options.some((option) => option && option.value === font)) return;
const option = document.createElement("option");
option.value = font;
option.textContent = font;
select.appendChild(option);
}
export function isTextLayer(layer) {
if (!layer || typeof layer !== "object") return false;
const layerType = String(layer.type != null ? layer.type : layer.layerType != null ? layer.layerType : layer.layer_type != null ? layer.layer_type : "").toLowerCase();
if (layerType === "text") return true;
return TEXT_LAYER_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(layer, field));
}
export function normalizeLayer(layer, fallback = {}) {
const source = layer && typeof layer === "object" ? layer : {};
const textLayer = isTextLayer(source) || fallback.type === "text";
const image = textLayer ? null : normalizeFileInfo(source.image || source.file || source.filename || fallback.image);
const hasLayerSlot =
image ||
textLayer ||
source.id ||
source.empty ||
Object.prototype.hasOwnProperty.call(source, "image") ||
Object.prototype.hasOwnProperty.call(source, "file") ||
Object.prototype.hasOwnProperty.call(source, "filename") ||
Object.prototype.hasOwnProperty.call(source, "type") ||
Object.prototype.hasOwnProperty.call(source, "layerType") ||
Object.prototype.hasOwnProperty.call(source, "layer_type") ||
TEXT_LAYER_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(source, field));
if (!hasLayerSlot) return null;
const normalized = {
id: source.id || fallback.id || makeId(),
type: textLayer ? "text" : "image",
image,
empty: !image && !textLayer,
fitMode: source.fitMode === "pad" || source.fit_mode === "pad" || fallback.fitMode === "pad" ? "pad" : "crop",
panX: normalizePan(source.panX != null ? source.panX : source.pan_x != null ? source.pan_x : fallback.panX),
panY: normalizePan(source.panY != null ? source.panY : source.pan_y != null ? source.pan_y : fallback.panY),
zoom: normalizeZoom(source.zoom != null ? source.zoom : fallback.zoom),
brightness: normalizeFilterValue(source.brightness != null ? source.brightness : fallback.brightness),
contrast: normalizeFilterValue(source.contrast != null ? source.contrast : fallback.contrast),
};
if (textLayer) {
normalized.text = normalizeTextLayerString(source.text, fallback.text != null ? fallback.text : DEFAULT_TEXT_LAYER.text);
normalized.fontFamily = normalizeTextLayerFontFamily(source.fontFamily != null ? source.fontFamily : source.font_family, fallback.fontFamily || fallback.font_family || DEFAULT_TEXT_LAYER.fontFamily);
normalized.fontSize = normalizeTextLayerFontSize(source.fontSize != null ? source.fontSize : source.font_size, fallback.fontSize || fallback.font_size || DEFAULT_TEXT_LAYER.fontSize);
normalized.bold = normalizeTextLayerBold(source.bold, fallback.bold);
normalized.italic = normalizeTextLayerItalic(source.italic, fallback.italic);
normalized.align = normalizeTextLayerAlign(source.align != null ? source.align : source.textAlign != null ? source.textAlign : source.text_align, fallback.align || fallback.textAlign || fallback.text_align || DEFAULT_TEXT_LAYER.align);
normalized.color = normalizeTextLayerColor(source.color, fallback.color || DEFAULT_TEXT_LAYER.color);
normalized.outlineColor = normalizeTextLayerColor(source.outlineColor != null ? source.outlineColor : source.outline_color, fallback.outlineColor || fallback.outline_color || DEFAULT_TEXT_LAYER.outlineColor);
normalized.outlineWidth = normalizeTextLayerNumber(source.outlineWidth != null ? source.outlineWidth : source.outline_width, fallback.outlineWidth != null ? fallback.outlineWidth : fallback.outline_width != null ? fallback.outline_width : DEFAULT_TEXT_LAYER.outlineWidth, 0, 64);
normalized.shadowColor = normalizeTextLayerColor(source.shadowColor != null ? source.shadowColor : source.shadow_color, fallback.shadowColor || fallback.shadow_color || DEFAULT_TEXT_LAYER.shadowColor);
normalized.shadowBlur = normalizeTextLayerNumber(source.shadowBlur != null ? source.shadowBlur : source.shadow_blur, fallback.shadowBlur != null ? fallback.shadowBlur : fallback.shadow_blur != null ? fallback.shadow_blur : DEFAULT_TEXT_LAYER.shadowBlur, 0, 128);
normalized.shadowOffsetX = normalizeTextLayerNumber(source.shadowOffsetX != null ? source.shadowOffsetX : source.shadow_offset_x, fallback.shadowOffsetX != null ? fallback.shadowOffsetX : fallback.shadow_offset_x != null ? fallback.shadow_offset_x : DEFAULT_TEXT_LAYER.shadowOffsetX, -512, 512);
normalized.shadowOffsetY = normalizeTextLayerNumber(source.shadowOffsetY != null ? source.shadowOffsetY : source.shadow_offset_y, fallback.shadowOffsetY != null ? fallback.shadowOffsetY : fallback.shadow_offset_y != null ? fallback.shadow_offset_y : DEFAULT_TEXT_LAYER.shadowOffsetY, -512, 512);
}
return normalized;
}
export function mirrorBaseLayer(keyframe) {
const layers = Array.isArray(keyframe.layers) ? keyframe.layers : [];
const base = layers[0] || null;
keyframe.image = base ? base.image : normalizeFileInfo(keyframe.image);
keyframe.fitMode = base ? base.fitMode : keyframe.fitMode === "pad" ? "pad" : "crop";
keyframe.panX = base ? base.panX : normalizePan(keyframe.panX);
keyframe.panY = base ? base.panY : normalizePan(keyframe.panY);
keyframe.zoom = base ? base.zoom : normalizeZoom(keyframe.zoom);
keyframe.brightness = base ? normalizeFilterValue(base.brightness) : normalizeFilterValue(keyframe.brightness);
keyframe.contrast = base ? normalizeFilterValue(base.contrast) : normalizeFilterValue(keyframe.contrast);
if (layers.length) {
const selectedLayerId = keyframe.selectedLayerId || keyframe.selected_layer_id;
keyframe.selectedLayerId = layers.some((layer) => layer.id === selectedLayerId) ? selectedLayerId : layers[0].id;
} else {
delete keyframe.selectedLayerId;
}
return keyframe;
}
export function normalizeLayers(keyframe, image, fallback) {
const layers = [];
if (Array.isArray(keyframe.layers)) {
for (const layer of keyframe.layers) {
const normalized = normalizeLayer(layer, { ...fallback, image: null });
if (normalized) layers.push(normalized);
}
}
if (!layers.length && image) {
const normalized = normalizeLayer({ image }, { ...fallback, id: keyframe.selectedLayerId || keyframe.selected_layer_id || makeId() });
if (normalized) layers.push(normalized);
}
return layers;
}
export function normalizeKeyframes(value, node = null) {
const source = timelinePayloadKeyframes(value);
return source
.map((keyframe) => {
if (!keyframe || typeof keyframe !== "object") return null;
const image = normalizeFileInfo(keyframe.image || keyframe.file || keyframe.filename);
const fallback = {
image,
fitMode: keyframe.fitMode === "pad" || keyframe.fit_mode === "pad" ? "pad" : "crop",
panX: keyframe.panX != null ? keyframe.panX : keyframe.pan_x,
panY: keyframe.panY != null ? keyframe.panY : keyframe.pan_y,
zoom: keyframe.zoom,
brightness: keyframe.brightness,
contrast: keyframe.contrast,
};
const normalized = {
id: keyframe.id || makeId(),
time: keyframeTime(keyframe, node),
slot: normalizeSlot(keyframe, node),
fitMode: fallback.fitMode,
panX: normalizePan(fallback.panX),
panY: normalizePan(fallback.panY),
zoom: normalizeZoom(fallback.zoom),
brightness: normalizeFilterValue(fallback.brightness),
contrast: normalizeFilterValue(fallback.contrast),
positivePrompt: normalizePrompt(keyframe.positivePrompt != null ? keyframe.positivePrompt : keyframe.positive_prompt),
promptStrength: normalizePromptStrength(keyframe.promptStrength != null ? keyframe.promptStrength : keyframe.prompt_strength),
inputImage: Boolean(keyframe.inputImage || keyframe.input_image || keyframe.connectedImage || keyframe.connected_image),
selectedLayerId: keyframe.selectedLayerId || keyframe.selected_layer_id,
layers: normalizeLayers(keyframe, image, fallback),
image,
};
mirrorBaseLayer(normalized);
const latentStrength = normalizeOptionalLatentStrength(keyframe);
if (latentStrength != null) normalized.latentStrength = latentStrength;
return normalized;
})
.filter(Boolean)
.sort((a, b) => a.time - b.time || a.slot - b.slot);
}
export function serializableTimelineKeyframes(node, keyframes = null) {
return (keyframes || normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node)).map((keyframe) => {
const serialized = { ...keyframe, time: normalizeTime(keyframe.time) };
delete serialized.slot;
delete serialized.frame;
return serialized;
});
}
export function serializableTimelinePayload(node, keyframes = null) {
return {
schemaVersion: TIMELINE_SCHEMA_VERSION,
keyframes: serializableTimelineKeyframes(node, keyframes),
};
}
export function serializedKeyframes(node) {
return JSON.stringify(serializableTimelinePayload(node));
}
export function syncTimelineWidget(node) {
setWidgetValue(node, "timeline_json", serializedKeyframes(node));
}
export function refreshTimeline(node) {
setupNode(node);
node.properties.etk_ltxv_timeline_keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const keyframes = node.properties.etk_ltxv_timeline_keyframes;
let selectedIndex = selectedKeyframeIndex(node, keyframes);
if (selectedIndex < 0 && keyframes.length) selectedIndex = 0;
if (selectedIndex >= keyframes.length) selectedIndex = keyframes.length - 1;
if (selectedIndex >= 0) {
setSelectedKeyframe(node, selectedIndex, keyframes);
} else {
setSelectedKeyframe(node, -1, keyframes);
}
for (const keyframe of keyframes) preloadImage(node, keyframe);
syncTimelineWidget(node);
setTimelineStatus(node, "Timeline previews refreshed.", "success");
node.setDirtyCanvas(true, true);
if (node.graph && typeof node.graph.setDirtyCanvas === "function") {
node.graph.setDirtyCanvas(true, true);
}
}
export function viewUrl(fileInfo) {
const normalized = normalizeFileInfo(fileInfo);
if (!normalized) return null;
const params = new URLSearchParams();
params.set("filename", normalized.filename);
params.set("type", normalized.type);
if (normalized.subfolder) params.set("subfolder", normalized.subfolder);
return api.apiURL(`/view?${params.toString()}`);
}
export function preloadImage(node, keyframe) {
if (!keyframe) return;
if (!node.etkTimelineImageCache) node.etkTimelineImageCache = new Map();
const files = [];
if (Array.isArray(keyframe.layers)) {
for (const layer of keyframe.layers) files.push(layer.image);
}
if (!files.length && keyframe.image) files.push(keyframe.image);
for (const fileInfo of files) {
const url = viewUrl(fileInfo);
if (!url || node.etkTimelineImageCache.get(url)) continue;
const image = new Image();
image.onload = () => node.setDirtyCanvas(true, true);
image.onerror = () => {
image.etkTimelineLoadFailed = true;
node.setDirtyCanvas(true, true);
};
image.src = url;
node.etkTimelineImageCache.set(url, image);
}
}
export function setSelectedKeyframe(node, index, keyframes) {
const frames = keyframes || normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
if (index == null || index < 0 || index >= frames.length) {
node.properties.etk_ltxv_selected_keyframe = -1;
node.properties.etk_ltxv_selected_keyframe_id = null;
syncKeyframePromptEditor(node);
syncTextLayerEditor(node);
return;
}
node.properties.etk_ltxv_selected_keyframe = index;
node.properties.etk_ltxv_selected_keyframe_id = frames[index].id;
syncKeyframePromptEditor(node, frames[index]);
syncTextLayerEditor(node);
}
export function selectedKeyframeIndex(node, keyframes) {
const frames = keyframes || normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const selectedId = node.properties.etk_ltxv_selected_keyframe_id;
if (selectedId) {
const idIndex = frames.findIndex((keyframe) => keyframe.id === selectedId);
if (idIndex >= 0) {
node.properties.etk_ltxv_selected_keyframe = idIndex;
return idIndex;
}
}
const index = node.properties.etk_ltxv_selected_keyframe;
if (index == null || index < 0 || index >= frames.length) return -1;
node.properties.etk_ltxv_selected_keyframe_id = frames[index].id;
return index;
}
export function selectedKeyframe(node) {
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const index = selectedKeyframeIndex(node, keyframes);
return index >= 0 ? keyframes[index] : null;
}
export function ensureSelectedKeyframe(node) {
setupNode(node);
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
let index = selectedKeyframeIndex(node, keyframes);
if (index >= 0) return keyframes[index];
if (keyframes.length > 0) {
setSelectedKeyframe(node, 0, keyframes);
syncTimelineWidget(node);
node.setDirtyCanvas(true, true);
return keyframes[0];
}
const slot = nearestFreeSlot(node, 0);
if (slot == null) return null;
const keyframe = { id: makeId(), time: slotToSeconds(node, slot), slot, fitMode: "crop", panX: 0, panY: 0, zoom: 1, image: null, layers: [] };
keyframes.push(keyframe);
node.properties.etk_ltxv_timeline_keyframes = keyframes;
setSelectedKeyframe(node, 0, keyframes);
syncTimelineWidget(node);
node.setDirtyCanvas(true, true);
return keyframe;
}
export function updateKeyframe(node, keyframeId, changes) {
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const index = keyframes.findIndex((keyframe) => keyframe.id === keyframeId);
if (index < 0) return null;
keyframes[index] = { ...keyframes[index], ...changes };
node.properties.etk_ltxv_timeline_keyframes = keyframes;
setSelectedKeyframe(node, index, keyframes);
syncTimelineWidget(node);
node.setDirtyCanvas(true, true);
return keyframes[index];
}
export function uploadMode(node) {
if (!node.properties) node.properties = {};
if (node.properties[UPLOAD_MODE_PROPERTY] !== "layer") {
node.properties[UPLOAD_MODE_PROPERTY] = "replace";
}
return node.properties[UPLOAD_MODE_PROPERTY];
}
export function toggleUploadMode(node) {
node.properties[UPLOAD_MODE_PROPERTY] = uploadMode(node) === "layer" ? "replace" : "layer";
setTimelineStatus(node, node.properties[UPLOAD_MODE_PROPERTY] === "layer" ? "Upload mode: add new layers." : "Upload mode: replace active layer.", "info");
node.setDirtyCanvas(true, true);
}
export function layerEditingLocked(node) {
if (!node) return false;
if (!node.properties) node.properties = {};
const locked = node.properties[LAYER_LOCK_PROPERTY] === true || node.properties[LAYER_LOCK_PROPERTY] === "true";
node.properties[LAYER_LOCK_PROPERTY] = locked;
return locked;
}
export function setLayerEditingLocked(node, locked) {
if (!node) return false;
if (!node.properties) node.properties = {};
const next = Boolean(locked);
node.properties[LAYER_LOCK_PROPERTY] = next;
setTimelineStatus(node, next ? "Layer editing locked." : "Layer editing unlocked.", next ? "info" : "success");
syncTextLayerEditor(node);
if (typeof node.setDirtyCanvas === "function") node.setDirtyCanvas(true, true);
if (node.graph && typeof node.graph.setDirtyCanvas === "function") node.graph.setDirtyCanvas(true, true);
return next;
}
export function toggleLayerEditingLock(node) {
return setLayerEditingLocked(node, !layerEditingLocked(node));
}
export function rejectLayerEditIfLocked(node, action = "Layer edit") {
if (!layerEditingLocked(node)) return false;
setTimelineStatus(node, `${action} blocked: layer editing is locked.`, "error");
if (typeof node.setDirtyCanvas === "function") node.setDirtyCanvas(true, true);
return true;
}
export function normalizeControlsTab(value) {
const tab = String(value || "");
if (tab === "image") return "layers";
return CONTROL_TABS.some((candidate) => candidate.id === tab) ? tab : "strength";
}
export function controlsTab(node) {
if (!node.properties) node.properties = {};
const tab = normalizeControlsTab(node.properties[CONTROL_TAB_PROPERTY]);
node.properties[CONTROL_TAB_PROPERTY] = tab;
return tab;
}
export function setControlsTab(node, tab) {
if (!node || !node.properties) return false;
const next = normalizeControlsTab(tab);
node.properties[CONTROL_TAB_PROPERTY] = next;
if (typeof node.setDirtyCanvas === "function") node.setDirtyCanvas(true, true);
if (node.graph && typeof node.graph.setDirtyCanvas === "function") node.graph.setDirtyCanvas(true, true);
return true;
}
export function keyframeHasImageSource(keyframe) {
return Boolean(
keyframe &&
(keyframe.image ||
keyframe.inputImage ||
(Array.isArray(keyframe.layers) && keyframe.layers.some((layer) => layer.image || isTextLayer(layer))))
);
}
export function activeLayerIndex(keyframe) {
const layers = Array.isArray(keyframe && keyframe.layers) ? keyframe.layers : [];
if (!layers.length) return -1;
const selectedLayerId = keyframe.selectedLayerId || keyframe.selected_layer_id;
const index = selectedLayerId ? layers.findIndex((layer) => layer.id === selectedLayerId) : -1;
return index >= 0 ? index : 0;
}
export function activeLayer(keyframe) {
const index = activeLayerIndex(keyframe);
return index >= 0 ? keyframe.layers[index] : null;
}
export function isEmptyLayer(layer) {
return !layer || (!isTextLayer(layer) && !normalizeFileInfo(layer.image));
}
export function updateActiveLayer(node, keyframeId, updater) {
if (rejectLayerEditIfLocked(node)) return null;
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const keyframeIndex = keyframes.findIndex((keyframe) => keyframe.id === keyframeId);
if (keyframeIndex < 0) return null;
const keyframe = keyframes[keyframeIndex];
const layerIndex = activeLayerIndex(keyframe);
if (layerIndex < 0) return null;
const currentLayer = keyframe.layers[layerIndex];
const nextLayer = updater({ ...currentLayer, image: currentLayer.image ? { ...currentLayer.image } : null });
if (!nextLayer) return null;
keyframe.layers[layerIndex] = normalizeLayer(nextLayer, { id: currentLayer.id }) || keyframe.layers[layerIndex];
keyframe.selectedLayerId = keyframe.layers[layerIndex].id;
mirrorBaseLayer(keyframe);
keyframes[keyframeIndex] = keyframe;
node.properties.etk_ltxv_timeline_keyframes = keyframes;
setSelectedKeyframe(node, keyframeIndex, keyframes);
syncTimelineWidget(node);
node.setDirtyCanvas(true, true);
return keyframe.layers[layerIndex];
}
export function setActiveLayerId(node, keyframeId, layerId) {
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const index = keyframes.findIndex((keyframe) => keyframe.id === keyframeId);
if (index < 0) return false;
if (!keyframes[index].layers.some((layer) => layer.id === layerId)) return false;
keyframes[index].selectedLayerId = layerId;
node.properties.etk_ltxv_timeline_keyframes = keyframes;
setSelectedKeyframe(node, index, keyframes);
syncTimelineWidget(node);
syncTextLayerEditor(node);
node.setDirtyCanvas(true, true);
return true;
}
export function deleteActiveLayer(node, keyframeId) {
if (rejectLayerEditIfLocked(node, "Layer delete")) return false;
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const index = keyframes.findIndex((keyframe) => keyframe.id === keyframeId);
if (index < 0) return false;
const keyframe = keyframes[index];
const layers = Array.isArray(keyframe.layers) ? keyframe.layers.slice() : [];
if (!layers.length) return false;
const layerIndex = activeLayerIndex(keyframe);
if (layerIndex < 0) return false;
if (isEmptyLayer(layers[layerIndex]) && layers.length > 1) {
layers.splice(layerIndex, 1);
} else {
layers[layerIndex] = normalizeLayer({
id: layers[layerIndex].id,
image: null,
empty: true,
fitMode: "crop",
panX: 0,
panY: 0,
zoom: 1,
brightness: 1,
contrast: 1,
}, { id: layers[layerIndex].id }) || layers[layerIndex];
}
const nextIndex = Math.min(layerIndex, layers.length - 1);
keyframe.layers = layers;
keyframe.selectedLayerId = layers[nextIndex] ? layers[nextIndex].id : null;
mirrorBaseLayer(keyframe);
keyframes[index] = keyframe;
node.properties.etk_ltxv_timeline_keyframes = keyframes;
setSelectedKeyframe(node, index, keyframes);
syncTimelineWidget(node);
syncTextLayerEditor(node);
node.setDirtyCanvas(true, true);
return true;
}
+233
View File
@@ -0,0 +1,233 @@
// Timeline widget creation, canvas sizing, and per-node setup.
import {
CANVAS_SIZE_PROPERTY,
OUTPUT_SETTINGS_PROPERTY,
TIMELINE_EDITOR_HEIGHT,
TIMELINE_EDITOR_WIDGET,
} from "./constants.js";
import {
ensureUiInstanceId,
errorText,
getWidget,
getWidgetValue,
hideWidget,
normalizeTimelineSettings,
setTimelineStatus,
} from "./runtime.js";
import {
controlsTab,
layerEditingLocked,
normalizeKeyframes,
preloadImage,
selectedKeyframe,
selectedKeyframeIndex,
setSelectedKeyframe,
syncTimelineWidget,
uploadMode,
} from "./model.js";
import {
clarifyDefaultGuideStrengthWidget,
ensureKeyframePromptEditorWidget,
ensureTextLayerEditorWidget,
fixPromptWidgetHeights,
migrateDisableRelayWidgetOrder,
syncChainInheritedWidgets,
timelineEditorWidget,
} from "./editors.js";
import { drawTimeline } from "./rendering.js";
import { installTimelineDragDropHooks, installTimelinePasteFiles, uploadSelectedImage } from "./media.js";
export function ensureTimelineEditorWidget(node) {
if (timelineEditorWidget(node)) return;
const widget = {
type: "custom",
name: TIMELINE_EDITOR_WIDGET,
value: null,
serialize: false,
options: { serialize: false, canvasOnly: true },
computeLayoutSize() {
return { minHeight: TIMELINE_EDITOR_HEIGHT, minWidth: 460 };
},
draw(ctx, owner, width, y, widgetHeight) {
const height = Math.max(
Number.isFinite(this.computedHeight)
? this.computedHeight
: Number.isFinite(widgetHeight)
? widgetHeight
: TIMELINE_EDITOR_HEIGHT,
TIMELINE_EDITOR_HEIGHT
);
this.last_y = y;
this.last_h = height;
this.computedHeight = height;
owner.etkTimelineEditorY = y;
owner.etkTimelineEditorHeight = height;
drawTimeline(owner, ctx, y, height);
},
mouse(event, pos, owner) {
const type = event && event.type ? event.type : "";
if (type.includes("move")) {
return owner.onMouseMove ? owner.onMouseMove(event, pos) : false;
}
if (type.includes("up")) {
return owner.onMouseUp ? owner.onMouseUp(event, pos) : false;
}
if (type.includes("down") || type === "mousedown" || !type) {
return owner.onMouseDown ? owner.onMouseDown(event, pos) : false;
}
return false;
},
};
if (typeof node.addCustomWidget === "function") {
node.addCustomWidget(widget);
} else {
if (!node.widgets) node.widgets = [];
node.widgets.push(widget);
}
}
export function normalizeCanvasSize(value) {
if (!value || typeof value !== "object") return null;
const hasIndexedSize = value.length >= 2 || (0 in value && 1 in value);
const width = Number(hasIndexedSize ? value[0] : value.width ?? value.w);
const height = Number(hasIndexedSize ? value[1] : value.height ?? value.h);
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
return [Math.max(1, width), Math.max(1, height)];
}
export function configuredCanvasSize(config) {
if (!config || typeof config !== "object") return null;
return normalizeCanvasSize(config.size) || normalizeCanvasSize(config.properties && config.properties[CANVAS_SIZE_PROPERTY]);
}
export function rememberTimelineCanvasSize(node, size = null) {
if (!node) return null;
if (!node.properties) node.properties = {};
const normalized = normalizeCanvasSize(size || node.size);
if (!normalized) return null;
node.properties[CANVAS_SIZE_PROPERTY] = normalized;
return normalized;
}
export function applyTimelineCanvasSize(node, preferredSize = null) {
if (!node) return null;
if (!node.properties) node.properties = {};
const minimumSize = typeof node.computeSize === "function" ? node.computeSize() : [460, TIMELINE_EDITOR_HEIGHT];
const restoredSize =
normalizeCanvasSize(preferredSize) ||
normalizeCanvasSize(node.properties[CANVAS_SIZE_PROPERTY]) ||
normalizeCanvasSize(node.size) ||
minimumSize;
node.size = [
Math.max(restoredSize[0], minimumSize[0]),
Math.max(restoredSize[1], minimumSize[1]),
];
return rememberTimelineCanvasSize(node);
}
export function markTimelineCanvasDirty(node) {
if (!node) return;
if (typeof node.setDirtyCanvas === "function") node.setDirtyCanvas(true, true);
if (node.graph && typeof node.graph.setDirtyCanvas === "function") node.graph.setDirtyCanvas(true, true);
}
export function scheduleTimelineCanvasSizeRestore(node, preferredSize = null) {
if (!node) return null;
if (!node.properties) node.properties = {};
const targetSize =
normalizeCanvasSize(preferredSize) ||
normalizeCanvasSize(node.properties[CANVAS_SIZE_PROPERTY]) ||
normalizeCanvasSize(node.size);
if (!targetSize) return null;
const appliedSize = applyTimelineCanvasSize(node, targetSize);
const restore = () => {
applyTimelineCanvasSize(node, targetSize);
markTimelineCanvasDirty(node);
};
const scope = typeof window !== "undefined" ? window : null;
const queueTask =
typeof queueMicrotask === "function"
? queueMicrotask
: typeof Promise !== "undefined"
? (fn) => Promise.resolve().then(fn)
: null;
const requestFrame =
scope && typeof scope.requestAnimationFrame === "function"
? scope.requestAnimationFrame.bind(scope)
: typeof requestAnimationFrame === "function"
? requestAnimationFrame
: null;
const timeout =
scope && typeof scope.setTimeout === "function"
? scope.setTimeout.bind(scope)
: typeof setTimeout === "function"
? setTimeout
: null;
if (queueTask) queueTask(restore);
if (requestFrame) requestFrame(restore);
if (timeout) timeout(restore, 0);
return appliedSize;
}
export function setupNode(node) {
if (!node.properties) node.properties = {};
const savedOutputSettings = normalizeTimelineSettings(node.properties[OUTPUT_SETTINGS_PROPERTY]);
if (savedOutputSettings) {
node.etkTimelineBackendSettings = { ...savedOutputSettings, ...(node.etkTimelineBackendSettings || {}) };
}
ensureUiInstanceId(node);
uploadMode(node);
layerEditingLocked(node);
controlsTab(node);
migrateDisableRelayWidgetOrder(node);
clarifyDefaultGuideStrengthWidget(node);
fixPromptWidgetHeights(node);
syncChainInheritedWidgets(node);
if (!node.properties.etk_ltxv_timeline_keyframes) {
node.properties.etk_ltxv_timeline_keyframes = normalizeKeyframes(getWidgetValue(node, "timeline_json", "[]"), node);
} else {
node.properties.etk_ltxv_timeline_keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
}
const selectedIndex = selectedKeyframeIndex(node, node.properties.etk_ltxv_timeline_keyframes);
if (selectedIndex < 0) {
setSelectedKeyframe(node, node.properties.etk_ltxv_timeline_keyframes.length > 0 ? 0 : -1, node.properties.etk_ltxv_timeline_keyframes);
}
const timelineWidget = getWidget(node, "timeline_json");
if (timelineWidget && !timelineWidget.etkTimelineHidden) {
hideWidget(timelineWidget, "etkTimelineHidden");
}
ensureTimelineEditorWidget(node);
ensureKeyframePromptEditorWidget(node);
ensureTextLayerEditorWidget(node);
if (!node.etkTimelineFileInput) {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/png,image/jpeg,image/webp";
input.style.position = "fixed";
input.style.left = "-1000px";
input.style.top = "-1000px";
document.body.appendChild(input);
input.addEventListener("change", async () => {
const file = input.files && input.files[0];
input.value = "";
if (!file) return;
try {
const keyframe = selectedKeyframe(node);
await uploadSelectedImage(node, file, keyframe ? keyframe.id : null);
} catch (error) {
setTimelineStatus(node, `Image upload failed: ${errorText(error)}`, "error");
console.error("[ETK timeline] Image upload failed", error);
}
});
node.etkTimelineFileInput = input;
}
installTimelinePasteFiles(node);
installTimelineDragDropHooks(node);
for (const keyframe of node.properties.etk_ltxv_timeline_keyframes) {
preloadImage(node, keyframe);
}
syncTimelineWidget(node);
}
+757
View File
@@ -0,0 +1,757 @@
// Canvas rendering for timeline controls, layers, text, and previews.
import {
CONTROL_HEADER_HEIGHT,
CONTROL_TABS,
CONTROL_TABS_HEIGHT,
DEFAULT_TEXT_LAYER,
TIMELINE_EDITOR_HEIGHT,
} from "./constants.js";
import { clamp } from "./runtime.js";
import {
activeLayer,
controlsTab,
displayLatentStrength,
isEmptyLayer,
isTextLayer,
keyframeHasImageSource,
layerEditingLocked,
normalizeFilterValue,
normalizeKeyframes,
normalizePan,
normalizePrompt,
normalizePromptStrength,
normalizeTextLayerAlign,
normalizeTextLayerBold,
normalizeTextLayerColor,
normalizeTextLayerFontFamily,
normalizeTextLayerFontSize,
normalizeTextLayerItalic,
normalizeTextLayerNumber,
normalizeTextLayerString,
normalizeZoom,
selectedKeyframe,
selectedKeyframeIndex,
setSelectedKeyframe,
syncTimelineWidget,
uploadMode,
viewUrl,
} from "./model.js";
import {
bounds,
formatSlotSeconds,
latentSlots,
outputDimension,
previewImageBox,
slotToX,
} from "./timeline_ops.js";
import {
deleteLayerButtonBounds,
imageButtonBounds,
keyframeStrengthSliderBounds,
layerFilterSliderBounds,
lockLayerButtonBounds,
modeButtonBounds,
textLayerButtonBounds,
uploadModeButtonBounds,
} from "./interaction.js";
export function drawDiamond(ctx, x, y, size, fill, stroke, lineWidth) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(Math.PI / 4);
ctx.fillStyle = fill;
ctx.fillRect(-size, -size, size * 2, size * 2);
ctx.strokeStyle = stroke;
ctx.lineWidth = lineWidth;
ctx.strokeRect(-size, -size, size * 2, size * 2);
ctx.restore();
}
export function refreshButtonBounds(node, areaY = null, areaH = null) {
const w = 22;
const { padX, width, timelineY } = bounds(node, areaY, areaH);
return { x: padX + width - w, y: timelineY - 29, w, h: 18 };
}
export function drawRefreshButton(node, ctx, areaY = null, areaH = null) {
const button = refreshButtonBounds(node, areaY, areaH);
ctx.save();
ctx.fillStyle = "#263243";
ctx.strokeStyle = "#526174";
ctx.lineWidth = 1;
ctx.fillRect(button.x, button.y, button.w, button.h);
ctx.strokeRect(button.x, button.y, button.w, button.h);
ctx.fillStyle = "#d7e4f3";
ctx.font = "bold 13px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("R", button.x + button.w / 2, button.y + button.h / 2 + 0.5);
ctx.restore();
}
export function previewPlacement(image, boxW, boxH, fitMode, panX, panY, zoom) {
const baseScale =
fitMode === "crop"
? Math.max(boxW / image.naturalWidth, boxH / image.naturalHeight)
: Math.min(boxW / image.naturalWidth, boxH / image.naturalHeight);
const scale = baseScale * normalizeZoom(zoom);
const drawW = image.naturalWidth * scale;
const drawH = image.naturalHeight * scale;
const excessX = Math.max(0, drawW - boxW);
const excessY = Math.max(0, drawH - boxH);
const panRangeX = Math.max(excessX, boxW);
const panRangeY = Math.max(excessY, boxH);
return {
drawW,
drawH,
drawX: (boxW - drawW) / 2 + normalizePan(panX) * (panRangeX / 2),
drawY: (boxH - drawH) / 2 + normalizePan(panY) * (panRangeY / 2),
excessX,
excessY,
panRangeX,
panRangeY,
};
}
export function drawCheckerboard(ctx, x, y, w, h, size = 8) {
ctx.save();
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.clip();
ctx.fillStyle = "#9b9b9b";
ctx.fillRect(x, y, w, h);
ctx.fillStyle = "#c7c7c7";
const startX = Math.floor(x / size) * size;
const startY = Math.floor(y / size) * size;
for (let yy = startY; yy < y + h; yy += size) {
for (let xx = startX; xx < x + w; xx += size) {
if (((Math.floor(xx / size) + Math.floor(yy / size)) & 1) === 0) {
ctx.fillRect(xx, yy, size, size);
}
}
}
ctx.restore();
}
export function editPromptModal(node, keyframeId, field, label) {
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const index = keyframes.findIndex((keyframe) => keyframe.id === keyframeId);
if (index < 0) return;
const overlay = document.createElement("div");
overlay.style.cssText = `
position: fixed; inset: 0; z-index: 100000;
background: rgba(0,0,0,0.48); display: flex;
align-items: center; justify-content: center;
`;
const panel = document.createElement("div");
panel.style.cssText = `
width: min(720px, calc(100vw - 48px)); min-height: 320px;
background: #202733; border: 1px solid #536274;
border-radius: 8px; box-shadow: 0 18px 60px rgba(0,0,0,0.55);
padding: 14px; color: #eef4ff; font-family: sans-serif;
`;
overlay.appendChild(panel);
const title = document.createElement("div");
title.textContent = `${label} prompt for ${formatSlotSeconds(node, keyframes[index].slot)}`;
title.style.cssText = "font-weight: 700; font-size: 14px; margin-bottom: 10px;";
panel.appendChild(title);
const textarea = document.createElement("textarea");
textarea.value = normalizePrompt(keyframes[index][field]);
textarea.style.cssText = `
width: 100%; min-height: 220px; box-sizing: border-box;
background: #111820; color: #f3f7ff; border: 1px solid #485668;
border-radius: 6px; padding: 10px; resize: vertical;
font: 13px/1.35 sans-serif;
`;
panel.appendChild(textarea);
const row = document.createElement("div");
row.style.cssText = "display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px;";
panel.appendChild(row);
const makeButton = (text, primary = false) => {
const button = document.createElement("button");
button.textContent = text;
button.style.cssText = `
padding: 6px 14px; border-radius: 5px; cursor: pointer;
border: 1px solid ${primary ? "#6ba8ff" : "#536274"};
background: ${primary ? "#2f7fd2" : "#313b49"};
color: #ffffff;
`;
return button;
};
const cancel = makeButton("Cancel");
const clear = makeButton("Clear");
const save = makeButton("Save", true);
row.appendChild(clear);
row.appendChild(cancel);
row.appendChild(save);
const close = () => overlay.remove();
cancel.addEventListener("click", close);
clear.addEventListener("click", () => {
textarea.value = "";
textarea.focus();
});
save.addEventListener("click", () => {
const latest = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const latestIndex = latest.findIndex((keyframe) => keyframe.id === keyframeId);
if (latestIndex >= 0) {
latest[latestIndex][field] = textarea.value;
node.properties.etk_ltxv_timeline_keyframes = latest;
setSelectedKeyframe(node, latestIndex, latest);
syncTimelineWidget(node);
node.setDirtyCanvas(true, true);
}
close();
});
overlay.addEventListener("keydown", (event) => {
if (event.key === "Escape") close();
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") save.click();
event.stopPropagation();
});
overlay.addEventListener("pointerdown", (event) => {
if (event.target === overlay) close();
event.stopPropagation();
});
panel.addEventListener("pointerdown", (event) => event.stopPropagation());
document.body.appendChild(overlay);
textarea.focus();
}
export function drawPreviewImage(ctx, image, boxX, boxY, boxW, boxH, fitMode, panX, panY, zoom, brightness = 1, contrast = 1) {
if (!image || !image.complete || image.naturalWidth <= 0 || image.naturalHeight <= 0) return false;
const placement = previewPlacement(image, boxW, boxH, fitMode, panX, panY, zoom);
ctx.save();
ctx.beginPath();
ctx.rect(boxX, boxY, boxW, boxH);
ctx.clip();
if ("filter" in ctx) {
ctx.filter = `brightness(${normalizeFilterValue(brightness)}) contrast(${normalizeFilterValue(contrast)})`;
}
ctx.drawImage(
image,
boxX + placement.drawX,
boxY + placement.drawY,
placement.drawW,
placement.drawH
);
ctx.restore();
return true;
}
export function previewSourceForLayer(node, layer) {
if (isTextLayer(layer)) {
return {
complete: true,
naturalWidth: outputDimension(node, "width", 64),
naturalHeight: outputDimension(node, "height", 64),
};
}
const url = viewUrl(layer && layer.image);
return url && node.etkTimelineImageCache ? node.etkTimelineImageCache.get(url) : null;
}
export function cssTextLayerFontFamily(layer) {
const family = normalizeTextLayerFontFamily(layer && layer.fontFamily, DEFAULT_TEXT_LAYER.fontFamily);
if (/^[a-zA-Z0-9 _-]+$/.test(family)) return family;
return JSON.stringify(family);
}
export function drawPreviewTextLayer(node, ctx, layer, boxX, boxY, boxW, boxH) {
const source = previewSourceForLayer(node, layer);
const placement = previewPlacement(source, boxW, boxH, layer.fitMode, layer.panX, layer.panY, layer.zoom);
const scale = Math.min(placement.drawW / source.naturalWidth, placement.drawH / source.naturalHeight);
const fontSize = Math.max(1, normalizeTextLayerFontSize(layer.fontSize, DEFAULT_TEXT_LAYER.fontSize) * scale);
const lines = normalizeTextLayerString(layer.text, DEFAULT_TEXT_LAYER.text).split(/\r?\n/);
const lineHeight = fontSize * 1.18;
const align = normalizeTextLayerAlign(layer.align, DEFAULT_TEXT_LAYER.align);
const textX = align === "left"
? boxX + placement.drawX
: align === "right"
? boxX + placement.drawX + placement.drawW
: boxX + placement.drawX + placement.drawW / 2;
const centerY = boxY + placement.drawY + placement.drawH / 2;
const startY = centerY - ((lines.length - 1) * lineHeight) / 2;
const outlineWidth = normalizeTextLayerNumber(layer.outlineWidth, DEFAULT_TEXT_LAYER.outlineWidth, 0, 64) * scale;
ctx.save();
ctx.beginPath();
ctx.rect(boxX, boxY, boxW, boxH);
ctx.clip();
if ("filter" in ctx) {
ctx.filter = `brightness(${normalizeFilterValue(layer.brightness)}) contrast(${normalizeFilterValue(layer.contrast)})`;
}
ctx.font = `${normalizeTextLayerItalic(layer.italic, false) ? "italic " : ""}${normalizeTextLayerBold(layer.bold, false) ? "700 " : ""}${fontSize}px ${cssTextLayerFontFamily(layer)}`;
ctx.textAlign = align;
ctx.textBaseline = "middle";
ctx.shadowColor = normalizeTextLayerColor(layer.shadowColor, DEFAULT_TEXT_LAYER.shadowColor);
ctx.shadowBlur = normalizeTextLayerNumber(layer.shadowBlur, DEFAULT_TEXT_LAYER.shadowBlur, 0, 128) * scale;
ctx.shadowOffsetX = normalizeTextLayerNumber(layer.shadowOffsetX, DEFAULT_TEXT_LAYER.shadowOffsetX, -512, 512) * scale;
ctx.shadowOffsetY = normalizeTextLayerNumber(layer.shadowOffsetY, DEFAULT_TEXT_LAYER.shadowOffsetY, -512, 512) * scale;
for (let index = 0; index < lines.length; index += 1) {
const y = startY + index * lineHeight;
if (outlineWidth > 0 && typeof ctx.strokeText === "function") {
ctx.lineWidth = outlineWidth;
ctx.strokeStyle = normalizeTextLayerColor(layer.outlineColor, DEFAULT_TEXT_LAYER.outlineColor);
ctx.strokeText(lines[index], textX, y);
}
ctx.fillStyle = normalizeTextLayerColor(layer.color, DEFAULT_TEXT_LAYER.color);
ctx.fillText(lines[index], textX, y);
}
ctx.restore();
return true;
}
export function drawPreviewLayers(node, ctx, keyframe, boxX, boxY, boxW, boxH) {
const layers = Array.isArray(keyframe.layers) ? keyframe.layers : [];
const sourceLayers = layers.length
? layers
: keyframe.image
? [{ id: "legacy", image: keyframe.image, fitMode: keyframe.fitMode, panX: keyframe.panX, panY: keyframe.panY, zoom: keyframe.zoom }]
: [];
drawCheckerboard(ctx, boxX, boxY, boxW, boxH);
let drawn = false;
let pending = false;
let failed = false;
for (const layer of sourceLayers) {
if (isEmptyLayer(layer)) continue;
if (isTextLayer(layer)) {
drawPreviewTextLayer(node, ctx, layer, boxX, boxY, boxW, boxH);
drawn = true;
continue;
}
const image = previewSourceForLayer(node, layer);
if (!image) {
pending = true;
continue;
}
if (image.etkTimelineLoadFailed) {
failed = true;
continue;
}
if (!drawPreviewImage(ctx, image, boxX, boxY, boxW, boxH, layer.fitMode, layer.panX, layer.panY, layer.zoom, layer.brightness, layer.contrast)) {
pending = true;
continue;
}
drawn = true;
}
return { drawn, pending, failed };
}
export function layerChipBounds(node, keyframe, areaY = null, areaH = null) {
const layers = Array.isArray(keyframe && keyframe.layers) ? keyframe.layers : [];
if (!layers.length) return [];
const content = controlContentBounds(node, areaY, areaH);
const chips = [];
let x = content.x;
const y = content.y + 33;
for (let index = 0; index < layers.length; index += 1) {
const empty = isEmptyLayer(layers[index]);
const text = isTextLayer(layers[index]);
const label = empty ? `E${index + 1}` : text ? `T${index + 1}` : index === 0 ? "Base" : `L${index + 1}`;
const w = empty || text ? 30 : index === 0 ? 42 : 30;
if (x + w > content.x + content.w) break;
chips.push({ x, y, w, h: 17, layerId: layers[index].id, label, index, empty });
x += w + 5;
}
return chips;
}
export function layerChipAt(node, keyframe, x, y) {
for (const chip of layerChipBounds(node, keyframe)) {
if (x >= chip.x && x <= chip.x + chip.w && y >= chip.y && y <= chip.y + chip.h) return chip;
}
return null;
}
export function drawLayerLockIcon(ctx, bounds, locked) {
const cx = bounds.x + bounds.w / 2;
const bodyW = 18;
const bodyH = 11;
const bodyX = Math.round(cx - bodyW / 2);
const bodyY = bounds.y + 9;
ctx.fillRect(bodyX, bodyY, bodyW, bodyH);
ctx.strokeRect(bodyX, bodyY, bodyW, bodyH);
ctx.beginPath();
if (locked) {
ctx.moveTo(cx - 6, bodyY);
ctx.lineTo(cx - 6, bodyY - 5);
ctx.lineTo(cx + 6, bodyY - 5);
ctx.lineTo(cx + 6, bodyY);
} else {
ctx.moveTo(cx - 6, bodyY);
ctx.lineTo(cx - 6, bodyY - 5);
ctx.lineTo(cx + 4, bodyY - 8);
ctx.lineTo(cx + 9, bodyY - 4);
}
ctx.stroke();
ctx.fillRect(Math.round(cx - 1), bodyY + 4, 2, 4);
}
export function drawLayerChips(node, ctx, keyframe, areaY = null, areaH = null) {
const activeId = activeLayer(keyframe) ? activeLayer(keyframe).id : null;
for (const chip of layerChipBounds(node, keyframe, areaY, areaH)) {
const selected = chip.layerId === activeId;
ctx.fillStyle = selected ? "#2f9bff" : chip.empty ? "rgba(72, 78, 90, 0.86)" : "rgba(58, 70, 88, 0.9)";
ctx.fillRect(chip.x, chip.y, chip.w, chip.h);
ctx.strokeStyle = selected ? "#ffffff" : chip.empty ? "#7d8797" : "#526174";
ctx.strokeRect(chip.x, chip.y, chip.w, chip.h);
ctx.fillStyle = chip.empty && !selected ? "#c5cfdd" : "#ffffff";
ctx.font = "bold 10px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.fillText(chip.label, chip.x + chip.w / 2, chip.y + 12);
}
}
export function drawStrengthSlider(ctx, slider, value, active) {
const normalized = clamp((value - slider.min) / (slider.max - slider.min), 0, 1);
const knobX = slider.trackX + slider.trackW * normalized;
ctx.fillStyle = "rgba(8, 12, 18, 0.78)";
ctx.fillRect(slider.x, slider.y, slider.w, slider.h);
ctx.strokeStyle = active ? "#ffffff" : "#3f4b5d";
ctx.strokeRect(slider.x, slider.y, slider.w, slider.h);
ctx.fillStyle = "#dce7f5";
ctx.font = "10px sans-serif";
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillText(`${slider.label} ${value.toFixed(2)}`, slider.x + 6, slider.y + 11);
ctx.fillStyle = "#2a3444";
ctx.fillRect(slider.trackX, slider.trackY, slider.trackW, slider.trackH);
ctx.fillStyle = slider.color;
ctx.fillRect(slider.trackX, slider.trackY, Math.max(0, knobX - slider.trackX), slider.trackH);
ctx.fillStyle = "#f4f8ff";
ctx.fillRect(knobX - 3, slider.trackY - 3, 6, slider.trackH + 6);
}
export function drawTimelineStatus(node, ctx, x, y, w, h) {
const status = node.etkTimelineStatus;
if (!status || !status.text) return false;
const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
if (status.expiresAt && now > status.expiresAt) {
node.etkTimelineStatus = null;
return false;
}
const tone = status.tone || "info";
const background =
tone === "error"
? "rgba(116, 34, 34, 0.92)"
: tone === "success"
? "rgba(24, 87, 54, 0.92)"
: "rgba(28, 48, 78, 0.92)";
ctx.save();
ctx.fillStyle = background;
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = tone === "error" ? "#ff9b9b" : tone === "success" ? "#89e0a8" : "#79bdff";
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = "#ffffff";
ctx.font = "bold 11px sans-serif";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
const text = String(status.text).length > 150 ? `${String(status.text).slice(0, 147)}...` : String(status.text);
ctx.fillText(text, x + 8, y + h / 2);
ctx.restore();
return true;
}
export function controlHeaderBounds(node, areaY = null, areaH = null) {
const { padX, width, controlsY } = bounds(node, areaY, areaH);
return { x: padX + 6, y: controlsY + 6, w: width - 12, h: CONTROL_HEADER_HEIGHT };
}
export function controlTabBounds(node, areaY = null, areaH = null) {
const { padX, controlsY } = bounds(node, areaY, areaH);
const y = controlsY + 34;
let x = padX + 8;
return CONTROL_TABS.map((tab) => {
const bounds = { ...tab, x, y, h: CONTROL_TABS_HEIGHT };
x += tab.w + 4;
return bounds;
});
}
export function controlTabAt(node, x, y, areaY = null, areaH = null) {
for (const tab of controlTabBounds(node, areaY, areaH)) {
if (x >= tab.x && x <= tab.x + tab.w && y >= tab.y && y <= tab.y + tab.h) return tab.id;
}
return null;
}
export function controlContentBounds(node, areaY = null, areaH = null) {
const { padX, width, controlsY, controlsH } = bounds(node, areaY, areaH);
const y = controlsY + 64;
return { x: padX + 8, y, w: width - 16, h: Math.max(1, controlsY + controlsH - y - 8) };
}
export function drawClippedText(ctx, text, x, y, w) {
ctx.save();
ctx.beginPath();
ctx.rect(x, y - 12, Math.max(1, w), 20);
ctx.clip();
ctx.fillText(text, x, y);
ctx.restore();
}
export function drawControlsHeader(node, ctx, selected, targetW, targetH, selectedLayer, areaY = null, areaH = null) {
const header = controlHeaderBounds(node, areaY, areaH);
ctx.fillStyle = "#0b1119";
ctx.fillRect(header.x, header.y, header.w, header.h);
ctx.strokeStyle = "#263243";
ctx.strokeRect(header.x, header.y, header.w, header.h);
ctx.textAlign = "left";
ctx.textBaseline = "middle";
if (!selected) {
ctx.fillStyle = "#aeb9c8";
ctx.font = "bold 11px sans-serif";
drawClippedText(ctx, "Keyframe controls | no keyframe selected", header.x + 8, header.y + header.h / 2 + 0.5, header.w - 16);
return;
}
const zoom = normalizeZoom(selectedLayer ? selectedLayer.zoom : selected.zoom);
const readout = [
`Keyframe ${formatSlotSeconds(node, selected.slot)}`,
`${targetW}x${targetH}`,
`${zoom.toFixed(2)}x`,
`latent ${displayLatentStrength(node, selected).toFixed(2)}`,
`prompt ${normalizePromptStrength(selected.promptStrength).toFixed(2)}`,
layerEditingLocked(node) ? "layers locked" : null,
selected.inputImage ? "input" : null,
].filter(Boolean).join(" | ");
ctx.fillStyle = "#eef4ff";
ctx.font = "bold 12px sans-serif";
drawClippedText(ctx, readout, header.x + 8, header.y + header.h / 2 + 0.5, header.w - 16);
}
export function drawControlsTabs(node, ctx, areaY = null, areaH = null) {
const active = controlsTab(node);
for (const tab of controlTabBounds(node, areaY, areaH)) {
const selected = tab.id === active;
ctx.fillStyle = selected ? "#2f7fd2" : "#1a2230";
ctx.fillRect(tab.x, tab.y, tab.w, tab.h);
ctx.strokeStyle = selected ? "#79bdff" : "#3b4658";
ctx.strokeRect(tab.x, tab.y, tab.w, tab.h);
ctx.fillStyle = selected ? "#ffffff" : "#c0cad8";
ctx.font = "bold 10px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(tab.label, tab.x + tab.w / 2, tab.y + tab.h / 2 + 0.5);
}
}
export function drawControlContentMessage(node, ctx, text, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
ctx.fillStyle = "#7d8797";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(text, content.x + content.w / 2, content.y + content.h / 2);
}
export function drawTimeline(node, ctx, areaY = null, areaH = null) {
const effectiveAreaH = Math.max(
Number.isFinite(areaH) ? areaH : TIMELINE_EDITOR_HEIGHT,
TIMELINE_EDITOR_HEIGHT
);
const { padX, width, timelineY, timelineH, controlsY, controlsH, previewY, previewH } = bounds(node, areaY, effectiveAreaH);
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const selected = selectedKeyframe(node);
const selectedIndex = selectedKeyframeIndex(node, keyframes);
const totalSlots = latentSlots(node);
const layersLocked = layerEditingLocked(node);
ctx.save();
drawRefreshButton(node, ctx, areaY, effectiveAreaH);
ctx.fillStyle = "#10131a";
ctx.fillRect(padX, timelineY, width, timelineH);
ctx.strokeStyle = "#2b3445";
ctx.strokeRect(padX, timelineY, width, timelineH);
ctx.strokeStyle = "#465062";
ctx.beginPath();
for (let i = 0; i <= 10; i += 1) {
const x = padX + width * (i / 10);
ctx.moveTo(x, timelineY + timelineH - 7);
ctx.lineTo(x, timelineY + timelineH);
}
ctx.stroke();
ctx.fillStyle = "#b9c7d9";
ctx.font = "10px sans-serif";
ctx.textAlign = "left";
ctx.fillText(formatSlotSeconds(node, 0), padX, timelineY - 6);
ctx.textAlign = "right";
ctx.fillText(formatSlotSeconds(node, totalSlots - 1), padX + width, timelineY - 6);
for (let index = 0; index < keyframes.length; index += 1) {
const keyframe = keyframes[index];
const x = slotToX(node, keyframe.slot);
const y = timelineY + timelineH / 2;
const isSelected = index === selectedIndex;
const hasImageSource = keyframeHasImageSource(keyframe);
const fill = isSelected ? "#2f9bff" : hasImageSource ? "#42c06e" : keyframe.inputImage ? "#d99b31" : "#7b8190";
drawDiamond(ctx, x, y, 7, fill, isSelected ? "#ffffff" : "#050505", isSelected ? 2 : 1);
ctx.fillStyle = "#d9e3f0";
ctx.font = "9px sans-serif";
ctx.textAlign = "center";
ctx.fillText(formatSlotSeconds(node, keyframe.slot), x, y + 22);
}
ctx.fillStyle = "#101722";
ctx.fillRect(padX, controlsY, width, controlsH);
ctx.strokeStyle = "#30394a";
ctx.strokeRect(padX, controlsY, width, controlsH);
drawControlsHeader(
node,
ctx,
selected,
selected ? outputDimension(node, "width", 64) : 0,
selected ? outputDimension(node, "height", 64) : 0,
selected ? activeLayer(selected) : null,
areaY,
effectiveAreaH
);
drawControlsTabs(node, ctx, areaY, effectiveAreaH);
ctx.fillStyle = "#151821";
ctx.fillRect(padX, previewY, width, previewH);
ctx.strokeStyle = "#30394a";
ctx.strokeRect(padX, previewY, width, previewH);
if (selected) {
const { targetW, targetH, boxX, boxY, boxW, boxH } = previewImageBox(node, areaY, effectiveAreaH);
const selectedLayer = activeLayer(selected);
const preview = drawPreviewLayers(node, ctx, selected, boxX, boxY, boxW, boxH);
if (!preview.drawn) {
ctx.fillStyle = "#657084";
ctx.font = "13px sans-serif";
ctx.textAlign = "center";
ctx.fillText(
preview.failed
? "Preview file missing - execute the node again"
: keyframeHasImageSource(selected)
? "Loading preview..."
: selected.inputImage
? "Connected image input"
: "Click here to upload image",
padX + width / 2,
previewY + previewH / 2
);
}
ctx.strokeStyle = "#42c06e";
ctx.strokeRect(boxX, boxY, boxW, boxH);
const activeControlTab = controlsTab(node);
if (activeControlTab === "layers") {
const button = modeButtonBounds(node, areaY, effectiveAreaH);
const fitMode = selectedLayer ? selectedLayer.fitMode : selected.fitMode;
ctx.fillStyle = layersLocked ? "#303642" : fitMode === "crop" ? "#2f9bff" : "#9b59d0";
ctx.fillRect(button.x, button.y, button.w, button.h);
ctx.fillStyle = layersLocked ? "#70798a" : "#ffffff";
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.font = "bold 10px sans-serif";
ctx.fillText(fitMode.toUpperCase(), button.x + button.w / 2, button.y + 14);
const imageButton = imageButtonBounds(node, areaY, effectiveAreaH);
ctx.fillStyle = layersLocked ? "#303642" : "#3a4658";
ctx.fillRect(imageButton.x, imageButton.y, imageButton.w, imageButton.h);
ctx.fillStyle = layersLocked ? "#70798a" : "#ffffff";
ctx.fillText("IMG", imageButton.x + imageButton.w / 2, imageButton.y + 14);
const textButton = textLayerButtonBounds(node, areaY, effectiveAreaH);
ctx.fillStyle = layersLocked ? "#303642" : "#3d526a";
ctx.fillRect(textButton.x, textButton.y, textButton.w, textButton.h);
ctx.fillStyle = layersLocked ? "#70798a" : "#ffffff";
ctx.fillText("TXT", textButton.x + textButton.w / 2, textButton.y + 14);
const uploadButton = uploadModeButtonBounds(node, areaY, effectiveAreaH);
const currentUploadMode = uploadMode(node);
ctx.fillStyle = layersLocked ? "#303642" : currentUploadMode === "layer" ? "#2f7fd2" : "#3a4658";
ctx.fillRect(uploadButton.x, uploadButton.y, uploadButton.w, uploadButton.h);
ctx.fillStyle = layersLocked ? "#70798a" : "#ffffff";
ctx.fillText(currentUploadMode.toUpperCase(), uploadButton.x + uploadButton.w / 2, uploadButton.y + 14);
const lockLayerButton = lockLayerButtonBounds(node, areaY, effectiveAreaH);
ctx.fillStyle = layersLocked ? "#8a6d2a" : "#273343";
ctx.fillRect(lockLayerButton.x, lockLayerButton.y, lockLayerButton.w, lockLayerButton.h);
ctx.strokeStyle = layersLocked ? "#ffd36a" : "#526174";
ctx.strokeRect(lockLayerButton.x, lockLayerButton.y, lockLayerButton.w, lockLayerButton.h);
ctx.fillStyle = layersLocked ? "#fff0bd" : "#d7e4f3";
drawLayerLockIcon(ctx, lockLayerButton, layersLocked);
const deleteLayerButton = deleteLayerButtonBounds(node, areaY, effectiveAreaH);
const canDeleteLayer = !layersLocked && Array.isArray(selected.layers) && selected.layers.length > 1 && selectedLayer;
ctx.fillStyle = canDeleteLayer ? "#8f3d3d" : "#303642";
ctx.fillRect(deleteLayerButton.x, deleteLayerButton.y, deleteLayerButton.w, deleteLayerButton.h);
ctx.fillStyle = canDeleteLayer ? "#ffffff" : "#70798a";
ctx.fillText("DEL", deleteLayerButton.x + deleteLayerButton.w / 2, deleteLayerButton.y + 14);
if (layerChipBounds(node, selected, areaY, effectiveAreaH).length) {
drawLayerChips(node, ctx, selected, areaY, effectiveAreaH);
}
} else if (activeControlTab === "filters") {
if (selectedLayer) {
drawStrengthSlider(
ctx,
layerFilterSliderBounds(node, "brightness", areaY, effectiveAreaH),
normalizeFilterValue(selectedLayer.brightness),
node.etkSliderDragging && node.etkSliderDragging.family === "filter" && node.etkSliderDragging.kind === "brightness"
);
drawStrengthSlider(
ctx,
layerFilterSliderBounds(node, "contrast", areaY, effectiveAreaH),
normalizeFilterValue(selectedLayer.contrast),
node.etkSliderDragging && node.etkSliderDragging.family === "filter" && node.etkSliderDragging.kind === "contrast"
);
} else {
drawControlContentMessage(node, ctx, "No active layer to filter", areaY, effectiveAreaH);
}
} else {
drawStrengthSlider(
ctx,
keyframeStrengthSliderBounds(node, "latent", areaY, effectiveAreaH),
displayLatentStrength(node, selected),
node.etkSliderDragging && node.etkSliderDragging.family === "strength" && node.etkSliderDragging.kind === "latent"
);
drawStrengthSlider(
ctx,
keyframeStrengthSliderBounds(node, "prompt", areaY, effectiveAreaH),
normalizePromptStrength(selected.promptStrength),
node.etkSliderDragging && node.etkSliderDragging.family === "strength" && node.etkSliderDragging.kind === "prompt"
);
}
} else {
drawControlContentMessage(node, ctx, "Select a keyframe to edit controls", areaY, effectiveAreaH);
ctx.fillStyle = "#7d8797";
ctx.font = "13px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.fillText("Double-click timeline to add a keyframe", padX + width / 2, previewY + previewH / 2);
}
if (node.etkTimelineDropHover) {
ctx.fillStyle = "rgba(47, 155, 255, 0.18)";
ctx.fillRect(padX, previewY, width, previewH);
ctx.strokeStyle = "#79bdff";
ctx.lineWidth = 2;
ctx.strokeRect(padX + 2, previewY + 2, width - 4, previewH - 4);
ctx.fillStyle = "#ffffff";
ctx.font = "bold 13px sans-serif";
ctx.textAlign = "center";
ctx.fillText(layersLocked ? "Layer editing locked" : uploadMode(node) === "layer" ? "Drop image to add layer" : "Drop image to replace active layer", padX + width / 2, previewY + previewH / 2);
}
drawTimelineStatus(node, ctx, padX + 6, previewY + previewH - 26, width - 12, 20);
ctx.fillStyle = "#aeb9c8";
ctx.font = "10px sans-serif";
ctx.textAlign = "left";
ctx.fillText("double-click: add | D: duplicate | X: delete keyframe | lock button blocks layer edits | drag/drop/paste image | drag/arrows: pan | wheel: zoom", padX, timelineY - 22);
ctx.restore();
}
+497
View File
@@ -0,0 +1,497 @@
// ComfyUI graph lookup, widget access, backend events, and global listener lifecycle.
import { api } from "../../../../scripts/api.js";
import { app } from "../../../../scripts/app.js";
import {
OUTPUT_SETTINGS_PROPERTY,
TARGET_NODES,
TIMELINE_GLOBAL_LISTENER_STATE,
TIMELINE_GLOBAL_LISTENER_TOKEN,
TIMELINE_STATUS_DURATION_MS,
UI_INSTANCE_PROPERTY,
UI_INSTANCE_WIDGET,
} from "./constants.js";
import { syncTimelineWidget } from "./model.js";
import { setupNode } from "./node_setup.js";
export function isTargetNode(nodeOrName) {
const name = typeof nodeOrName === "string" ? nodeOrName : nodeOrName ? nodeOrName.type : null;
return TARGET_NODES.has(name);
}
export function disposeTimelineGlobalListenerState(state) {
for (const dispose of [...(state.disposers || [])].reverse()) {
try {
dispose();
} catch (error) {
console.warn("[ETK timeline] Failed to remove stale global listener", error);
}
}
state.installed = new Set();
state.disposers = [];
}
export function timelineGlobalListenerState() {
let state = window[TIMELINE_GLOBAL_LISTENER_STATE];
if (!state || typeof state !== "object") {
state = { token: null, installed: new Set(), disposers: [] };
window[TIMELINE_GLOBAL_LISTENER_STATE] = state;
}
if (!(state.installed instanceof Set)) state.installed = new Set(state.installed || []);
if (!Array.isArray(state.disposers)) state.disposers = [];
if (state.token && state.token !== TIMELINE_GLOBAL_LISTENER_TOKEN) {
disposeTimelineGlobalListenerState(state);
}
state.token = TIMELINE_GLOBAL_LISTENER_TOKEN;
return state;
}
export function resetTimelineGlobalListeners() {
const state = timelineGlobalListenerState();
disposeTimelineGlobalListenerState(state);
state.token = TIMELINE_GLOBAL_LISTENER_TOKEN;
}
export function addTimelineWindowListener(key, eventName, handler, options = false) {
const state = timelineGlobalListenerState();
if (state.installed.has(key)) return;
let effectiveOptions = options;
let controller = null;
if (typeof AbortController !== "undefined") {
controller = new AbortController();
effectiveOptions = typeof options === "boolean" ? { capture: options, signal: controller.signal } : { ...(options || {}), signal: controller.signal };
}
window.addEventListener(eventName, handler, effectiveOptions);
state.installed.add(key);
state.disposers.push(() => {
if (controller) {
controller.abort();
} else if (typeof window.removeEventListener === "function") {
window.removeEventListener(eventName, handler, effectiveOptions);
}
state.installed.delete(key);
});
}
export function errorText(error) {
if (!error) return "unknown error";
return error.message ? String(error.message) : String(error);
}
export function setTimelineStatus(node, text, tone = "info", durationMs = TIMELINE_STATUS_DURATION_MS) {
if (!node) return;
const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
node.etkTimelineStatus = { text: String(text || ""), tone, expiresAt: durationMs > 0 ? now + durationMs : 0 };
if (typeof node.setDirtyCanvas === "function") node.setDirtyCanvas(true, true);
if (node.graph && typeof node.graph.setDirtyCanvas === "function") node.graph.setDirtyCanvas(true, true);
}
export function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
export function makeId() {
if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID();
return `etk-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function getWidget(node, name) {
return node.widgets ? node.widgets.find((widget) => widget.name === name) : undefined;
}
export function getWidgetValue(node, name, fallback) {
const widget = getWidget(node, name);
return widget && widget.value != null ? widget.value : fallback;
}
export function getInput(node, name) {
return node.inputs ? node.inputs.find((input) => input.name === name) : undefined;
}
export function graphLinkById(graph, linkId) {
if (!graph || linkId == null) return null;
const candidates = [linkId, Number(linkId), String(linkId)];
for (const store of [graph.links, graph._links]) {
if (!store) continue;
if (typeof store.get === "function") {
for (const candidate of candidates) {
const link = store.get(candidate);
if (link) return link;
}
} else {
for (const candidate of candidates) {
const link = store[candidate];
if (link) return link;
}
}
}
return null;
}
export function valueFromCandidate(value) {
if (value == null) return undefined;
if (Array.isArray(value)) return value.length ? value[0] : undefined;
if (typeof value === "object") {
if ("value" in value) return value.value;
if ("text" in value) return value.text;
}
return value;
}
export function linkedSourceInfo(node, name) {
const input = getInput(node, name);
const linkId = input ? input.link : null;
const graph = node.graph || app.graph;
if (linkId == null || !graph) return null;
const link = graphLinkById(graph, linkId);
if (!link) return null;
const originId = link.origin_id ?? link.originId;
const originSlot = link.origin_slot ?? link.originSlot ?? 0;
const sourceNode =
typeof graph.getNodeById === "function"
? graph.getNodeById(originId)
: graph._nodes
? graph._nodes.find((candidate) => candidate.id === originId)
: null;
if (!sourceNode) return null;
const output = sourceNode.outputs ? sourceNode.outputs[originSlot] : null;
return { link, sourceNode, output };
}
export function linkedSourceNode(node, name) {
const info = linkedSourceInfo(node, name);
return info ? info.sourceNode : null;
}
export function valueFromLinkedSource(info, preferredName) {
if (!info || !info.sourceNode) return undefined;
const sourceNode = info.sourceNode;
const widgetNames = [preferredName];
if (info.output && info.output.name) widgetNames.push(info.output.name);
const lowerNames = widgetNames.map((name) => String(name || "").toLowerCase()).filter(Boolean);
const widgets = sourceNode.widgets || [];
const namedWidget = widgets.find((widget) => lowerNames.includes((widget.name || "").toLowerCase()));
if (namedWidget && namedWidget.value != null) return namedWidget.value;
if (widgets.length === 1 && widgets[0].value != null) return widgets[0].value;
if (sourceNode.properties) {
let propertyValue = sourceNode.properties[preferredName];
const candidate = valueFromCandidate(propertyValue);
if (candidate != null) return candidate;
}
return undefined;
}
export function getNumericControlValue(node, name, fallback, options = {}) {
const backendValue = node.etkTimelineBackendSettings ? node.etkTimelineBackendSettings[name] : undefined;
const backendNumeric = Number(valueFromCandidate(backendValue));
if (
Number.isFinite(backendNumeric) &&
(options.min == null || backendNumeric >= options.min) &&
(!options.rejectZero || backendNumeric !== 0)
) {
return backendNumeric;
}
const sourceValue = valueFromLinkedSource(linkedSourceInfo(node, name), name);
const widgetValue = getWidgetValue(node, name, fallback);
const candidates = sourceValue != null ? [sourceValue, widgetValue, fallback] : [widgetValue, fallback];
for (const candidate of candidates) {
const numeric = Number(valueFromCandidate(candidate));
if (!Number.isFinite(numeric)) continue;
if (options.min != null && numeric < options.min) continue;
if (options.rejectZero && numeric === 0) continue;
return numeric;
}
return fallback;
}
export function setWidgetValue(node, name, value, notify = false) {
const widget = getWidget(node, name);
if (!widget) return;
widget.value = value;
if (notify && typeof widget.callback === "function") widget.callback(value, node, widget);
}
export function hideWidget(widget, flagName, options = {}) {
if (!widget || widget[flagName]) return;
widget[flagName] = true;
widget.hidden = true;
if (options.serialize === false) widget.serialize = false;
widget.computeSize = () => [0, 0];
widget.draw = () => {};
if (widget.element) widget.element.style.display = "none";
}
export function ensureUiInstanceId(node) {
if (!node.properties) node.properties = {};
let value = node.etkTimelineUiInstanceId;
if (!value) {
value = makeId();
node.etkTimelineUiInstanceId = value;
}
node.properties[UI_INSTANCE_PROPERTY] = value;
const widget = getWidget(node, UI_INSTANCE_WIDGET);
if (widget) {
widget.value = value;
if (widget.element) widget.element.value = value;
hideWidget(widget, "etkTimelineUiInstanceHidden", { serialize: false });
}
return value;
}
export function uiPayloadValue(message, name) {
if (!message) return undefined;
const source = message.output && message.output[name] != null ? message.output : message;
const value = source[name];
return Array.isArray(value) ? value[0] : value;
}
export function parsePayloadObject(payload) {
if (payload == null) return null;
if (Array.isArray(payload)) payload = payload.length ? payload[0] : null;
if (typeof payload === "string") {
try {
payload = JSON.parse(payload);
} catch {
return null;
}
}
return payload && typeof payload === "object" ? payload : null;
}
export function payloadUiInstanceId(payload) {
const parsed = parsePayloadObject(payload);
return parsed && parsed.ui_instance_id != null ? String(parsed.ui_instance_id) : "";
}
export function payloadsMatchNode(node, payloads) {
const nodeId = ensureUiInstanceId(node);
return payloads.some((payload) => {
const payloadId = payloadUiInstanceId(payload);
return !!payloadId && payloadId === nodeId;
});
}
export function normalizeTimelineSettings(payload) {
if (payload == null) return null;
if (Array.isArray(payload)) payload = payload.length ? payload[0] : null;
if (typeof payload === "string") {
try {
payload = JSON.parse(payload);
} catch {
return null;
}
}
if (!payload || typeof payload !== "object") return null;
const settings = {};
for (const name of ["width", "height", "length", "fps"]) {
const numeric = Number(valueFromCandidate(payload[name]));
if (Number.isFinite(numeric)) settings[name] = name === "fps" ? Math.max(0.001, numeric) : Math.round(numeric);
}
return Object.keys(settings).length ? settings : null;
}
export function applyBackendTimelineSettings(node, payload) {
const settings = normalizeTimelineSettings(payload);
if (!settings) return false;
const previousSettings = node.etkTimelineBackendSettings || {};
const nextSettings = { ...previousSettings, ...settings };
node.etkTimelineBackendSettings = nextSettings;
if (!node.properties) node.properties = {};
node.properties[OUTPUT_SETTINGS_PROPERTY] = nextSettings;
let changed = false;
for (const name of ["width", "height", "length", "fps"]) {
if (settings[name] == null) continue;
if (previousSettings[name] !== settings[name]) changed = true;
setWidgetValue(node, name, settings[name], true);
}
return changed;
}
export function graphNodes(graph) {
return graph ? graph.nodes || graph._nodes || [] : [];
}
export function rootGraph() {
return app.rootGraph || (app.graph && app.graph.rootGraph) || app.graph || null;
}
export function activeCanvasGraph() {
const canvas = app.canvas;
if (canvas && typeof canvas.getCurrentGraph === "function") {
return canvas.getCurrentGraph() || canvas.graph || app.graph || null;
}
return (canvas && canvas.graph) || app.graph || null;
}
export function uniqueGraphs(...graphs) {
const seen = new Set();
const result = [];
for (const graph of graphs) {
if (!graph || seen.has(graph)) continue;
seen.add(graph);
result.push(graph);
}
return result;
}
export function findNodeInGraphByLocalId(graph, nodeId) {
if (nodeId == null || !graph) return null;
const normalizedId = String(nodeId);
const numericId = Number(nodeId);
if (typeof graph.getNodeById === "function" && Number.isFinite(numericId)) {
const node = graph.getNodeById(numericId);
if (node) return node;
}
return graphNodes(graph).find((node) => String(node.id) === normalizedId) || null;
}
export function walkGraphs(graph, callback, visited = new Set()) {
if (!graph || visited.has(graph)) return null;
visited.add(graph);
for (const node of graphNodes(graph)) {
const result = callback(node, graph);
if (result) return result;
if (node.subgraph) {
const subgraphResult = walkGraphs(node.subgraph, callback, visited);
if (subgraphResult) return subgraphResult;
}
}
const subgraphs = graph.subgraphs;
if (subgraphs && typeof subgraphs.values === "function") {
for (const subgraph of subgraphs.values()) {
const subgraphResult = walkGraphs(subgraph, callback, visited);
if (subgraphResult) return subgraphResult;
}
} else if (Array.isArray(subgraphs)) {
for (const subgraph of subgraphs) {
const subgraphResult = walkGraphs(subgraph, callback, visited);
if (subgraphResult) return subgraphResult;
}
}
return null;
}
export function findTimelineNodeByLocalId(nodeId, preferredGraph = null) {
const normalizedId = String(nodeId);
for (const graph of uniqueGraphs(preferredGraph, activeCanvasGraph(), rootGraph())) {
const node = findNodeInGraphByLocalId(graph, normalizedId);
if (isTargetNode(node)) return node;
}
return walkGraphs(rootGraph(), (node) => {
return String(node.id) === normalizedId && isTargetNode(node) ? node : null;
});
}
export function findTimelineNodeByLocatorId(locatorId) {
if (locatorId == null) return null;
const parts = String(locatorId).split(":");
if (parts.length !== 2 || Number.isFinite(Number(parts[0]))) return null;
const [graphId, localId] = parts;
return walkGraphs(rootGraph(), (node, graph) => {
return String(graph && graph.id) === graphId && String(node.id) === localId && isTargetNode(node) ? node : null;
});
}
export function findTimelineNodeByExecutionId(executionId) {
if (executionId == null) return null;
const normalizedId = String(executionId);
const parts = normalizedId.split(":").filter((part) => part !== "");
if (parts.length <= 1) return findTimelineNodeByLocalId(normalizedId);
for (const graph of uniqueGraphs(rootGraph(), activeCanvasGraph())) {
let currentGraph = graph;
let node = null;
for (let index = 0; index < parts.length; index += 1) {
node = findNodeInGraphByLocalId(currentGraph, parts[index]);
if (!node) break;
if (index === parts.length - 1) {
if (isTargetNode(node)) return node;
break;
}
currentGraph = node.subgraph;
if (!currentGraph) break;
}
}
return findTimelineNodeByLocalId(parts[parts.length - 1]);
}
export function timelinePayloadUiInstanceId(payload) {
if (payload == null) return null;
if (Array.isArray(payload)) payload = payload.length ? payload[0] : null;
if (typeof payload === "string") {
try {
payload = JSON.parse(payload);
} catch {
return null;
}
}
if (!payload || typeof payload !== "object") return null;
const value = valueFromCandidate(payload[UI_INSTANCE_WIDGET] || payload.uiInstanceId);
return value == null ? null : String(value);
}
export function nodeUiInstanceId(node) {
if (!node) return null;
const propertyValue = node.properties ? node.properties[UI_INSTANCE_PROPERTY] : null;
if (propertyValue != null) return String(propertyValue);
const widget = getWidget(node, UI_INSTANCE_WIDGET);
return widget && widget.value != null ? String(widget.value) : null;
}
export function findTimelineNodeByUiInstanceId(settingsPayload) {
const uiInstanceId = timelinePayloadUiInstanceId(settingsPayload);
if (!uiInstanceId) return null;
return walkGraphs(rootGraph(), (node) => {
return isTargetNode(node) && nodeUiInstanceId(node) === uiInstanceId ? node : null;
});
}
export function findTimelineNodeById(nodeId) {
if (nodeId == null) return null;
return findTimelineNodeByLocatorId(nodeId) || findTimelineNodeByExecutionId(nodeId);
}
export function applyBackendTimelineSettingsToNode(node, settingsPayload) {
if (!node || !isTargetNode(node)) return false;
if (!payloadsMatchNode(node, [settingsPayload])) return false;
setupNode(node);
const changed = applyBackendTimelineSettings(node, settingsPayload);
syncTimelineWidget(node);
node.setDirtyCanvas(true, true);
if (node.graph && typeof node.graph.setDirtyCanvas === "function") {
node.graph.setDirtyCanvas(true, true);
}
return changed;
}
export function deliverBackendTimelineSettingsToNode(node, settingsPayload) {
if (!node || !isTargetNode(node)) return false;
if (!payloadsMatchNode(node, [settingsPayload])) return false;
applyBackendTimelineSettingsToNode(node, settingsPayload);
return true;
}
export function installBackendTimelineSettingsListener() {
if (installBackendTimelineSettingsListener.installed) return;
installBackendTimelineSettingsListener.installed = true;
if (!api || typeof api.addEventListener !== "function") return;
api.addEventListener("etk_ltxv_timeline_settings", (event) => {
const detail = event && event.detail ? event.detail : {};
const candidates = [
findTimelineNodeById(detail.display_node),
findTimelineNodeById(detail.node),
findTimelineNodeByUiInstanceId(detail.settings),
];
const seen = new Set();
for (const node of candidates) {
if (!node || seen.has(node)) continue;
seen.add(node);
if (deliverBackendTimelineSettingsToNode(node, detail.settings)) return;
}
});
}
+300
View File
@@ -0,0 +1,300 @@
// Timeline geometry, keyframe operations, sliders, and time conversion.
import {
CUSTOM_AREA_TOP,
PREVIEW_MIN_HEIGHT,
TIMELINE_BOTTOM_PAD,
TIMELINE_CONTROLS_GAP,
TIMELINE_CONTROLS_HEIGHT,
TIMELINE_GAP,
TIMELINE_TOP_PAD,
TIMELINE_TRACK_HEIGHT,
} from "./constants.js";
import {
clamp,
getNumericControlValue,
getWidgetValue,
linkedSourceNode,
makeId,
normalizeTimelineSettings,
setTimelineStatus,
setWidgetValue,
} from "./runtime.js";
import {
displayLatentStrength,
mirrorBaseLayer,
normalizeFilterValue,
normalizeKeyframes,
normalizeLatentStrengthValue,
normalizeLayer,
normalizePromptStrength,
normalizeSlot,
preloadImage,
rejectLayerEditIfLocked,
setSelectedKeyframe,
syncTimelineWidget,
} from "./model.js";
import { timelineEditorWidget } from "./editors.js";
import { keyframeStrengthSliderBounds, layerFilterSliderBounds } from "./interaction.js";
export function bounds(node, areaY = null, areaH = null) {
const padX = 20;
const width = Math.max(1, node.size[0] - padX * 2);
const widget = timelineEditorWidget(node);
const widgetY = Number.isFinite(areaY)
? areaY
: Number.isFinite(widget && widget.last_y)
? widget.last_y
: Number.isFinite(node.etkTimelineEditorY)
? node.etkTimelineEditorY
: CUSTOM_AREA_TOP;
const timelineY = widgetY + TIMELINE_TOP_PAD;
const timelineH = TIMELINE_TRACK_HEIGHT;
const controlsY = timelineY + timelineH + TIMELINE_GAP;
const controlsH = TIMELINE_CONTROLS_HEIGHT;
const previewY = controlsY + controlsH + TIMELINE_CONTROLS_GAP;
const availableHeight = Number.isFinite(areaH)
? areaH - TIMELINE_TOP_PAD - timelineH - TIMELINE_GAP - controlsH - TIMELINE_CONTROLS_GAP - TIMELINE_BOTTOM_PAD
: node.size[1] - previewY - TIMELINE_BOTTOM_PAD;
const previewH = Math.max(PREVIEW_MIN_HEIGHT, availableHeight);
return { padX, width, widgetY, timelineY, timelineH, controlsY, controlsH, previewY, previewH };
}
export function duration(node) {
const rawLength = Math.max(1, Math.round(getNumericControlValue(node, "length", 97, { min: 1, rejectZero: true })));
const normalized = Math.max(1, Math.round((rawLength - 1) / 8) * 8 + 1);
if (!linkedSourceNode(node, "length") && normalized !== rawLength) setWidgetValue(node, "length", normalized);
return normalized;
}
export function latentSlots(node) {
return Math.max(1, Math.floor((duration(node) - 1) / 8) + 1);
}
export function timelineFps(node) {
if (!node) return 25;
const fps = Number(getNumericControlValue(node, "fps", Number.NaN, { min: 0.001 }));
if (Number.isFinite(fps) && fps > 0) return fps;
const promptFps = Number(getNumericControlValue(node, "prompt_schedule_fps", 25, { min: 0.001 }));
return Number.isFinite(promptFps) && promptFps > 0 ? promptFps : 25;
}
export function slotToSeconds(node, slot) {
return Math.max(0, normalizeSlot({ slot }) * 8) / timelineFps(node);
}
export function formatSeconds(value) {
const seconds = Number(value);
if (!Number.isFinite(seconds)) return "0s";
const fixed = seconds >= 10 ? seconds.toFixed(1) : seconds.toFixed(2);
return `${fixed.replace(/\.?0+$/, "")}s`;
}
export function formatSlotSeconds(node, slot) {
return formatSeconds(slotToSeconds(node, slot));
}
export function outputDimension(node, name, fallback) {
const raw = Math.max(32, getNumericControlValue(node, name, fallback, { min: 32 }));
return Math.max(32, Math.round(raw / 32) * 32);
}
export function widgetOutputSettings(node) {
if (!node) return null;
return normalizeTimelineSettings({
width: getWidgetValue(node, "width"),
height: getWidgetValue(node, "height"),
length: getWidgetValue(node, "length"),
fps: getWidgetValue(node, "fps"),
});
}
export function previewImageBox(node, areaY = null, areaH = null) {
const { padX, width, previewY, previewH } = bounds(node, areaY, areaH);
const targetW = outputDimension(node, "width", 64);
const targetH = outputDimension(node, "height", 64);
const scale = Math.min((width - 12) / targetW, (previewH - 12) / targetH);
const boxW = Math.max(1, targetW * scale);
const boxH = Math.max(1, targetH * scale);
return {
targetW,
targetH,
boxW,
boxH,
boxX: padX + (width - boxW) / 2,
boxY: previewY + 6 + Math.max(0, (previewH - 12 - boxH) / 2),
};
}
export function slotToX(node, slot) {
const { padX, width } = bounds(node);
const maxSlot = Math.max(1, latentSlots(node) - 1);
return padX + width * clamp(slot / maxSlot, 0, 1);
}
export function xToSlot(node, x) {
const { padX, width } = bounds(node);
const maxSlot = Math.max(0, latentSlots(node) - 1);
return Math.round(clamp((x - padX) / width, 0, 1) * maxSlot);
}
export function findKeyframeAt(node, pos) {
const { timelineY, timelineH } = bounds(node);
const [x, y] = pos;
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
for (let index = keyframes.length - 1; index >= 0; index -= 1) {
const cx = slotToX(node, keyframes[index].slot);
const cy = timelineY + timelineH / 2;
if (Math.abs(x - cx) <= 16 && Math.abs(y - cy) <= 18) return index;
}
return -1;
}
export function slotConflict(node, slot, ignoreIndex = -1) {
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
return keyframes.findIndex((keyframe, index) => index !== ignoreIndex && keyframe.slot === slot);
}
export function nearestFreeSlot(node, slot) {
const maxSlot = latentSlots(node) - 1;
const desiredSlot = clamp(slot, 0, maxSlot);
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const occupied = new Set(keyframes.map((keyframe) => keyframe.slot));
if (!occupied.has(desiredSlot)) return desiredSlot;
for (let offset = 1; offset <= maxSlot; offset += 1) {
const right = desiredSlot + offset;
if (right <= maxSlot && !occupied.has(right)) return right;
const left = desiredSlot - offset;
if (left >= 0 && !occupied.has(left)) return left;
}
return null;
}
export function addKeyframeAt(node, x) {
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const requestedSlot = xToSlot(node, x);
const slot = nearestFreeSlot(node, requestedSlot);
if (slot == null) {
const conflictIndex = slotConflict(node, requestedSlot);
if (conflictIndex >= 0) setSelectedKeyframe(node, conflictIndex, keyframes);
setTimelineStatus(node, "No free keyframe positions are available for another keyframe.", "error");
node.setDirtyCanvas(true, true);
return false;
}
const conflictIndex = slotConflict(node, slot);
if (conflictIndex >= 0) {
setSelectedKeyframe(node, conflictIndex, keyframes);
node.setDirtyCanvas(true, true);
return false;
}
const keyframe = { id: makeId(), time: slotToSeconds(node, slot), slot, fitMode: "crop", panX: 0, panY: 0, zoom: 1, image: null };
keyframes.push(keyframe);
keyframes.sort((a, b) => a.slot - b.slot);
node.properties.etk_ltxv_timeline_keyframes = keyframes;
setSelectedKeyframe(node, keyframes.findIndex((candidate) => candidate.id === keyframe.id), keyframes);
syncTimelineWidget(node);
node.setDirtyCanvas(true, true);
return true;
}
export function duplicateKeyframeAtIndex(node, index) {
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
if (index < 0 || index >= keyframes.length) return false;
const source = keyframes[index];
const slot = nearestFreeSlot(node, source.slot + 1);
if (slot == null) {
setTimelineStatus(node, "No free keyframe positions are available for a duplicate keyframe.", "error");
node.setDirtyCanvas(true, true);
return false;
}
const duplicate = {
...source,
id: makeId(),
time: slotToSeconds(node, slot),
slot,
image: source.image ? { ...source.image } : null,
layers: Array.isArray(source.layers)
? source.layers.map((layer) => ({ ...layer, image: layer.image ? { ...layer.image } : null }))
: [],
};
keyframes.push(duplicate);
keyframes.sort((a, b) => a.slot - b.slot);
node.properties.etk_ltxv_timeline_keyframes = keyframes;
setSelectedKeyframe(node, keyframes.findIndex((candidate) => candidate.id === duplicate.id), keyframes);
preloadImage(node, duplicate);
syncTimelineWidget(node);
setTimelineStatus(node, `Duplicated keyframe to ${formatSlotSeconds(node, slot)}.`, "success");
node.setDirtyCanvas(true, true);
return true;
}
export function deleteKeyframeAtIndex(node, index) {
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
if (index < 0 || index >= keyframes.length) return false;
keyframes.splice(index, 1);
node.properties.etk_ltxv_timeline_keyframes = keyframes;
setSelectedKeyframe(node, keyframes.length ? Math.min(index, keyframes.length - 1) : -1, keyframes);
syncTimelineWidget(node);
setTimelineStatus(node, "Deleted selected keyframe.", "success");
node.setDirtyCanvas(true, true);
return true;
}
export function setKeyframeStrength(node, keyframeId, kind, rawValue) {
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const index = keyframes.findIndex((keyframe) => keyframe.id === keyframeId);
if (index < 0) return false;
const next = { ...keyframes[index] };
if (kind === "latent") {
next.latentStrength = normalizeLatentStrengthValue(rawValue, displayLatentStrength(node, next));
} else if (kind === "prompt") {
next.promptStrength = normalizePromptStrength(rawValue);
} else {
return false;
}
keyframes[index] = next;
node.properties.etk_ltxv_timeline_keyframes = keyframes;
setSelectedKeyframe(node, index, keyframes);
syncTimelineWidget(node);
node.setDirtyCanvas(true, true);
return true;
}
export function setLayerFilter(node, keyframeId, layerId, kind, rawValue) {
if (rejectLayerEditIfLocked(node, "Layer filter")) return false;
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const keyframeIndex = keyframes.findIndex((keyframe) => keyframe.id === keyframeId);
if (keyframeIndex < 0) return false;
const keyframe = keyframes[keyframeIndex];
const layerIndex = Array.isArray(keyframe.layers) ? keyframe.layers.findIndex((layer) => layer.id === layerId) : -1;
if (layerIndex < 0) return false;
const nextLayer = {
...keyframe.layers[layerIndex],
[kind]: normalizeFilterValue(rawValue),
};
keyframe.layers[layerIndex] = normalizeLayer(nextLayer, { id: nextLayer.id }) || nextLayer;
keyframe.selectedLayerId = keyframe.layers[layerIndex].id;
mirrorBaseLayer(keyframe);
keyframes[keyframeIndex] = keyframe;
node.properties.etk_ltxv_timeline_keyframes = keyframes;
setSelectedKeyframe(node, keyframeIndex, keyframes);
syncTimelineWidget(node);
node.setDirtyCanvas(true, true);
return true;
}
export function sliderValueFromX(slider, x) {
const ratio = clamp((x - slider.trackX) / slider.trackW, 0, 1);
return slider.min + ratio * (slider.max - slider.min);
}
export function updateTimelineSliderDrag(node, pos) {
if (!node || !node.etkSliderDragging || !pos) return false;
const drag = node.etkSliderDragging;
const slider = drag.family === "filter" ? layerFilterSliderBounds(node, drag.kind) : keyframeStrengthSliderBounds(node, drag.kind);
const value = sliderValueFromX(slider, pos[0]);
if (drag.family === "filter") return setLayerFilter(node, drag.keyframeId, drag.layerId, drag.kind, value);
return setKeyframeStrength(node, drag.keyframeId, drag.kind, value);
}