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