| import { app } from "../../../scripts/app.js"; |
|
|
| |
| |
| |
|
|
| function catmullRom(p0, p1, p2, p3, t) { |
| const t2 = t * t; |
| const t3 = t2 * t; |
| return { |
| x: |
| 0.5 * |
| (2 * p1.x + |
| (-p0.x + p2.x) * t + |
| (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + |
| (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3), |
| y: |
| 0.5 * |
| (2 * p1.y + |
| (-p0.y + p2.y) * t + |
| (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + |
| (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3), |
| }; |
| } |
|
|
| function interpolateSpline(controlPoints, numSamples) { |
| if (controlPoints.length === 0) return []; |
| if (controlPoints.length === 1) { |
| return Array.from({ length: numSamples }, () => ({ ...controlPoints[0] })); |
| } |
| if (controlPoints.length === 2) { |
| const [a, b] = controlPoints; |
| return Array.from({ length: numSamples }, (_, i) => { |
| const t = i / (numSamples - 1); |
| return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; |
| }); |
| } |
| |
| const pts = [ |
| controlPoints[0], |
| ...controlPoints, |
| controlPoints[controlPoints.length - 1], |
| ]; |
| const nSeg = pts.length - 3; |
| const result = []; |
| for (let i = 0; i < numSamples; i++) { |
| const gT = (i / (numSamples - 1)) * nSeg; |
| const seg = Math.min(Math.floor(gT), nSeg - 1); |
| const lT = gT - seg; |
| result.push(catmullRom(pts[seg], pts[seg + 1], pts[seg + 2], pts[seg + 3], lT)); |
| } |
| return result; |
| } |
|
|
| |
| |
| |
|
|
| const SPLINE_COLORS = [ |
| "#ef4444", "#22c55e", "#3b82f6", "#f59e0b", |
| "#a855f7", "#06b6d4", "#f97316", "#84cc16", |
| ]; |
| const POINT_RADIUS = 5; |
| const ACTIVE_POINT_RADIUS = 7; |
| const HIT_TOLERANCE = 14; |
| const CURVE_LINE_WIDTH = 2.5; |
| const CANVAS_MIN_HEIGHT = 128; |
|
|
| |
| |
| |
|
|
| function dist(a, b) { |
| return Math.hypot(a.x - b.x, a.y - b.y); |
| } |
|
|
| function hideWidget(w) { |
| if (!w) return; |
| w.hidden = true; |
| w._origComputeSize = w.computeSize; |
| w.computeSize = () => [0, -3.3]; |
| w.computedHeight = 0; |
| } |
|
|
| function closestSegment(pts, p) { |
| let best = Infinity; |
| let bestIdx = 0; |
| for (let i = 0; i < pts.length - 1; i++) { |
| const a = pts[i]; |
| const b = pts[i + 1]; |
| const dx = b.x - a.x; |
| const dy = b.y - a.y; |
| const len2 = dx * dx + dy * dy; |
| let t = len2 === 0 ? 0 : ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2; |
| t = Math.max(0, Math.min(1, t)); |
| const proj = { x: a.x + t * dx, y: a.y + t * dy }; |
| const d = dist(proj, p); |
| if (d < best) { |
| best = d; |
| bestIdx = i; |
| } |
| } |
| return { dist: best, segIndex: bestIdx }; |
| } |
|
|
| |
| |
| |
|
|
| app.registerExtension({ |
| name: "LTXVideo.SparseTrackEditor", |
|
|
| async nodeCreated(node) { |
| if (node.comfyClass !== "LTXVSparseTrackEditor") return; |
| initEditor(node); |
| }, |
|
|
| async beforeRegisterNodeDef(nodeType, nodeData) { |
| if (nodeData?.name !== "LTXVSparseTrackEditor") return; |
|
|
| const origExecuted = nodeType.prototype.onExecuted; |
| nodeType.prototype.onExecuted = function (data) { |
| origExecuted?.apply(this, arguments); |
| if (data?.bg_image?.[0]) { |
| loadBgImage(this, data.bg_image[0]); |
| } |
| }; |
|
|
| const origConfigure = nodeType.prototype.onConfigure; |
| nodeType.prototype.onConfigure = function (info) { |
| origConfigure?.apply(this, arguments); |
| if (this._ed) { |
| reloadState(this); |
| } |
| }; |
|
|
| const origRemoved = nodeType.prototype.onRemoved; |
| nodeType.prototype.onRemoved = function () { |
| origRemoved?.apply(this, arguments); |
| if (this._ed) { |
| cancelAnimationFrame(this._ed.rafId); |
| if (this._ed._docClickHandler) { |
| document.removeEventListener("click", this._ed._docClickHandler); |
| } |
| this._ed = null; |
| } |
| }; |
| }, |
| }); |
|
|
| |
| |
| |
|
|
| function initEditor(node) { |
| const pointsW = node.widgets.find((w) => w.name === "points_store"); |
| const coordsW = node.widgets.find((w) => w.name === "coordinates"); |
| hideWidget(pointsW); |
| hideWidget(coordsW); |
|
|
| const ed = { |
| splines: [], |
| active: 0, |
| imgW: 1024, |
| imgH: 576, |
| bgImg: null, |
| drag: null, |
| hover: null, |
| canvas: null, |
| ctx: null, |
| menu: null, |
| dirty: true, |
| rafId: null, |
| }; |
| node._ed = ed; |
|
|
| |
| try { |
| const saved = JSON.parse(pointsW.value); |
| if (Array.isArray(saved) && saved.length > 0) { |
| ed.splines = saved; |
| } |
| } catch (_) { |
| |
| } |
| if (ed.splines.length === 0) { |
| ed.splines = [ |
| [ |
| { x: ed.imgW * 0.3, y: ed.imgH * 0.5 }, |
| { x: ed.imgW * 0.7, y: ed.imgH * 0.5 }, |
| ], |
| ]; |
| } |
|
|
| |
| const container = document.createElement("div"); |
| container.style.position = "relative"; |
| container.style.minHeight = `${CANVAS_MIN_HEIGHT}px`; |
|
|
| const canvas = document.createElement("canvas"); |
| canvas.width = 1024; |
| canvas.height = 576; |
| canvas.style.width = "100%"; |
| canvas.style.display = "block"; |
| canvas.style.background = "#1a1a2e"; |
| canvas.style.borderRadius = "4px"; |
| canvas.style.cursor = "default"; |
| canvas.style.pointerEvents = "auto"; |
| canvas.style.touchAction = "none"; |
| container.appendChild(canvas); |
| ed.canvas = canvas; |
| ed.ctx = canvas.getContext("2d"); |
|
|
| const menu = buildContextMenu(); |
| container.appendChild(menu); |
| ed.menu = menu; |
|
|
| const widget = node.addDOMWidget("track_editor", "TrackEditorWidget", container, { |
| serialize: false, |
| hideOnZoom: false, |
| }); |
| widget.computeSize = () => { |
| const w = node.size[0]; |
| const aspect = node._ed.imgH / node._ed.imgW; |
| const h = Math.max(CANVAS_MIN_HEIGHT, Math.round(w * aspect)); |
| return [w, h + 10]; |
| }; |
|
|
| setupEvents(node); |
| syncWidgets(node); |
| startRenderLoop(node); |
|
|
| requestAnimationFrame(() => { |
| node.setSize?.(node.computeSize()); |
| app.graph?.setDirtyCanvas(true, true); |
| }); |
| } |
|
|
| |
| |
| |
|
|
| function reloadState(node) { |
| const ed = node._ed; |
| const pointsW = node.widgets.find((w) => w.name === "points_store"); |
| try { |
| const saved = JSON.parse(pointsW?.value); |
| if (Array.isArray(saved) && saved.length > 0) { |
| ed.splines = saved; |
| } |
| } catch (_) { |
| |
| } |
| ed.dirty = true; |
| syncWidgets(node); |
| } |
|
|
| |
| |
| |
|
|
| function loadBgImage(node, b64) { |
| const ed = node._ed; |
| const img = new window.Image(); |
| img.onload = () => { |
| const firstLoad = ed.bgImg === null; |
| ed.bgImg = img; |
| ed.imgW = img.width; |
| ed.imgH = img.height; |
| if (firstLoad && ed.splines.length === 1 && ed.splines[0].length === 2) { |
| ed.splines[0][0] = { x: ed.imgW * 0.3, y: ed.imgH * 0.5 }; |
| ed.splines[0][1] = { x: ed.imgW * 0.7, y: ed.imgH * 0.5 }; |
| } |
| ed.dirty = true; |
| syncWidgets(node); |
| requestAnimationFrame(() => { |
| node.setSize?.(node.computeSize()); |
| app.graph?.setDirtyCanvas(true, true); |
| }); |
| }; |
| img.src = `data:image/jpeg;base64,${b64}`; |
| } |
|
|
| |
| |
| |
|
|
| function getScale(node) { |
| const ed = node._ed; |
| const c = ed.canvas; |
| const scaleX = c.width / ed.imgW; |
| const scaleY = c.height / ed.imgH; |
| return Math.min(scaleX, scaleY); |
| } |
|
|
| function imgToCanvas(node, p) { |
| const s = getScale(node); |
| return { x: p.x * s, y: p.y * s }; |
| } |
|
|
| function canvasToImg(node, p) { |
| const s = getScale(node); |
| return { x: p.x / s, y: p.y / s }; |
| } |
|
|
| function mouseToCanvas(node, e) { |
| const rect = node._ed.canvas.getBoundingClientRect(); |
| return { |
| x: (e.clientX - rect.left) * (node._ed.canvas.width / rect.width), |
| y: (e.clientY - rect.top) * (node._ed.canvas.height / rect.height), |
| }; |
| } |
|
|
| |
| |
| |
|
|
| function hitTestPoint(node, canvasPos) { |
| const ed = node._ed; |
| const dpr = window.devicePixelRatio || 1; |
| const tol = HIT_TOLERANCE * dpr; |
| for (let si = 0; si < ed.splines.length; si++) { |
| for (let pi = 0; pi < ed.splines[si].length; pi++) { |
| const cp = imgToCanvas(node, ed.splines[si][pi]); |
| if (dist(cp, canvasPos) < tol) { |
| return { si, pi }; |
| } |
| } |
| } |
| return null; |
| } |
|
|
| |
| |
| |
|
|
| function buildContextMenu() { |
| const menu = document.createElement("div"); |
| Object.assign(menu.style, { |
| display: "none", |
| position: "absolute", |
| background: "#252530", |
| border: "1px solid #555", |
| borderRadius: "6px", |
| padding: "4px 0", |
| zIndex: "1000", |
| boxShadow: "0 4px 12px rgba(0,0,0,0.4)", |
| minWidth: "160px", |
| fontFamily: "system-ui, sans-serif", |
| fontSize: "13px", |
| }); |
| return menu; |
| } |
|
|
| function showMenu(node, canvasEvt, imgPos) { |
| const ed = node._ed; |
| const menu = ed.menu; |
| menu.innerHTML = ""; |
|
|
| const hit = hitTestPoint(node, mouseToCanvas(node, canvasEvt)); |
|
|
| const items = []; |
| items.push({ |
| label: "Add Point at Cursor", |
| action: () => { |
| ed.splines[ed.active].push({ x: imgPos.x, y: imgPos.y }); |
| ed.dirty = true; |
| syncWidgets(node); |
| }, |
| }); |
| items.push({ |
| label: "Subdivide Nearest Segment", |
| action: () => { |
| const spline = ed.splines[ed.active]; |
| if (spline.length < 2) return; |
| const { segIndex } = closestSegment(spline, imgPos); |
| const a = spline[segIndex]; |
| const b = spline[segIndex + 1]; |
| const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; |
| spline.splice(segIndex + 1, 0, mid); |
| ed.dirty = true; |
| syncWidgets(node); |
| }, |
| }); |
| items.push({ separator: true }); |
| items.push({ |
| label: "New Spline", |
| action: () => { |
| ed.splines.push([ |
| { x: imgPos.x - 40, y: imgPos.y }, |
| { x: imgPos.x + 40, y: imgPos.y }, |
| ]); |
| ed.active = ed.splines.length - 1; |
| ed.dirty = true; |
| syncWidgets(node); |
| }, |
| }); |
| items.push({ |
| label: "New Static Spline", |
| action: () => { |
| ed.splines.push([{ x: imgPos.x, y: imgPos.y }]); |
| ed.active = ed.splines.length - 1; |
| ed.dirty = true; |
| syncWidgets(node); |
| }, |
| }); |
| items.push({ |
| label: "Delete Spline", |
| action: () => { |
| if (ed.splines.length <= 1) return; |
| ed.splines.splice(ed.active, 1); |
| ed.active = Math.min(ed.active, ed.splines.length - 1); |
| ed.dirty = true; |
| syncWidgets(node); |
| }, |
| disabled: ed.splines.length <= 1, |
| }); |
| if (hit) { |
| const spline = ed.splines[hit.si]; |
| if (spline.length > 1) { |
| items.push({ separator: true }); |
| items.push({ |
| label: "Delete Point", |
| action: () => { |
| spline.splice(hit.pi, 1); |
| ed.dirty = true; |
| syncWidgets(node); |
| }, |
| }); |
| } |
| } |
|
|
| for (const item of items) { |
| if (item.separator) { |
| const hr = document.createElement("div"); |
| hr.style.borderTop = "1px solid #444"; |
| hr.style.margin = "4px 8px"; |
| menu.appendChild(hr); |
| continue; |
| } |
| const el = document.createElement("div"); |
| el.textContent = item.label; |
| const isDisabled = item.disabled; |
| Object.assign(el.style, { |
| padding: "6px 14px", |
| cursor: isDisabled ? "default" : "pointer", |
| color: isDisabled ? "#666" : "#ddd", |
| whiteSpace: "nowrap", |
| }); |
| if (!isDisabled) { |
| el.addEventListener("mouseenter", () => (el.style.background = "#3a3a4a")); |
| el.addEventListener("mouseleave", () => (el.style.background = "none")); |
| el.addEventListener("click", (e) => { |
| e.stopPropagation(); |
| menu.style.display = "none"; |
| item.action(); |
| }); |
| } |
| menu.appendChild(el); |
| } |
|
|
| const rect = node._ed.canvas.getBoundingClientRect(); |
| const containerRect = node._ed.canvas.parentElement.getBoundingClientRect(); |
| menu.style.left = `${canvasEvt.clientX - containerRect.left}px`; |
| menu.style.top = `${canvasEvt.clientY - containerRect.top}px`; |
| menu.style.display = "block"; |
| } |
|
|
| |
| |
| |
|
|
| function setupEvents(node) { |
| const ed = node._ed; |
| const canvas = ed.canvas; |
|
|
| canvas.addEventListener("pointerdown", (e) => { |
| ed.menu.style.display = "none"; |
| if (e.button === 2) return; |
| if (e.button !== 0) return; |
| e.preventDefault(); |
| e.stopPropagation(); |
| canvas.setPointerCapture(e.pointerId); |
|
|
| const cp = mouseToCanvas(node, e); |
| const hit = hitTestPoint(node, cp); |
| if (hit) { |
| ed.active = hit.si; |
| ed.drag = { ...hit, pointerId: e.pointerId }; |
| canvas.style.cursor = "grabbing"; |
| ed.dirty = true; |
| } |
| }); |
|
|
| canvas.addEventListener("pointermove", (e) => { |
| const cp = mouseToCanvas(node, e); |
|
|
| if (ed.drag) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| const imgP = canvasToImg(node, cp); |
| imgP.x = Math.max(0, Math.min(ed.imgW, imgP.x)); |
| imgP.y = Math.max(0, Math.min(ed.imgH, imgP.y)); |
| ed.splines[ed.drag.si][ed.drag.pi] = imgP; |
| ed.dirty = true; |
| syncWidgets(node); |
| return; |
| } |
|
|
| const hit = hitTestPoint(node, cp); |
| if (hit) { |
| canvas.style.cursor = "grab"; |
| ed.hover = hit; |
| } else { |
| canvas.style.cursor = "default"; |
| ed.hover = null; |
| } |
| ed.dirty = true; |
| }); |
|
|
| canvas.addEventListener("pointerup", (e) => { |
| if (ed.drag) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| canvas.releasePointerCapture(e.pointerId); |
| ed.drag = null; |
| canvas.style.cursor = "default"; |
| ed.dirty = true; |
| syncWidgets(node); |
| } |
| }); |
|
|
| canvas.addEventListener("pointerleave", () => { |
| ed.hover = null; |
| ed.dirty = true; |
| }); |
|
|
| canvas.addEventListener("lostpointercapture", () => { |
| if (ed.drag) { |
| ed.drag = null; |
| canvas.style.cursor = "default"; |
| ed.dirty = true; |
| syncWidgets(node); |
| } |
| }); |
|
|
| canvas.addEventListener("contextmenu", (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| const cp = mouseToCanvas(node, e); |
| const imgP = canvasToImg(node, cp); |
| showMenu(node, e, imgP); |
| }); |
|
|
| const docClickHandler = (e) => { |
| if (!ed.menu.contains(e.target)) { |
| ed.menu.style.display = "none"; |
| } |
| }; |
| document.addEventListener("click", docClickHandler); |
| ed._docClickHandler = docClickHandler; |
| } |
|
|
| |
| |
| |
|
|
| function syncWidgets(node) { |
| const ed = node._ed; |
| const pointsW = node.widgets.find((w) => w.name === "points_store"); |
| const coordsW = node.widgets.find((w) => w.name === "coordinates"); |
| const samplesW = node.widgets.find((w) => w.name === "points_to_sample"); |
|
|
| if (pointsW) pointsW.value = JSON.stringify(ed.splines); |
|
|
| const numSamples = samplesW ? samplesW.value : 121; |
| const interpolated = ed.splines.map((sp) => interpolateSpline(sp, numSamples)); |
| |
| const rounded = interpolated.map((track) => |
| track.map((p) => ({ x: Math.round(p.x), y: Math.round(p.y) })) |
| ); |
| if (coordsW) coordsW.value = JSON.stringify(rounded); |
| } |
|
|
| |
| |
| |
|
|
| function startRenderLoop(node) { |
| const ed = node._ed; |
|
|
| function loop() { |
| ed.rafId = requestAnimationFrame(loop); |
| resizeCanvas(node); |
| if (!ed.dirty) return; |
| ed.dirty = false; |
| render(node); |
| } |
|
|
| loop(); |
| } |
|
|
| function resizeCanvas(node) { |
| const ed = node._ed; |
| const canvas = ed.canvas; |
| const dispW = canvas.clientWidth || node.size?.[0] || 400; |
|
|
| const aspect = ed.imgH / ed.imgW; |
| const dispH = Math.max(CANVAS_MIN_HEIGHT, Math.round(dispW * aspect)); |
|
|
| canvas.style.height = `${dispH}px`; |
|
|
| const dpr = window.devicePixelRatio || 1; |
| const bufW = Math.round(dispW * dpr); |
| const bufH = Math.round(dispH * dpr); |
|
|
| if (canvas.width !== bufW || canvas.height !== bufH) { |
| canvas.width = bufW; |
| canvas.height = bufH; |
| ed.dirty = true; |
| } |
| } |
|
|
| function render(node) { |
| const ed = node._ed; |
| const ctx = ed.ctx; |
| const c = ed.canvas; |
|
|
| ctx.clearRect(0, 0, c.width, c.height); |
|
|
| |
| if (ed.bgImg) { |
| const s = getScale(node); |
| ctx.globalAlpha = 0.85; |
| ctx.drawImage(ed.bgImg, 0, 0, ed.imgW * s, ed.imgH * s); |
| ctx.globalAlpha = 1.0; |
| } |
|
|
| const samplesW = node.widgets.find((w) => w.name === "points_to_sample"); |
| const numSamples = samplesW ? samplesW.value : 121; |
| const dpr = window.devicePixelRatio || 1; |
|
|
| |
| for (let si = 0; si < ed.splines.length; si++) { |
| const isActive = si === ed.active; |
| const color = SPLINE_COLORS[si % SPLINE_COLORS.length]; |
| const spline = ed.splines[si]; |
|
|
| |
| if (spline.length >= 2) { |
| const curve = interpolateSpline(spline, Math.max(numSamples, 60)); |
| ctx.beginPath(); |
| const p0 = imgToCanvas(node, curve[0]); |
| ctx.moveTo(p0.x, p0.y); |
| for (let i = 1; i < curve.length; i++) { |
| const p = imgToCanvas(node, curve[i]); |
| ctx.lineTo(p.x, p.y); |
| } |
| ctx.strokeStyle = color; |
| ctx.lineWidth = (isActive ? CURVE_LINE_WIDTH * 1.4 : CURVE_LINE_WIDTH) * dpr; |
| ctx.globalAlpha = isActive ? 1.0 : 0.5; |
| ctx.stroke(); |
| ctx.globalAlpha = 1.0; |
| } |
|
|
| |
| for (let pi = 0; pi < spline.length; pi++) { |
| const cp = imgToCanvas(node, spline[pi]); |
| const isHov = ed.hover && ed.hover.si === si && ed.hover.pi === pi; |
| const isDrag = ed.drag && ed.drag.si === si && ed.drag.pi === pi; |
| const baseR = |
| isHov || isDrag |
| ? ACTIVE_POINT_RADIUS |
| : isActive |
| ? POINT_RADIUS |
| : POINT_RADIUS * 0.8; |
| const r = baseR * dpr; |
|
|
| |
| ctx.beginPath(); |
| ctx.arc(cp.x, cp.y, r + 2 * dpr, 0, Math.PI * 2); |
| ctx.fillStyle = isActive ? "#fff" : "rgba(255,255,255,0.5)"; |
| ctx.fill(); |
|
|
| |
| ctx.beginPath(); |
| ctx.arc(cp.x, cp.y, r, 0, Math.PI * 2); |
| ctx.fillStyle = color; |
| ctx.fill(); |
|
|
| |
| if (isActive) { |
| ctx.fillStyle = "#fff"; |
| ctx.font = `bold ${Math.round(11 * dpr)}px system-ui`; |
| ctx.textAlign = "center"; |
| ctx.fillText(String(pi), cp.x, cp.y - r - 4 * dpr); |
| } |
| } |
| } |
|
|
| |
| if (ed.splines.length > 1) { |
| const dpr = window.devicePixelRatio || 1; |
| ctx.fillStyle = "rgba(0,0,0,0.5)"; |
| ctx.fillRect(4 * dpr, 4 * dpr, 130 * dpr, 20 * dpr); |
| ctx.fillStyle = SPLINE_COLORS[ed.active % SPLINE_COLORS.length]; |
| ctx.font = `bold ${11 * dpr}px system-ui`; |
| ctx.textAlign = "left"; |
| ctx.fillText( |
| `Spline ${ed.active + 1} / ${ed.splines.length}`, |
| 10 * dpr, |
| 18 * dpr |
| ); |
| } |
| } |
|
|