758 lines
29 KiB
JavaScript
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();
|
|
}
|