Release standalone timeline editor 1.0.0
This commit is contained in:
@@ -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.
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
import "./ltxv_timeline/extension.js";
|
||||
import {
|
||||
applyBackendTimelineSettingsToNode,
|
||||
ensureUiInstanceId,
|
||||
payloadsMatchNode,
|
||||
} from "./ltxv_timeline/runtime.js";
|
||||
|
||||
export {
|
||||
applyBackendTimelineSettingsToNode,
|
||||
ensureUiInstanceId,
|
||||
payloadsMatchNode,
|
||||
};
|
||||
@@ -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",
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user