Release standalone timeline editor 1.0.0
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user