Files
2026-08-26 09:55:12 -06:00

758 lines
29 KiB
JavaScript

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