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
+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;
}