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